Restructure backend into services and fix security, crash and sync issues
Security - Serve character images through os.Root so names like ../ can't escape the characters folder - Sync start and /sync/reset are POST only (legacy and /api/v1) - /api/v1/token/cleanup requires a token, password compared in constant time, tokens no longer logged - Echo's rate limiter replaces the custom one; TRUSTED_PROXIES enables X-Forwarded-For for reverse proxies Crashes and races - Sync state lives in SyncService with atomics and a mutex instead of unsynchronised globals (fixes concurrent map writes during full sync) - Only one sync can run; /api/v1 sync and music routes are locked during sync like the legacy ones - No log.Fatal outside startup: errors become 4xx/5xx responses - Search uses an in-order character match instead of unescaped regexes - Empty queue / no songs return 404 instead of panicking or looping Behaviour - Sync result is computed once and stays the same between polls - Swagger and /openapi serve the embedded spec - Shutdown stops HTTP before closing the database - Expired tokens are deleted with a context that outlives the request - Files are served with Range support and correct content types; audio has no write deadline Performance - Migration 000006 adds soundtrack.fingerprint (names, sizes, mtimes) so unchanged folders are skipped without reading any files - Songs are reconciled per soundtrack with one query plus needed writes; renames keep play counts - Moved MUSIC_PATH updates soundtrack and song paths - .id cleanup only runs for changed soundtracks or a full sync - Classic random uses one SQL query; Gitea release lookups are cached with a timeout Structure - internal/config reads all environment variables once - sqlc emits a Querier interface; backend globals become services injected into handlers - One route registration shared by legacy and /api/v1 - Removed dead code (dbHelper.go, unused middleware, duplicate Gitea functions, embedded Swagger UI copy) and lib/pq - templ and swag pinned as go tools
This commit is contained in:
1 parent
75b6f0c1cf
commit
090e794460
78 files changed
+2812
-6196
No files matched your search
+201
-217
@@ -1,23 +1,23 @@
|
||||
// Package docs GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
|
||||
// This file was generated by swaggo/swag
|
||||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"text/template"
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
"github.com/swaggo/swag"
|
||||
)
|
||||
|
||||
var doc = `{
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"contact": {},
|
||||
"termsOfService": "http://sanplex.xyz/terms/",
|
||||
"contact": {
|
||||
"name": "Sebastian Olsson",
|
||||
"email": "zarnor91@gmail.com"
|
||||
},
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "http://opensource.org/licenses/MIT"
|
||||
},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
@@ -26,9 +26,6 @@ var doc = `{
|
||||
"/api/v1/statistics/songs/least-played": {
|
||||
"get": {
|
||||
"description": "Returns the top N least played songs with their soundtrack info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -39,7 +36,7 @@ var doc = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -78,9 +75,6 @@ var doc = `{
|
||||
"/api/v1/statistics/songs/most-played": {
|
||||
"get": {
|
||||
"description": "Returns the top N most played songs with their soundtrack info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -91,7 +85,7 @@ var doc = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -129,10 +123,7 @@ var doc = `{
|
||||
},
|
||||
"/api/v1/statistics/soundtracks/last-played": {
|
||||
"get": {
|
||||
"description": "Returns the most recently played soundtracks",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the most recently played soundtracks with their songs",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -143,7 +134,7 @@ var doc = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -182,9 +173,6 @@ var doc = `{
|
||||
"/api/v1/statistics/soundtracks/least-played": {
|
||||
"get": {
|
||||
"description": "Returns the top N least played soundtracks with their songs",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -195,7 +183,7 @@ var doc = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -234,9 +222,6 @@ var doc = `{
|
||||
"/api/v1/statistics/soundtracks/most-played": {
|
||||
"get": {
|
||||
"description": "Returns the top N most played soundtracks with their songs",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -247,7 +232,7 @@ var doc = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -285,10 +270,7 @@ var doc = `{
|
||||
},
|
||||
"/api/v1/statistics/soundtracks/never-played": {
|
||||
"get": {
|
||||
"description": "Returns all soundtracks that have never been played (times_played = 0)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns all soundtracks that have never been played",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -320,10 +302,7 @@ var doc = `{
|
||||
},
|
||||
"/api/v1/statistics/soundtracks/oldest-played": {
|
||||
"get": {
|
||||
"description": "Returns the least recently played soundtracks (that have been played at least once)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the least recently played soundtracks with their songs",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -334,7 +313,7 @@ var doc = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -372,10 +351,7 @@ var doc = `{
|
||||
},
|
||||
"/api/v1/statistics/summary": {
|
||||
"get": {
|
||||
"description": "Returns overall statistics about the music library",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns overall play statistics",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -404,7 +380,7 @@ var doc = `{
|
||||
},
|
||||
"/api/v1/token": {
|
||||
"post": {
|
||||
"description": "Returns a new session token for API access. Requires valid API password.",
|
||||
"description": "Returns a new session token for API access. Requires the API password when one is configured.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -451,6 +427,15 @@ var doc = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
@@ -464,9 +449,6 @@ var doc = `{
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes the current session token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -517,9 +499,6 @@ var doc = `{
|
||||
"/api/v1/token/cleanup": {
|
||||
"post": {
|
||||
"description": "Removes all expired session tokens from the database",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -541,7 +520,9 @@ var doc = `{
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
@@ -568,11 +549,9 @@ var doc = `{
|
||||
"/character": {
|
||||
"get": {
|
||||
"description": "Returns the image for a specific character",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"image/png"
|
||||
"image/png",
|
||||
"image/jpeg"
|
||||
],
|
||||
"tags": [
|
||||
"characters"
|
||||
@@ -581,7 +560,7 @@ var doc = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Character name",
|
||||
"description": "Character file name",
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -593,6 +572,18 @@ var doc = `{
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid name",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "not found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,9 +591,6 @@ var doc = `{
|
||||
"/characters": {
|
||||
"get": {
|
||||
"description": "Returns a list of all available characters",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -625,10 +613,7 @@ var doc = `{
|
||||
},
|
||||
"/download": {
|
||||
"get": {
|
||||
"description": "Checks for the latest version of the application",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the latest version of the client application",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -642,6 +627,12 @@ var doc = `{
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "upstream service unavailable",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -649,9 +640,6 @@ var doc = `{
|
||||
"/download/linux": {
|
||||
"get": {
|
||||
"description": "Redirects to download the latest Linux version",
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"download"
|
||||
],
|
||||
@@ -662,6 +650,18 @@ var doc = `{
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "not found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "upstream service unavailable",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,9 +669,6 @@ var doc = `{
|
||||
"/download/list": {
|
||||
"get": {
|
||||
"description": "Lists all assets available for the latest version",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -688,6 +685,12 @@ var doc = `{
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "upstream service unavailable",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,9 +698,6 @@ var doc = `{
|
||||
"/download/windows": {
|
||||
"get": {
|
||||
"description": "Redirects to download the latest Windows version",
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"download"
|
||||
],
|
||||
@@ -708,6 +708,18 @@ var doc = `{
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "not found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "upstream service unavailable",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,9 +727,6 @@ var doc = `{
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Returns the health status of the server",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -729,18 +738,18 @@ var doc = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/music": {
|
||||
"get": {
|
||||
"description": "Returns a specific song by name",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the song at the given position in the queue",
|
||||
"produces": [
|
||||
"audio/mpeg"
|
||||
],
|
||||
@@ -750,8 +759,8 @@ var doc = `{
|
||||
"summary": "Get a specific song",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Song name",
|
||||
"type": "integer",
|
||||
"description": "Song number in the queue",
|
||||
"name": "song",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -764,8 +773,14 @@ var doc = `{
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"206": {
|
||||
"description": "Partial Content",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "song can't be empty",
|
||||
"description": "song must be a number",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -787,17 +802,14 @@ var doc = `{
|
||||
},
|
||||
"/music/addPlayed": {
|
||||
"get": {
|
||||
"description": "Adds the latest song to the played list",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Marks the current song as played",
|
||||
"tags": [
|
||||
"music"
|
||||
],
|
||||
"summary": "Add latest to played",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"423": {
|
||||
"description": "Syncing is in progress",
|
||||
@@ -811,16 +823,13 @@ var doc = `{
|
||||
"/music/addQue": {
|
||||
"get": {
|
||||
"description": "Adds the latest song to the queue",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"music"
|
||||
],
|
||||
"summary": "Add latest to queue",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"423": {
|
||||
"description": "Syncing is in progress",
|
||||
@@ -834,9 +843,6 @@ var doc = `{
|
||||
"/music/all/order": {
|
||||
"get": {
|
||||
"description": "Returns a list of all soundtracks in order",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -850,8 +856,7 @@ var doc = `{
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -867,9 +872,6 @@ var doc = `{
|
||||
"/music/all/random": {
|
||||
"get": {
|
||||
"description": "Returns a list of all soundtracks in random order",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -883,8 +885,7 @@ var doc = `{
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -900,9 +901,6 @@ var doc = `{
|
||||
"/music/info": {
|
||||
"get": {
|
||||
"description": "Returns information about the current song",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -914,8 +912,7 @@ var doc = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/backend.SongInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -924,9 +921,6 @@ var doc = `{
|
||||
"/music/list": {
|
||||
"get": {
|
||||
"description": "Returns a list of played songs",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -940,8 +934,7 @@ var doc = `{
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/backend.SongInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -982,13 +975,7 @@ var doc = `{
|
||||
},
|
||||
"/music/played": {
|
||||
"put": {
|
||||
"description": "Marks a song as played by its ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Marks the song at the given queue position as played",
|
||||
"tags": [
|
||||
"music"
|
||||
],
|
||||
@@ -996,15 +983,15 @@ var doc = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Song ID",
|
||||
"description": "Song number in the queue",
|
||||
"name": "song",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
@@ -1087,7 +1074,7 @@ var doc = `{
|
||||
},
|
||||
"/music/rand/classic": {
|
||||
"get": {
|
||||
"description": "Returns a random song from the classic selection",
|
||||
"description": "Returns a random song where every song has the same chance",
|
||||
"produces": [
|
||||
"audio/mpeg"
|
||||
],
|
||||
@@ -1119,7 +1106,7 @@ var doc = `{
|
||||
},
|
||||
"/music/rand/low": {
|
||||
"get": {
|
||||
"description": "Returns a random song with low chance selection",
|
||||
"description": "Returns a random song, favouring soundtracks that have been played less",
|
||||
"produces": [
|
||||
"audio/mpeg"
|
||||
],
|
||||
@@ -1152,16 +1139,13 @@ var doc = `{
|
||||
"/music/reset": {
|
||||
"get": {
|
||||
"description": "Resets the music state",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"music"
|
||||
],
|
||||
"summary": "Reset music state",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"423": {
|
||||
"description": "Syncing is in progress",
|
||||
@@ -1205,11 +1189,8 @@ var doc = `{
|
||||
}
|
||||
},
|
||||
"/sync": {
|
||||
"get": {
|
||||
"description": "Starts syncing soundtracks with only new changes",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"post": {
|
||||
"description": "Starts syncing soundtracks that have changed",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1234,11 +1215,8 @@ var doc = `{
|
||||
}
|
||||
},
|
||||
"/sync/full": {
|
||||
"get": {
|
||||
"post": {
|
||||
"description": "Starts a full sync of all soundtracks",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1264,10 +1242,7 @@ var doc = `{
|
||||
},
|
||||
"/sync/progress": {
|
||||
"get": {
|
||||
"description": "Returns the current sync progress or result",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the progress of the running sync, or the result of the last sync",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1279,19 +1254,15 @@ var doc = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/backend.SyncResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/reset": {
|
||||
"get": {
|
||||
"post": {
|
||||
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1318,9 +1289,6 @@ var doc = `{
|
||||
"/version": {
|
||||
"get": {
|
||||
"description": "get latest version info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1334,12 +1302,6 @@ var doc = `{
|
||||
"schema": {
|
||||
"$ref": "#/definitions/backend.VersionData"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1347,9 +1309,6 @@ var doc = `{
|
||||
"/version/history": {
|
||||
"get": {
|
||||
"description": "get version history",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1366,18 +1325,35 @@ var doc = `{
|
||||
"$ref": "#/definitions/backend.VersionData"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"backend.SongInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"CurrentlyPlaying": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Song": {
|
||||
"type": "string"
|
||||
},
|
||||
"SongNo": {
|
||||
"type": "integer"
|
||||
},
|
||||
"SongPlayed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"Soundtrack": {
|
||||
"type": "string"
|
||||
},
|
||||
"SoundtrackPlayed": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend.SongInfoForStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1450,6 +1426,50 @@ var doc = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend.SyncResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"catched_errors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_added": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_changed_content": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_changed_title": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_re_added": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_removed": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"total_time": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend.VersionData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1477,7 +1497,7 @@ var doc = `{
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "Required: API password",
|
||||
"description": "API password, required when API_PASSWORD is set",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -1499,56 +1519,20 @@ var doc = `{
|
||||
}
|
||||
}`
|
||||
|
||||
type swaggerInfo struct {
|
||||
Version string
|
||||
Host string
|
||||
BasePath string
|
||||
Schemes []string
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = swaggerInfo{
|
||||
Version: "",
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "1.0",
|
||||
Host: "",
|
||||
BasePath: "",
|
||||
BasePath: "/",
|
||||
Schemes: []string{},
|
||||
Title: "",
|
||||
Description: "",
|
||||
}
|
||||
|
||||
type s struct{}
|
||||
|
||||
func (s *s) ReadDoc() string {
|
||||
sInfo := SwaggerInfo
|
||||
sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1)
|
||||
|
||||
t, err := template.New("swagger_info").Funcs(template.FuncMap{
|
||||
"marshal": func(v interface{}) string {
|
||||
a, _ := json.Marshal(v)
|
||||
return string(a)
|
||||
},
|
||||
"escape": func(v interface{}) string {
|
||||
// escape tabs
|
||||
str := strings.Replace(v.(string), "\t", "\\t", -1)
|
||||
// replace " with \", and if that results in \\", replace that with \\\"
|
||||
str = strings.Replace(str, "\"", "\\\"", -1)
|
||||
return strings.Replace(str, "\\\\\"", "\\\\\\\"", -1)
|
||||
},
|
||||
}).Parse(doc)
|
||||
if err != nil {
|
||||
return doc
|
||||
}
|
||||
|
||||
var tpl bytes.Buffer
|
||||
if err := t.Execute(&tpl, sInfo); err != nil {
|
||||
return doc
|
||||
}
|
||||
|
||||
return tpl.String()
|
||||
Title: "MusicServer API",
|
||||
Description: "API for the MusicServer application",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register("swagger", &s{})
|
||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||
}
|
||||
+192
-160
@@ -1,15 +1,24 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"contact": {}
|
||||
"description": "API for the MusicServer application",
|
||||
"title": "MusicServer API",
|
||||
"termsOfService": "http://sanplex.xyz/terms/",
|
||||
"contact": {
|
||||
"name": "Sebastian Olsson",
|
||||
"email": "zarnor91@gmail.com"
|
||||
},
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "http://opensource.org/licenses/MIT"
|
||||
},
|
||||
"version": "1.0"
|
||||
},
|
||||
"basePath": "/",
|
||||
"paths": {
|
||||
"/api/v1/statistics/songs/least-played": {
|
||||
"get": {
|
||||
"description": "Returns the top N least played songs with their soundtrack info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -20,7 +29,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -59,9 +68,6 @@
|
||||
"/api/v1/statistics/songs/most-played": {
|
||||
"get": {
|
||||
"description": "Returns the top N most played songs with their soundtrack info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -72,7 +78,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -110,10 +116,7 @@
|
||||
},
|
||||
"/api/v1/statistics/soundtracks/last-played": {
|
||||
"get": {
|
||||
"description": "Returns the most recently played soundtracks",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the most recently played soundtracks with their songs",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -124,7 +127,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -163,9 +166,6 @@
|
||||
"/api/v1/statistics/soundtracks/least-played": {
|
||||
"get": {
|
||||
"description": "Returns the top N least played soundtracks with their songs",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -176,7 +176,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -215,9 +215,6 @@
|
||||
"/api/v1/statistics/soundtracks/most-played": {
|
||||
"get": {
|
||||
"description": "Returns the top N most played soundtracks with their songs",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -228,7 +225,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -266,10 +263,7 @@
|
||||
},
|
||||
"/api/v1/statistics/soundtracks/never-played": {
|
||||
"get": {
|
||||
"description": "Returns all soundtracks that have never been played (times_played = 0)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns all soundtracks that have never been played",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -301,10 +295,7 @@
|
||||
},
|
||||
"/api/v1/statistics/soundtracks/oldest-played": {
|
||||
"get": {
|
||||
"description": "Returns the least recently played soundtracks (that have been played at least once)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the least recently played soundtracks with their songs",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -315,7 +306,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Number of results (default: 10)",
|
||||
"description": "Number of results (default: 10, max: 100)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -353,10 +344,7 @@
|
||||
},
|
||||
"/api/v1/statistics/summary": {
|
||||
"get": {
|
||||
"description": "Returns overall statistics about the music library",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns overall play statistics",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -385,7 +373,7 @@
|
||||
},
|
||||
"/api/v1/token": {
|
||||
"post": {
|
||||
"description": "Returns a new session token for API access. Requires valid API password.",
|
||||
"description": "Returns a new session token for API access. Requires the API password when one is configured.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -432,6 +420,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
@@ -445,9 +442,6 @@
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes the current session token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -498,9 +492,6 @@
|
||||
"/api/v1/token/cleanup": {
|
||||
"post": {
|
||||
"description": "Removes all expired session tokens from the database",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -522,7 +513,9 @@
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
@@ -549,11 +542,9 @@
|
||||
"/character": {
|
||||
"get": {
|
||||
"description": "Returns the image for a specific character",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"image/png"
|
||||
"image/png",
|
||||
"image/jpeg"
|
||||
],
|
||||
"tags": [
|
||||
"characters"
|
||||
@@ -562,7 +553,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Character name",
|
||||
"description": "Character file name",
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -574,6 +565,18 @@
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid name",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "not found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,9 +584,6 @@
|
||||
"/characters": {
|
||||
"get": {
|
||||
"description": "Returns a list of all available characters",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -606,10 +606,7 @@
|
||||
},
|
||||
"/download": {
|
||||
"get": {
|
||||
"description": "Checks for the latest version of the application",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the latest version of the client application",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -623,6 +620,12 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "upstream service unavailable",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,9 +633,6 @@
|
||||
"/download/linux": {
|
||||
"get": {
|
||||
"description": "Redirects to download the latest Linux version",
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"download"
|
||||
],
|
||||
@@ -643,6 +643,18 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "not found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "upstream service unavailable",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -650,9 +662,6 @@
|
||||
"/download/list": {
|
||||
"get": {
|
||||
"description": "Lists all assets available for the latest version",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -669,6 +678,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "upstream service unavailable",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,9 +691,6 @@
|
||||
"/download/windows": {
|
||||
"get": {
|
||||
"description": "Redirects to download the latest Windows version",
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"download"
|
||||
],
|
||||
@@ -689,6 +701,18 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "not found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "upstream service unavailable",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,9 +720,6 @@
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Returns the health status of the server",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -710,18 +731,18 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/music": {
|
||||
"get": {
|
||||
"description": "Returns a specific song by name",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the song at the given position in the queue",
|
||||
"produces": [
|
||||
"audio/mpeg"
|
||||
],
|
||||
@@ -731,8 +752,8 @@
|
||||
"summary": "Get a specific song",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Song name",
|
||||
"type": "integer",
|
||||
"description": "Song number in the queue",
|
||||
"name": "song",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -745,8 +766,14 @@
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"206": {
|
||||
"description": "Partial Content",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "song can't be empty",
|
||||
"description": "song must be a number",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -768,17 +795,14 @@
|
||||
},
|
||||
"/music/addPlayed": {
|
||||
"get": {
|
||||
"description": "Adds the latest song to the played list",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Marks the current song as played",
|
||||
"tags": [
|
||||
"music"
|
||||
],
|
||||
"summary": "Add latest to played",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"423": {
|
||||
"description": "Syncing is in progress",
|
||||
@@ -792,16 +816,13 @@
|
||||
"/music/addQue": {
|
||||
"get": {
|
||||
"description": "Adds the latest song to the queue",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"music"
|
||||
],
|
||||
"summary": "Add latest to queue",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"423": {
|
||||
"description": "Syncing is in progress",
|
||||
@@ -815,9 +836,6 @@
|
||||
"/music/all/order": {
|
||||
"get": {
|
||||
"description": "Returns a list of all soundtracks in order",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -831,8 +849,7 @@
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,9 +865,6 @@
|
||||
"/music/all/random": {
|
||||
"get": {
|
||||
"description": "Returns a list of all soundtracks in random order",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -864,8 +878,7 @@
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -881,9 +894,6 @@
|
||||
"/music/info": {
|
||||
"get": {
|
||||
"description": "Returns information about the current song",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -895,8 +905,7 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/backend.SongInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -905,9 +914,6 @@
|
||||
"/music/list": {
|
||||
"get": {
|
||||
"description": "Returns a list of played songs",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -921,8 +927,7 @@
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/backend.SongInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -963,13 +968,7 @@
|
||||
},
|
||||
"/music/played": {
|
||||
"put": {
|
||||
"description": "Marks a song as played by its ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Marks the song at the given queue position as played",
|
||||
"tags": [
|
||||
"music"
|
||||
],
|
||||
@@ -977,15 +976,15 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Song ID",
|
||||
"description": "Song number in the queue",
|
||||
"name": "song",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
@@ -1068,7 +1067,7 @@
|
||||
},
|
||||
"/music/rand/classic": {
|
||||
"get": {
|
||||
"description": "Returns a random song from the classic selection",
|
||||
"description": "Returns a random song where every song has the same chance",
|
||||
"produces": [
|
||||
"audio/mpeg"
|
||||
],
|
||||
@@ -1100,7 +1099,7 @@
|
||||
},
|
||||
"/music/rand/low": {
|
||||
"get": {
|
||||
"description": "Returns a random song with low chance selection",
|
||||
"description": "Returns a random song, favouring soundtracks that have been played less",
|
||||
"produces": [
|
||||
"audio/mpeg"
|
||||
],
|
||||
@@ -1133,16 +1132,13 @@
|
||||
"/music/reset": {
|
||||
"get": {
|
||||
"description": "Resets the music state",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"music"
|
||||
],
|
||||
"summary": "Reset music state",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"423": {
|
||||
"description": "Syncing is in progress",
|
||||
@@ -1186,11 +1182,8 @@
|
||||
}
|
||||
},
|
||||
"/sync": {
|
||||
"get": {
|
||||
"description": "Starts syncing soundtracks with only new changes",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"post": {
|
||||
"description": "Starts syncing soundtracks that have changed",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1215,11 +1208,8 @@
|
||||
}
|
||||
},
|
||||
"/sync/full": {
|
||||
"get": {
|
||||
"post": {
|
||||
"description": "Starts a full sync of all soundtracks",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1245,10 +1235,7 @@
|
||||
},
|
||||
"/sync/progress": {
|
||||
"get": {
|
||||
"description": "Returns the current sync progress or result",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns the progress of the running sync, or the result of the last sync",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1260,19 +1247,15 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/backend.SyncResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/reset": {
|
||||
"get": {
|
||||
"post": {
|
||||
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1299,9 +1282,6 @@
|
||||
"/version": {
|
||||
"get": {
|
||||
"description": "get latest version info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1315,12 +1295,6 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/backend.VersionData"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1328,9 +1302,6 @@
|
||||
"/version/history": {
|
||||
"get": {
|
||||
"description": "get version history",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1347,18 +1318,35 @@
|
||||
"$ref": "#/definitions/backend.VersionData"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"backend.SongInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"CurrentlyPlaying": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Song": {
|
||||
"type": "string"
|
||||
},
|
||||
"SongNo": {
|
||||
"type": "integer"
|
||||
},
|
||||
"SongPlayed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"Soundtrack": {
|
||||
"type": "string"
|
||||
},
|
||||
"SoundtrackPlayed": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend.SongInfoForStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1431,6 +1419,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend.SyncResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"catched_errors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_added": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_changed_content": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_changed_title": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_re_added": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"soundtracks_removed": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"total_time": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend.VersionData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1458,7 +1490,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "Required: API password",
|
||||
"description": "API password, required when API_PASSWORD is set",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
||||
+146
-125
@@ -1,4 +1,20 @@
|
||||
basePath: /
|
||||
definitions:
|
||||
backend.SongInfo:
|
||||
properties:
|
||||
CurrentlyPlaying:
|
||||
type: boolean
|
||||
Song:
|
||||
type: string
|
||||
SongNo:
|
||||
type: integer
|
||||
SongPlayed:
|
||||
type: integer
|
||||
Soundtrack:
|
||||
type: string
|
||||
SoundtrackPlayed:
|
||||
type: integer
|
||||
type: object
|
||||
backend.SongInfoForStats:
|
||||
properties:
|
||||
file_name:
|
||||
@@ -46,6 +62,35 @@ definitions:
|
||||
total_soundtracks:
|
||||
type: integer
|
||||
type: object
|
||||
backend.SyncResponse:
|
||||
properties:
|
||||
catched_errors:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
soundtracks_added:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
soundtracks_changed_content:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
soundtracks_changed_title:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
soundtracks_re_added:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
soundtracks_removed:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
total_time:
|
||||
type: string
|
||||
type: object
|
||||
backend.VersionData:
|
||||
properties:
|
||||
changelog:
|
||||
@@ -65,7 +110,7 @@ definitions:
|
||||
description: 'Optional: "web", "mobile", "api"'
|
||||
type: string
|
||||
password:
|
||||
description: 'Required: API password'
|
||||
description: API password, required when API_PASSWORD is set
|
||||
type: string
|
||||
type: object
|
||||
server.TokenResponse:
|
||||
@@ -78,15 +123,22 @@ definitions:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
contact:
|
||||
email: zarnor91@gmail.com
|
||||
name: Sebastian Olsson
|
||||
description: API for the MusicServer application
|
||||
license:
|
||||
name: MIT
|
||||
url: http://opensource.org/licenses/MIT
|
||||
termsOfService: http://sanplex.xyz/terms/
|
||||
title: MusicServer API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/api/v1/statistics/songs/least-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the top N least played songs with their soundtrack info
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
@@ -116,11 +168,9 @@ paths:
|
||||
- statistics
|
||||
/api/v1/statistics/songs/most-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the top N most played songs with their soundtrack info
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
@@ -150,11 +200,9 @@ paths:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/last-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the most recently played soundtracks
|
||||
description: Returns the most recently played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
@@ -184,11 +232,9 @@ paths:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/least-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the top N least played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
@@ -218,11 +264,9 @@ paths:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/most-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the top N most played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
@@ -252,10 +296,7 @@ paths:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/never-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns all soundtracks that have never been played (times_played
|
||||
= 0)
|
||||
description: Returns all soundtracks that have never been played
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -276,12 +317,9 @@ paths:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/oldest-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the least recently played soundtracks (that have been played
|
||||
at least once)
|
||||
description: Returns the least recently played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
@@ -311,9 +349,7 @@ paths:
|
||||
- statistics
|
||||
/api/v1/statistics/summary:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns overall statistics about the music library
|
||||
description: Returns overall play statistics
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -332,8 +368,6 @@ paths:
|
||||
- statistics
|
||||
/api/v1/token:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Deletes the current session token
|
||||
parameters:
|
||||
- description: Bearer token
|
||||
@@ -368,8 +402,8 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a new session token for API access. Requires valid API
|
||||
password.
|
||||
description: Returns a new session token for API access. Requires the API password
|
||||
when one is configured.
|
||||
parameters:
|
||||
- description: Password and client type
|
||||
in: body
|
||||
@@ -396,6 +430,12 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"429":
|
||||
description: Too Many Requests
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
@@ -407,8 +447,6 @@ paths:
|
||||
- auth
|
||||
/api/v1/token/cleanup:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Removes all expired session tokens from the database
|
||||
parameters:
|
||||
- description: Bearer token
|
||||
@@ -422,7 +460,8 @@ paths:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
@@ -441,29 +480,34 @@ paths:
|
||||
- auth
|
||||
/character:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the image for a specific character
|
||||
parameters:
|
||||
- description: Character name
|
||||
- description: Character file name
|
||||
in: query
|
||||
name: name
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- image/png
|
||||
- image/jpeg
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
"400":
|
||||
description: invalid name
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: not found
|
||||
schema:
|
||||
type: string
|
||||
summary: Get character image
|
||||
tags:
|
||||
- characters
|
||||
/characters:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a list of all available characters
|
||||
produces:
|
||||
- application/json
|
||||
@@ -479,9 +523,7 @@ paths:
|
||||
- characters
|
||||
/download:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Checks for the latest version of the application
|
||||
description: Returns the latest version of the client application
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -489,26 +531,34 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
type: string
|
||||
"502":
|
||||
description: upstream service unavailable
|
||||
schema:
|
||||
type: string
|
||||
summary: Check for latest version
|
||||
tags:
|
||||
- download
|
||||
/download/linux:
|
||||
get:
|
||||
description: Redirects to download the latest Linux version
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"302":
|
||||
description: Found
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: not found
|
||||
schema:
|
||||
type: string
|
||||
"502":
|
||||
description: upstream service unavailable
|
||||
schema:
|
||||
type: string
|
||||
summary: Download latest Linux version
|
||||
tags:
|
||||
- download
|
||||
/download/list:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Lists all assets available for the latest version
|
||||
produces:
|
||||
- application/json
|
||||
@@ -519,26 +569,34 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
"502":
|
||||
description: upstream service unavailable
|
||||
schema:
|
||||
type: string
|
||||
summary: List assets of latest version
|
||||
tags:
|
||||
- download
|
||||
/download/windows:
|
||||
get:
|
||||
description: Redirects to download the latest Windows version
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"302":
|
||||
description: Found
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: not found
|
||||
schema:
|
||||
type: string
|
||||
"502":
|
||||
description: upstream service unavailable
|
||||
schema:
|
||||
type: string
|
||||
summary: Download latest Windows version
|
||||
tags:
|
||||
- download
|
||||
/health:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the health status of the server
|
||||
produces:
|
||||
- application/json
|
||||
@@ -546,21 +604,21 @@ paths:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Check server health
|
||||
tags:
|
||||
- health
|
||||
/music:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a specific song by name
|
||||
description: Returns the song at the given position in the queue
|
||||
parameters:
|
||||
- description: Song name
|
||||
- description: Song number in the queue
|
||||
in: query
|
||||
name: song
|
||||
required: true
|
||||
type: string
|
||||
type: integer
|
||||
produces:
|
||||
- audio/mpeg
|
||||
responses:
|
||||
@@ -568,8 +626,12 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
"206":
|
||||
description: Partial Content
|
||||
schema:
|
||||
type: file
|
||||
"400":
|
||||
description: song can't be empty
|
||||
description: song must be a number
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
@@ -585,12 +647,10 @@ paths:
|
||||
- music
|
||||
/music/addPlayed:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Adds the latest song to the played list
|
||||
description: Marks the current song as played
|
||||
responses:
|
||||
"204":
|
||||
description: ""
|
||||
"200":
|
||||
description: OK
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
@@ -600,12 +660,10 @@ paths:
|
||||
- music
|
||||
/music/addQue:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Adds the latest song to the queue
|
||||
responses:
|
||||
"204":
|
||||
description: ""
|
||||
"200":
|
||||
description: OK
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
@@ -615,8 +673,6 @@ paths:
|
||||
- music
|
||||
/music/all/order:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a list of all soundtracks in order
|
||||
produces:
|
||||
- application/json
|
||||
@@ -625,8 +681,7 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: string
|
||||
type: array
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
@@ -637,8 +692,6 @@ paths:
|
||||
- music
|
||||
/music/all/random:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a list of all soundtracks in random order
|
||||
produces:
|
||||
- application/json
|
||||
@@ -647,8 +700,7 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: string
|
||||
type: array
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
@@ -659,8 +711,6 @@ paths:
|
||||
- music
|
||||
/music/info:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns information about the current song
|
||||
produces:
|
||||
- application/json
|
||||
@@ -668,15 +718,12 @@ paths:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
$ref: '#/definitions/backend.SongInfo'
|
||||
summary: Get current song info
|
||||
tags:
|
||||
- music
|
||||
/music/list:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a list of played songs
|
||||
produces:
|
||||
- application/json
|
||||
@@ -685,8 +732,7 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
$ref: '#/definitions/backend.SongInfo'
|
||||
type: array
|
||||
summary: Get played songs list
|
||||
tags:
|
||||
@@ -714,20 +760,16 @@ paths:
|
||||
- music
|
||||
/music/played:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Marks a song as played by its ID
|
||||
description: Marks the song at the given queue position as played
|
||||
parameters:
|
||||
- description: Song ID
|
||||
- description: Song number in the queue
|
||||
in: query
|
||||
name: song
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: ""
|
||||
"200":
|
||||
description: OK
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
@@ -783,7 +825,7 @@ paths:
|
||||
- music
|
||||
/music/rand/classic:
|
||||
get:
|
||||
description: Returns a random song from the classic selection
|
||||
description: Returns a random song where every song has the same chance
|
||||
produces:
|
||||
- audio/mpeg
|
||||
responses:
|
||||
@@ -804,7 +846,8 @@ paths:
|
||||
- music
|
||||
/music/rand/low:
|
||||
get:
|
||||
description: Returns a random song with low chance selection
|
||||
description: Returns a random song, favouring soundtracks that have been played
|
||||
less
|
||||
produces:
|
||||
- audio/mpeg
|
||||
responses:
|
||||
@@ -825,12 +868,10 @@ paths:
|
||||
- music
|
||||
/music/reset:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Resets the music state
|
||||
responses:
|
||||
"204":
|
||||
description: ""
|
||||
"200":
|
||||
description: OK
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
@@ -860,10 +901,8 @@ paths:
|
||||
tags:
|
||||
- music
|
||||
/sync:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Starts syncing soundtracks with only new changes
|
||||
post:
|
||||
description: Starts syncing soundtracks that have changed
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -879,9 +918,7 @@ paths:
|
||||
tags:
|
||||
- sync
|
||||
/sync/full:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
post:
|
||||
description: Starts a full sync of all soundtracks
|
||||
produces:
|
||||
- application/json
|
||||
@@ -899,24 +936,20 @@ paths:
|
||||
- sync
|
||||
/sync/progress:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the current sync progress or result
|
||||
description: Returns the progress of the running sync, or the result of the
|
||||
last sync
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
$ref: '#/definitions/backend.SyncResponse'
|
||||
summary: Get sync progress
|
||||
tags:
|
||||
- sync
|
||||
/sync/reset:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
post:
|
||||
description: Resets the soundtracks database by deleting all soundtracks and
|
||||
songs
|
||||
produces:
|
||||
@@ -935,8 +968,6 @@ paths:
|
||||
- sync
|
||||
/version:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: get latest version info
|
||||
produces:
|
||||
- application/json
|
||||
@@ -945,17 +976,11 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/backend.VersionData'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
type: string
|
||||
summary: Getting the latest version of the backend
|
||||
tags:
|
||||
- version
|
||||
/version/history:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: get version history
|
||||
produces:
|
||||
- application/json
|
||||
@@ -966,10 +991,6 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/backend.VersionData'
|
||||
type: array
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
type: string
|
||||
summary: Getting the version history of the backend
|
||||
tags:
|
||||
- version
|
||||
|
||||
+19
-62
@@ -2,84 +2,41 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
//
|
||||
// @Title Swagger Example API
|
||||
// @version 0.5
|
||||
// @description This is a sample server Petstore server.
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
const shutdownTimeout = 5 * time.Second
|
||||
|
||||
//
|
||||
// @contact.name Sebastian Olsson
|
||||
// @contact.email zarnor91@gmail.com
|
||||
|
||||
//
|
||||
// @license.name Apache 2.0
|
||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
|
||||
// @host localhost:8080
|
||||
func main() {
|
||||
/*f, perr := os.Create("cpu.pprof")
|
||||
if perr != nil {
|
||||
log.Fatal(perr)
|
||||
}
|
||||
pprof.StartCPUProfile(f)
|
||||
defer pprof.StopCPUProfile()*/
|
||||
|
||||
appServer := server.NewServerInstance()
|
||||
httpServer := appServer.HTTPServer()
|
||||
|
||||
// Create a done channel to signal when the shutdown is complete
|
||||
done := make(chan bool, 1)
|
||||
|
||||
// Run graceful shutdown in a separate goroutine
|
||||
go gracefulShutdown(appServer, httpServer, done)
|
||||
|
||||
logging.GetLogger().Info("Server starting", zap.String("address", httpServer.Addr))
|
||||
err := httpServer.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
// Wait for the graceful shutdown to complete
|
||||
<-done
|
||||
logging.GetLogger().Info("Graceful shutdown complete")
|
||||
}
|
||||
|
||||
func gracefulShutdown(appServer *server.Server, httpServer *http.Server, done chan bool) {
|
||||
// Create context that listens for the interrupt signal from the OS.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Listen for the interrupt signal.
|
||||
<-ctx.Done()
|
||||
s := server.NewServerInstance()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- s.ListenAndServe() }()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("HTTP server error", zap.Error(err))
|
||||
}
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
|
||||
|
||||
// Close database connection
|
||||
if appServer != nil && appServer.DB() != nil {
|
||||
appServer.DB().Close()
|
||||
}
|
||||
|
||||
// The context is used to inform the server it has 5 seconds to finish
|
||||
// the request it is currently handling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
||||
if err := s.Shutdown(shutdownCtx); err != nil {
|
||||
logging.GetLogger().Error("Server forced to shutdown with error", zap.Error(err))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Server exiting")
|
||||
|
||||
// Notify the main goroutine that the shutdown is complete
|
||||
done <- true
|
||||
logging.GetLogger().Info("Graceful shutdown complete")
|
||||
}
|
||||
@@ -4,6 +4,3 @@ import "embed"
|
||||
|
||||
//go:embed "assets"
|
||||
var Assets embed.FS
|
||||
|
||||
//go:embed "swagger"
|
||||
var Swagger embed.FS
|
||||
+30
-50
@@ -1,77 +1,57 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"music-server/internal/backend"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var soundtracks_added []string
|
||||
|
||||
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
// NewSearchHandler returns a handler that renders the soundtracks matching the posted search term.
|
||||
func NewSearchHandler(cache *backend.SearchCache) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
search_term := r.FormValue("search_term")
|
||||
search(search_term)
|
||||
results := soundtracks_added
|
||||
|
||||
component := FoundSoundtracks(results)
|
||||
err = component.Render(r.Context(), w)
|
||||
soundtracks, err := cache.Get(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Fatalf("Error rendering in FindSoundtrackWebHandler: %e", err)
|
||||
logging.GetLogger().Error("Failed to load search data", zap.Error(err))
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
results := search(r.FormValue("search_term"), soundtracks)
|
||||
if err := FoundSoundtracks(results).Render(r.Context(), w); err != nil {
|
||||
logging.GetLogger().Error("Failed to render search results", zap.Error(err))
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func search(searchText string) {
|
||||
soundtracks_added = nil
|
||||
soundtrackData := backend.GetSearchCache()
|
||||
results := searchWithData(searchText, soundtrackData)
|
||||
soundtracks_added = results
|
||||
}
|
||||
|
||||
// searchWithData is a testable version of search that takes soundtrack data as a parameter
|
||||
func searchWithData(searchText string, soundtrackData []backend.SoundtrackData) []string {
|
||||
// search returns the soundtracks matching searchText: exact matches first, then matches
|
||||
// ignoring spaces and special characters, then matches with the letters in order.
|
||||
func search(searchText string, soundtrackData []backend.SoundtrackData) []string {
|
||||
var results []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// Precompute cleaned and lowercase search terms once
|
||||
cleanedSearchTerm := strings.ToLower(searchText)
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, " ", "")
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "é", "e")
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "+", "plus")
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "&", "and")
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "'n", "and")
|
||||
lowercaseSearchText := strings.ToLower(searchText)
|
||||
|
||||
// Pass 1: Exact match (case-insensitive contains)
|
||||
add := func(match func(backend.SoundtrackData) bool) {
|
||||
for _, data := range soundtrackData {
|
||||
if !seen[data.Original] && (searchText == "" || strings.Contains(data.Lowercase, lowercaseSearchText)) {
|
||||
if !seen[data.Original] && match(data) {
|
||||
results = append(results, data.Original)
|
||||
seen[data.Original] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Contains match with cleaned terms
|
||||
for _, data := range soundtrackData {
|
||||
if !seen[data.Original] && (cleanedSearchTerm == "" || strings.Contains(data.Cleaned, cleanedSearchTerm)) {
|
||||
results = append(results, data.Original)
|
||||
seen[data.Original] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Regex match with cleaned terms
|
||||
for _, data := range soundtrackData {
|
||||
if !seen[data.Original] && backend.MatchRegexCached(cleanedSearchTerm, data.Cleaned) {
|
||||
results = append(results, data.Original)
|
||||
seen[data.Original] = true
|
||||
}
|
||||
}
|
||||
lowercase := strings.ToLower(searchText)
|
||||
cleaned := backend.CleanSearchTerm(searchText)
|
||||
|
||||
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Lowercase, lowercase) })
|
||||
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Cleaned, cleaned) })
|
||||
add(func(d backend.SoundtrackData) bool { return backend.MatchesSubsequence(cleaned, d.Cleaned) })
|
||||
return results
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"music-server/internal/backend"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCleanTerm tests that search terms are cleaned correctly
|
||||
func TestCleanTerm(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// CleanSearchTermForMatch removes spaces, replaces specific chars, and lowercases
|
||||
{"MGS4", "mgs4"},
|
||||
{"RE2", "re2"},
|
||||
{"FFVII", "ffvii"},
|
||||
{"Ys", "ys"},
|
||||
{" test ", "test"},
|
||||
{"Test 123", "test123"},
|
||||
{"A+B", "aplusb"},
|
||||
{"é", "e"},
|
||||
// Note: CleanSearchTermForMatch does NOT remove apostrophes, it only replaces "'n" with "and"
|
||||
{"It's", "it's"},
|
||||
// But it does replace "'n" with "and"
|
||||
{"I'n", "iand"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := backend.CleanSearchTermForMatch(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CleanSearchTermForMatch(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// searchWithDataHelper is a helper to create backend.SoundtrackData from original names
|
||||
func searchWithDataHelper(originals []string) []backend.SoundtrackData {
|
||||
data := make([]backend.SoundtrackData, len(originals))
|
||||
for i, orig := range originals {
|
||||
data[i] = backend.SoundtrackData{
|
||||
Original: orig,
|
||||
Lowercase: strings.ToLower(orig),
|
||||
Cleaned: backend.CleanSearchTermForMatch(orig),
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestNormalSearch tests the normal search function
|
||||
func TestNormalSearch(t *testing.T) {
|
||||
// Set up comprehensive test data with all user-provided soundtracks
|
||||
testData := searchWithDataHelper([]string{
|
||||
"A Bird Story",
|
||||
"Actraiser",
|
||||
"Actraiser 2",
|
||||
"Bangai-O Spirits",
|
||||
"Ciconia When they Cry",
|
||||
"Civilization IV",
|
||||
"Civilization VI",
|
||||
"Clair Obscur - Expedition 33",
|
||||
"Club House Games (42 All-Time Classics)",
|
||||
"Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv)",
|
||||
"Final Fantasy VII Crisis Core",
|
||||
"Final Fantasy VII Rebirth",
|
||||
"Final Fantasy VII Remake",
|
||||
"Final Fantasy VII",
|
||||
"Joe & Mac",
|
||||
"Judgment",
|
||||
"Kingdom Hearts",
|
||||
"Kingdom Hearts Birth By Sleep",
|
||||
"Kingdom Hearts II",
|
||||
"Kingdom Hearts III",
|
||||
"Lemmings (SNES + Mega Drive)",
|
||||
"Lemmings 3D",
|
||||
"Mega Man X4",
|
||||
"Metal Gear Rising - Revengeance",
|
||||
"Metal Gear Solid (+Twin Snakes)",
|
||||
"Metal Gear Solid 2 - Sons of Liberty",
|
||||
"Metal Gear Solid 3 - Snake Eater",
|
||||
"Metal Gear Solid 4 - Guns of the Patriots",
|
||||
"Metroid Prime",
|
||||
"Octopath Traveler",
|
||||
"Octopath Traveler 2",
|
||||
"Ori and the Blind Forest",
|
||||
"Ori and the Will of the Wisps",
|
||||
"OutRun 2006 - Coast 2 Coast",
|
||||
"Paper Mario - The Thousand Year Door",
|
||||
"Pokémon HeartGold & SoulSilver",
|
||||
"Resident Evil 2 (Remake)",
|
||||
"Shenmue",
|
||||
"Super Mario Galaxy",
|
||||
"Trails in the Sky FC (Sora no Kiseki FC)",
|
||||
"Trails in the Sky SC (Sora no Kiseki SC)",
|
||||
"Turrican II - The Final Fight",
|
||||
"Umineko no Naku Koro ni",
|
||||
"Umineko no Naku Koro ni Chiru",
|
||||
"VVVVVV",
|
||||
"YS VIII - Lacrimosa of DANA",
|
||||
"Yakuza 0",
|
||||
"Ys (Chronicles)",
|
||||
"Ys - Memories of Celceta",
|
||||
"Ys - The Ark of Napishtim",
|
||||
"Ys II (Chronicles)",
|
||||
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
|
||||
"Ys IV - The Dawn of Ys",
|
||||
"Ys Origin",
|
||||
"Ys Seven",
|
||||
"Ys X Nordics",
|
||||
"Call of Duty: Modern Warfare",
|
||||
"Half-Life 3",
|
||||
})
|
||||
|
||||
// Test cases covering all match types
|
||||
tests := []struct {
|
||||
query string
|
||||
wantFound string
|
||||
}{
|
||||
// Exact substring matches
|
||||
{"Metal", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"Resident", "Resident Evil 2 (Remake)"},
|
||||
{"Final", "Final Fantasy VII"},
|
||||
{"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||
|
||||
// Contains matches (on cleaned terms)
|
||||
{"gotp", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"mgs", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"kh", "Kingdom Hearts"},
|
||||
{"mg", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
|
||||
// Regex matches
|
||||
{"mgs4go", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"re2r", "Resident Evil 2 (Remake)"},
|
||||
{"ffv", "Final Fantasy VII"},
|
||||
{"yiwfyt", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||
{"codmw", "Call of Duty: Modern Warfare"},
|
||||
{"mgs4gop", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.query, func(t *testing.T) {
|
||||
results := searchWithData(tt.query, testData)
|
||||
|
||||
// Check that the expected item is in the results
|
||||
found := false
|
||||
for _, result := range results {
|
||||
if result == tt.wantFound {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("searchWithData(%q, ...) did not find expected item %q, got %v", tt.query, tt.wantFound, results)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchOrdering tests that Pass 1 matches come before Pass 2 matches
|
||||
func TestSearchOrdering(t *testing.T) {
|
||||
// "MG" matches Pass 1 (exact substring on original)
|
||||
// "Metal Gear" matches Pass 2 (contains on cleaned) but NOT Pass 1 (no "mg" substring in "metal gear")
|
||||
testData := searchWithDataHelper([]string{
|
||||
"Metal Gear",
|
||||
"MG",
|
||||
})
|
||||
|
||||
results := searchWithData("mg", testData)
|
||||
|
||||
// Find indices
|
||||
indexPass1 := -1
|
||||
indexPass2 := -1
|
||||
for i, result := range results {
|
||||
if result == "MG" {
|
||||
indexPass1 = i
|
||||
}
|
||||
if result == "Metal Gear" {
|
||||
indexPass2 = i
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1 match (MG) should come before Pass 2 match (Metal Gear)
|
||||
if indexPass1 >= 0 && indexPass2 >= 0 && indexPass1 >= indexPass2 {
|
||||
t.Errorf("Expected Pass 1 match 'MG' to come before Pass 2 match 'Metal Gear', but got order: %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The tests above verify:
|
||||
// 1. clean_term - the term cleaning logic
|
||||
// 2. search - the complete normal search with exact, contains, and regex matching
|
||||
// 3. ordering - exact matches come before contains matches
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 665 B |
Binary file not shown.
|
Before Width: | Height: | Size: 628 B |
@@ -1,60 +0,0 @@
|
||||
<!-- HTML for static distribution bundle build -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Swagger UI</title>
|
||||
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
|
||||
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
|
||||
<style>
|
||||
html
|
||||
{
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after
|
||||
{
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
margin:0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "./swagger.yaml",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
// End Swagger UI call region
|
||||
|
||||
window.ui = ui;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,75 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Swagger UI: OAuth2 Redirect</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
'use strict';
|
||||
function run () {
|
||||
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
||||
var sentState = oauth2.state;
|
||||
var redirectUrl = oauth2.redirectUrl;
|
||||
var isValid, qp, arr;
|
||||
|
||||
if (/code|token|error/.test(window.location.hash)) {
|
||||
qp = window.location.hash.substring(1);
|
||||
} else {
|
||||
qp = location.search.substring(1);
|
||||
}
|
||||
|
||||
arr = qp.split("&");
|
||||
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
|
||||
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
||||
function (key, value) {
|
||||
return key === "" ? value : decodeURIComponent(value);
|
||||
}
|
||||
) : {};
|
||||
|
||||
isValid = qp.state === sentState;
|
||||
|
||||
if ((
|
||||
oauth2.auth.schema.get("flow") === "accessCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorization_code"
|
||||
) && !oauth2.auth.code) {
|
||||
if (!isValid) {
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "warning",
|
||||
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
|
||||
});
|
||||
}
|
||||
|
||||
if (qp.code) {
|
||||
delete oauth2.state;
|
||||
oauth2.auth.code = qp.code;
|
||||
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
||||
} else {
|
||||
let oauthErrorMsg;
|
||||
if (qp.error) {
|
||||
oauthErrorMsg = "["+qp.error+"]: " +
|
||||
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
||||
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
||||
}
|
||||
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "error",
|
||||
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
run();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +0,0 @@
|
||||
/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SwaggerUIBundle=t():e.SwaggerUIBundle=t()}(this,(function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist",n(n.s=479)}([function(e,t,n){"use strict";e.exports=n(555)},function(e,t,n){e.exports=function(){"use strict";var e=Array.prototype.slice;function t(e,t){t&&(e.prototype=Object.create(t.prototype)),e.prototype.constructor=e}function n(e){return i(e)?e:J(e)}function r(e){return u(e)?e:K(e)}function o(e){return s(e)?e:Y(e)}function a(e){return i(e)&&!c(e)?e:G(e)}function i(e){return!(!e||!e[f])}function u(e){return!(!e||!e[p])}function s(e){return!(!e||!e[h])}function c(e){return u(e)||s(e)}function l(e){return!(!e||!e[d])}t(r,n),t(o,n),t(a,n),n.isIterable=i,n.isKeyed=u,n.isIndexed=s,n.isAssociative=c,n.isOrdered=l,n.Keyed=r,n.Indexed=o,n.Set=a;var f="@@__IMMUTABLE_ITERABLE__@@",p="@@__IMMUTABLE_KEYED__@@",h="@@__IMMUTABLE_INDEXED__@@",d="@@__IMMUTABLE_ORDERED__@@",m="delete",v=5,g=1<<v,y=g-1,b={},w={value:!1},x={value:!1};function E(e){return e.value=!1,e}function _(e){e&&(e.value=!0)}function S(){}function k(e,t){t=t||0;for(var n=Math.max(0,e.length-t),r=new Array(n),o=0;o<n;o++)r[o]=e[o+t];return r}function A(e){return void 0===e.size&&(e.size=e.__iterate(C)),e.size}function O(e,t){if("number"!=typeof t){var n=t>>>0;if(""+n!==t||4294967295===n)return NaN;t=n}return t<0?A(e)+t:t}function C(){return!0}function j(e,t,n){return(0===e||void 0!==n&&e<=-n)&&(void 0===t||void 0!==n&&t>=n)}function T(e,t){return P(e,t,0)}function I(e,t){return P(e,t,t)}function P(e,t,n){return void 0===e?n:e<0?Math.max(0,t+e):void 0===t?e:Math.min(t,e)}var N=0,M=1,R=2,D="function"==typeof Symbol&&Symbol.iterator,L="@@iterator",B=D||L;function F(e){this.next=e}function z(e,t,n,r){var o=0===e?t:1===e?n:[t,n];return r?r.value=o:r={value:o,done:!1},r}function U(){return{value:void 0,done:!0}}function q(e){return!!H(e)}function V(e){return e&&"function"==typeof e.next}function W(e){var t=H(e);return t&&t.call(e)}function H(e){var t=e&&(D&&e[D]||e[L]);if("function"==typeof t)return t}function $(e){return e&&"number"==typeof e.length}function J(e){return null==e?ie():i(e)?e.toSeq():ce(e)}function K(e){return null==e?ie().toKeyedSeq():i(e)?u(e)?e.toSeq():e.fromEntrySeq():ue(e)}function Y(e){return null==e?ie():i(e)?u(e)?e.entrySeq():e.toIndexedSeq():se(e)}function G(e){return(null==e?ie():i(e)?u(e)?e.entrySeq():e:se(e)).toSetSeq()}F.prototype.toString=function(){return"[Iterator]"},F.KEYS=N,F.VALUES=M,F.ENTRIES=R,F.prototype.inspect=F.prototype.toSource=function(){return this.toString()},F.prototype[B]=function(){return this},t(J,n),J.of=function(){return J(arguments)},J.prototype.toSeq=function(){return this},J.prototype.toString=function(){return this.__toString("Seq {","}")},J.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},J.prototype.__iterate=function(e,t){return fe(this,e,t,!0)},J.prototype.__iterator=function(e,t){return pe(this,e,t,!0)},t(K,J),K.prototype.toKeyedSeq=function(){return this},t(Y,J),Y.of=function(){return Y(arguments)},Y.prototype.toIndexedSeq=function(){return this},Y.prototype.toString=function(){return this.__toString("Seq [","]")},Y.prototype.__iterate=function(e,t){return fe(this,e,t,!1)},Y.prototype.__iterator=function(e,t){return pe(this,e,t,!1)},t(G,J),G.of=function(){return G(arguments)},G.prototype.toSetSeq=function(){return this},J.isSeq=ae,J.Keyed=K,J.Set=G,J.Indexed=Y;var Q,Z,X,ee="@@__IMMUTABLE_SEQ__@@";function te(e){this._array=e,this.size=e.length}function ne(e){var t=Object.keys(e);this._object=e,this._keys=t,this.size=t.length}function re(e){this._iterable=e,this.size=e.length||e.size}function oe(e){this._iterator=e,this._iteratorCache=[]}function ae(e){return!(!e||!e[ee])}function ie(){return Q||(Q=new te([]))}function ue(e){var t=Array.isArray(e)?new te(e).fromEntrySeq():V(e)?new oe(e).fromEntrySeq():q(e)?new re(e).fromEntrySeq():"object"==typeof e?new ne(e):void 0;if(Line truncated
|
||||
//# sourceMappingURL=swagger-ui-bundle.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["webpack://SwaggerUIBundle/webpack/universalModuleDefinition","webpack://SwaggerUIBundle/webpack/bootstrap","webpack://SwaggerUIBundle/./node_modules/react/index.js","webpack://SwaggerUIBundle/./node_modules/immutable/dist/immutable.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/concat.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/defineProperty.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/map.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/classCallCheck.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/createClass.js","webpack://SwaggerUIBundle/./src/core/utils.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/inherits.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/createSuper.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/assertThisInitialized.js","webpack://SwaggerUIBundle/./node_modules/prop-types/index.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/filter.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/slicedToArray.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/toConsumableArray.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/object/keys.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/export.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/global.js","webpack://SwaggerUIBundle/./node_modules/reselect/es/defaultMemoize.js","webpack://SwaggerUIBundle/./node_modules/reselect/es/index.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/typeof.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/index-of.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/slice.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/array/is-array.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/for-each.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/object/assign.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/objectSpread2.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/extends.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-uncurry-this.js","webpack://SwaggerUIBundle/./src/core/window.js","webpack://SwaggerUIBundle/./node_modules/react-immutable-proptypes/dist/ImmutablePropTypes.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/json/stringify.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/reduce.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/includes.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/an-object.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/fails.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/path.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/object-is-prototype-of.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-call.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-pure.js","webpack://SwaggerUIBundle/./src/core/plugins/oas3/helpers.jsx","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/well-known-symbol.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/entry-virtual.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-callable.js","webpack://SwaggerUIBundle/./node_modules/lodash/get.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/regenerator/index.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-object.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/a-callable.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/has-own-property.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/define-well-known-symbol.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/iterate.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/bind.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/descriptors.js","webpack://SwaggerUIBundle/./src/core/plugins/spec/actions.js","webpack://SwaggerUIBundle/./node_modules/classnames/index.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-bind-context.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/find.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/hLine truncated
|
||||
@@ -1,3 +0,0 @@
|
||||
/*! For license information please see swagger-ui-es-bundle-core.js.LICENSE.txt */
|
||||
module.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var a=t[r]={i:r,l:!1,exports:{}};return e[r].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(r,a,function(t){return e[t]}.bind(null,a));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist",n(n.s=452)}([function(e,t){e.exports=require("react")},function(e,t){e.exports=require("immutable")},function(e,t,n){e.exports=n(492)},function(e,t,n){var r=n(178);e.exports=function(e,t,n){return t in e?r(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e},e.exports.__esModule=!0,e.exports.default=e.exports},function(e,t,n){e.exports=n(522)},function(e,t){e.exports=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")},e.exports.__esModule=!0,e.exports.default=e.exports},function(e,t,n){var r=n(178);function a(e,t){for(var n=0;n<t.length;n++){var a=t[n];a.enumerable=a.enumerable||!1,a.configurable=!0,"value"in a&&(a.writable=!0),r(e,a.key,a)}}e.exports=function(e,t,n){return t&&a(e.prototype,t),n&&a(e,n),r(e,"prototype",{writable:!1}),e},e.exports.__esModule=!0,e.exports.default=e.exports},function(e,t,n){"use strict";(function(e){n.d(t,"z",(function(){return be})),n.d(t,"i",(function(){return Ee})),n.d(t,"v",(function(){return xe})),n.d(t,"r",(function(){return Se})),n.d(t,"t",(function(){return we})),n.d(t,"s",(function(){return je})),n.d(t,"p",(function(){return Oe})),n.d(t,"u",(function(){return Ce})),n.d(t,"x",(function(){return _e})),n.d(t,"y",(function(){return Ae})),n.d(t,"J",(function(){return ke})),n.d(t,"f",(function(){return Ie})),n.d(t,"n",(function(){return Pe})),n.d(t,"h",(function(){return Te})),n.d(t,"D",(function(){return Re})),n.d(t,"K",(function(){return Me})),n.d(t,"o",(function(){return ze})),n.d(t,"C",(function(){return Ve})),n.d(t,"a",(function(){return Fe})),n.d(t,"H",(function(){return Je})),n.d(t,"b",(function(){return We})),n.d(t,"G",(function(){return He})),n.d(t,"F",(function(){return $e})),n.d(t,"E",(function(){return Ke})),n.d(t,"k",(function(){return Ye})),n.d(t,"d",(function(){return Ge})),n.d(t,"g",(function(){return Ze})),n.d(t,"m",(function(){return Xe})),n.d(t,"l",(function(){return Qe})),n.d(t,"e",(function(){return et})),n.d(t,"I",(function(){return tt})),n.d(t,"w",(function(){return nt})),n.d(t,"A",(function(){return rt})),n.d(t,"B",(function(){return at})),n.d(t,"j",(function(){return ot})),n.d(t,"c",(function(){return it})),n.d(t,"q",(function(){return ct}));var r=n(14),a=n.n(r),o=(n(13),n(19)),i=n.n(o),s=n(56),c=n.n(s),u=n(22),l=n.n(u),p=n(4),f=n.n(p),d=n(83),h=n.n(d),m=n(2),v=n.n(m),g=n(23),y=n.n(g),b=n(12),E=n.n(b),x=n(15),S=n.n(x),w=(n(50),n(31)),j=n.n(w),O=n(24),C=n.n(O),_=n(194),A=n.n(_),k=n(21),I=n.n(k),P=n(74),T=n.n(P),R=(n(32),n(30)),N=n.n(R),M=n(20),q=n.n(M),D=n(55),B=n.n(D),U=n(106),L=n.n(U),z=n(104),V=n.n(z),F=n(1),J=n.n(F),W=n(418),H=n(419),$=n.n(H),K=n(244),Y=n.n(K),G=n(107),Z=n.n(G),X=n(420),Q=n.n(X),ee=n(306),te=n.n(ee),ne=n(102),re=n.n(ne),ae=n(103),oe=n.n(ae),ie=n(133),se=n(28),ce=n(424),ue=n.n(ce),le=n(138),pe=n(117),fe=n.n(pe),de=n(425),he=n.n(de),me=n(73),ve=n.n(me),ge="default",ye=function(e){return J.a.Iterable.isIterable(e)};function be(e){return we(e)?ye(e)?e.toJS():e:{}}function Ee(e){var t,n;if(ye(e))return e;if(e instanceof se.a.File)return e;if(!we(e))return e;if(l()(e))return f()(n=J.a.Seq(e)).call(n,Ee).toList();if(oe()(h()(e))){var r,a=function(e){if(!oe()(h()(e)))return e;var t,n={},r="_**[]",a={},o=c()(h()(e).call(e));try{for(o.s();!(t=o.n()).done;){var i=t.value;if(n[i[0]]||a[i[0]]&&a[i[0]].containsMultiple){var s,u,l,p;if(!a[i[0]])a[i[0]]={containsMultiple:!0,length:1},n[v()(l=v()(p="".concat(i[0])).call(p,r)).call(l,a[i[0]].length)]=n[i[0]],delete n[i[0]];a[i[0]].length+=1,n[v()(s=v()(u="".concat(i[0])).call(u,r)).call(s,a[i[0]].length)]=i[1]}else n[i[0]]=i[1]}}catch(e){o.e(e)}finally{o.f()}return n}(e);return f()(r=J.a.OrderedMap(a)).call(r,Ee)}return f()(t=J.a.OrderedMap(e)).call(t,Ee)}function xe(e){return l()(e)?e:[e]}function Se(e){return"function"==typeof e}function we(e){return!!e&&"object"===i()(e)}function je(e){return"function"==typeof e}function Oe(e){return l()(e)}var Ce=Z.a;function _e(e,t){var n;return j()(n=S()(e)).call(n,(function(n,r){return n[r]=t(e[r],r),n}),{})}function Ae(e,t){var n;return j()(n=S()(e)).call(n,(function(n,r){var a=t(e[r],r);retuLine truncated
|
||||
//# sourceMappingURL=swagger-ui-es-bundle-core.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["webpack://SwaggerUIBundle/webpack/bootstrap","webpack://SwaggerUIBundle/external \"react\"","webpack://SwaggerUIBundle/external \"immutable\"","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/concat.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/defineProperty.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/map.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/classCallCheck.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/createClass.js","webpack://SwaggerUIBundle/./src/core/utils.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/inherits.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/createSuper.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/assertThisInitialized.js","webpack://SwaggerUIBundle/external \"prop-types\"","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/filter.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/slicedToArray.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/toConsumableArray.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/object/keys.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/export.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/global.js","webpack://SwaggerUIBundle/external \"reselect\"","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/typeof.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/index-of.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/slice.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/array/is-array.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/for-each.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/object/assign.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/objectSpread2.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/extends.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-uncurry-this.js","webpack://SwaggerUIBundle/./src/core/window.js","webpack://SwaggerUIBundle/external \"react-immutable-proptypes\"","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/json/stringify.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/reduce.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/includes.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/an-object.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/fails.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/path.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/object-is-prototype-of.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-call.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-pure.js","webpack://SwaggerUIBundle/./src/core/plugins/oas3/helpers.jsx","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/well-known-symbol.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/entry-virtual.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-callable.js","webpack://SwaggerUIBundle/./node_modules/lodash/get.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/regenerator/index.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-object.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/a-callable.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/has-own-property.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/define-well-known-symbol.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/iterate.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/bind.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/descriptors.js","webpack://SwaggerUIBundle/./src/core/plugins/spec/actions.js","webpack://SwaggerUIBundle/external \"classnames\"","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-bind-context.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/find.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/createForOfIteratorHelper.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/get-built-in.js","webpack://SwaggerUIBundle/./node_modules/lodash/isArray.js","webpack://SwaggerUIBundle/./node_modules/@babel/ruLine truncated
|
||||
@@ -1,3 +0,0 @@
|
||||
/*! For license information please see swagger-ui-es-bundle.js.LICENSE.txt */
|
||||
module.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist",n(n.s=479)}([function(e,t,n){"use strict";e.exports=n(555)},function(e,t,n){e.exports=function(){"use strict";var e=Array.prototype.slice;function t(e,t){t&&(e.prototype=Object.create(t.prototype)),e.prototype.constructor=e}function n(e){return i(e)?e:J(e)}function r(e){return u(e)?e:K(e)}function o(e){return s(e)?e:Y(e)}function a(e){return i(e)&&!c(e)?e:G(e)}function i(e){return!(!e||!e[f])}function u(e){return!(!e||!e[p])}function s(e){return!(!e||!e[h])}function c(e){return u(e)||s(e)}function l(e){return!(!e||!e[d])}t(r,n),t(o,n),t(a,n),n.isIterable=i,n.isKeyed=u,n.isIndexed=s,n.isAssociative=c,n.isOrdered=l,n.Keyed=r,n.Indexed=o,n.Set=a;var f="@@__IMMUTABLE_ITERABLE__@@",p="@@__IMMUTABLE_KEYED__@@",h="@@__IMMUTABLE_INDEXED__@@",d="@@__IMMUTABLE_ORDERED__@@",m="delete",v=5,g=1<<v,y=g-1,b={},w={value:!1},x={value:!1};function E(e){return e.value=!1,e}function _(e){e&&(e.value=!0)}function S(){}function k(e,t){t=t||0;for(var n=Math.max(0,e.length-t),r=new Array(n),o=0;o<n;o++)r[o]=e[o+t];return r}function A(e){return void 0===e.size&&(e.size=e.__iterate(C)),e.size}function O(e,t){if("number"!=typeof t){var n=t>>>0;if(""+n!==t||4294967295===n)return NaN;t=n}return t<0?A(e)+t:t}function C(){return!0}function j(e,t,n){return(0===e||void 0!==n&&e<=-n)&&(void 0===t||void 0!==n&&t>=n)}function T(e,t){return P(e,t,0)}function I(e,t){return P(e,t,t)}function P(e,t,n){return void 0===e?n:e<0?Math.max(0,t+e):void 0===t?e:Math.min(t,e)}var N=0,M=1,R=2,D="function"==typeof Symbol&&Symbol.iterator,L="@@iterator",B=D||L;function F(e){this.next=e}function z(e,t,n,r){var o=0===e?t:1===e?n:[t,n];return r?r.value=o:r={value:o,done:!1},r}function U(){return{value:void 0,done:!0}}function q(e){return!!H(e)}function V(e){return e&&"function"==typeof e.next}function W(e){var t=H(e);return t&&t.call(e)}function H(e){var t=e&&(D&&e[D]||e[L]);if("function"==typeof t)return t}function $(e){return e&&"number"==typeof e.length}function J(e){return null==e?ie():i(e)?e.toSeq():ce(e)}function K(e){return null==e?ie().toKeyedSeq():i(e)?u(e)?e.toSeq():e.fromEntrySeq():ue(e)}function Y(e){return null==e?ie():i(e)?u(e)?e.entrySeq():e.toIndexedSeq():se(e)}function G(e){return(null==e?ie():i(e)?u(e)?e.entrySeq():e:se(e)).toSetSeq()}F.prototype.toString=function(){return"[Iterator]"},F.KEYS=N,F.VALUES=M,F.ENTRIES=R,F.prototype.inspect=F.prototype.toSource=function(){return this.toString()},F.prototype[B]=function(){return this},t(J,n),J.of=function(){return J(arguments)},J.prototype.toSeq=function(){return this},J.prototype.toString=function(){return this.__toString("Seq {","}")},J.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},J.prototype.__iterate=function(e,t){return fe(this,e,t,!0)},J.prototype.__iterator=function(e,t){return pe(this,e,t,!0)},t(K,J),K.prototype.toKeyedSeq=function(){return this},t(Y,J),Y.of=function(){return Y(arguments)},Y.prototype.toIndexedSeq=function(){return this},Y.prototype.toString=function(){return this.__toString("Seq [","]")},Y.prototype.__iterate=function(e,t){return fe(this,e,t,!1)},Y.prototype.__iterator=function(e,t){return pe(this,e,t,!1)},t(G,J),G.of=function(){return G(arguments)},G.prototype.toSetSeq=function(){return this},J.isSeq=ae,J.Keyed=K,J.Set=G,J.Indexed=Y;var Q,Z,X,ee="@@__IMMUTABLE_SEQ__@@";function te(e){this._array=e,this.size=e.length}function ne(e){var t=Object.keys(e);this._object=e,this._keys=t,this.size=t.length}function re(e){this._iterable=e,this.size=e.length||e.size}function oe(e){this._iterator=e,this._iteratorCache=[]}function ae(e){return!(!e||!e[ee])}function ie(){return Q||(Q=new te([]))}function ue(e){var t=Array.isArray(e)?new te(e).fromEntrySeq():V(e)?new oe(e).fromEntrySeq():q(e)?new re(e).fromEntrySeq():"object"==typeof e?new ne(e):void 0;if(!t)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+e);return t}function se(e){var t=le(e);if(!t)throw new TypeError("Expected Array or iterable object of values: "+e);return tLine truncated
|
||||
//# sourceMappingURL=swagger-ui-es-bundle.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["webpack://SwaggerUIBundle/webpack/bootstrap","webpack://SwaggerUIBundle/./node_modules/react/index.js","webpack://SwaggerUIBundle/./node_modules/immutable/dist/immutable.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/concat.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/defineProperty.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/map.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/classCallCheck.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/createClass.js","webpack://SwaggerUIBundle/./src/core/utils.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/inherits.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/createSuper.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/assertThisInitialized.js","webpack://SwaggerUIBundle/./node_modules/prop-types/index.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/filter.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/slicedToArray.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/toConsumableArray.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/object/keys.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/export.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/global.js","webpack://SwaggerUIBundle/./node_modules/reselect/es/defaultMemoize.js","webpack://SwaggerUIBundle/./node_modules/reselect/es/index.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/typeof.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/index-of.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/slice.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/array/is-array.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/for-each.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/object/assign.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/objectSpread2.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/extends.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-uncurry-this.js","webpack://SwaggerUIBundle/./src/core/window.js","webpack://SwaggerUIBundle/./node_modules/react-immutable-proptypes/dist/ImmutablePropTypes.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/json/stringify.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/reduce.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/includes.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/an-object.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/fails.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/path.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/object-is-prototype-of.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-call.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-pure.js","webpack://SwaggerUIBundle/./src/core/plugins/oas3/helpers.jsx","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/well-known-symbol.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/entry-virtual.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-callable.js","webpack://SwaggerUIBundle/./node_modules/lodash/get.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/regenerator/index.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/is-object.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/a-callable.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/has-own-property.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/define-well-known-symbol.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/iterate.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/bind.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/descriptors.js","webpack://SwaggerUIBundle/./src/core/plugins/spec/actions.js","webpack://SwaggerUIBundle/./node_modules/classnames/index.js","webpack://SwaggerUIBundle/./node_modules/core-js-pure/internals/function-bind-context.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/find.js","webpack://SwaggerUIBundle/./node_modules/@babel/runtime-corejs3/helpers/createForOfIteratorHelper.js","webpack://SwaggerUIBundlLine truncated
|
||||
@@ -1,3 +0,0 @@
|
||||
/*! For license information please see swagger-ui-standalone-preset.js.LICENSE.txt */
|
||||
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SwaggerUIStandalonePreset=e():t.SwaggerUIStandalonePreset=e()}(this,(function(){return function(t){var e={};function n(r){if(e[r])return e[r].exports;var i=e[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=t,n.c=e,n.d=function(t,e,r){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:r})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var i in t)n.d(r,i,function(e){return t[e]}.bind(null,i));return r},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="/dist",n(n.s=247)}([function(t,e,n){"use strict";var r=n(1),i=n(72),o=n(6),u=n(15),s=n(74).f,a=n(256),c=n(16),f=n(25),l=n(51),p=n(20),h=function(t){var e=function(n,r,o){if(this instanceof e){switch(arguments.length){case 0:return new t;case 1:return new t(n);case 2:return new t(n,r)}return new t(n,r,o)}return i(t,this,arguments)};return e.prototype=t.prototype,e};t.exports=function(t,e){var n,i,d,y,v,g,w,M,_=t.target,L=t.global,x=t.stat,m=t.proto,j=L?r:x?r[_]:(r[_]||{}).prototype,b=L?c:c[_]||l(c,_,{})[_],N=b.prototype;for(d in e)n=!a(L?d:_+(x?".":"#")+d,t.forced)&&j&&p(j,d),v=b[d],n&&(g=t.noTargetGet?(M=s(j,d))&&M.value:j[d]),y=n&&g?g:e[d],n&&typeof v==typeof y||(w=t.bind&&n?f(y,r):t.wrap&&n?h(y):m&&u(y)?o(y):y,(t.sham||y&&y.sham||v&&v.sham)&&l(w,"sham",!0),l(b,d,w),m&&(p(c,i=_+"Prototype")||l(c,i,{}),l(c[i],d,y),t.real&&N&&!N[d]&&l(N,d,y)))}},function(t,e,n){(function(e){var n=function(t){return t&&t.Math==Math&&t};t.exports=n("object"==typeof globalThis&&globalThis)||n("object"==typeof window&&window)||n("object"==typeof self&&self)||n("object"==typeof e&&e)||function(){return this}()||Function("return this")()}).call(this,n(33))},function(t,e,n){"use strict";t.exports=n(320)},function(t,e,n){t.exports=function(){"use strict";var t=Array.prototype.slice;function e(t,e){e&&(t.prototype=Object.create(e.prototype)),t.prototype.constructor=t}function n(t){return u(t)?t:J(t)}function r(t){return s(t)?t:Z(t)}function i(t){return a(t)?t:X(t)}function o(t){return u(t)&&!c(t)?t:V(t)}function u(t){return!(!t||!t[l])}function s(t){return!(!t||!t[p])}function a(t){return!(!t||!t[h])}function c(t){return s(t)||a(t)}function f(t){return!(!t||!t[d])}e(r,n),e(i,n),e(o,n),n.isIterable=u,n.isKeyed=s,n.isIndexed=a,n.isAssociative=c,n.isOrdered=f,n.Keyed=r,n.Indexed=i,n.Set=o;var l="@@__IMMUTABLE_ITERABLE__@@",p="@@__IMMUTABLE_KEYED__@@",h="@@__IMMUTABLE_INDEXED__@@",d="@@__IMMUTABLE_ORDERED__@@",y="delete",v=5,g=1<<v,w=g-1,M={},_={value:!1},L={value:!1};function x(t){return t.value=!1,t}function m(t){t&&(t.value=!0)}function j(){}function b(t,e){e=e||0;for(var n=Math.max(0,t.length-e),r=new Array(n),i=0;i<n;i++)r[i]=t[i+e];return r}function N(t){return void 0===t.size&&(t.size=t.__iterate(D)),t.size}function S(t,e){if("number"!=typeof e){var n=e>>>0;if(""+n!==e||4294967295===n)return NaN;e=n}return e<0?N(t)+e:e}function D(){return!0}function I(t,e,n){return(0===t||void 0!==n&&t<=-n)&&(void 0===e||void 0!==n&&e>=n)}function A(t,e){return C(t,e,0)}function T(t,e){return C(t,e,e)}function C(t,e,n){return void 0===t?n:t<0?Math.max(0,e+t):void 0===e?t:Math.min(e,t)}var E=0,O=1,z=2,k="function"==typeof Symbol&&Symbol.iterator,U="@@iterator",Y=k||U;function R(t){this.next=t}function P(t,e,n,r){var i=0===t?e:1===t?n:[e,n];return r?r.value=i:r={value:i,done:!1},r}function Q(){return{value:void 0,done:!0}}function B(t){return!!W(t)}function F(t){return t&&"function"==typeof t.next}function G(t){var e=W(t);return e&&e.call(t)}function W(t){var e=t&&(k&&t[k]||t[U]);if("function"==typeof e)return e}function q(t){return t&&"number"==typeof t.length}function J(t){return null==t?ut():u(t)?t.toSeq():ct(t)}function Z(t){return null==t?ut().toKeyedSeq():u(t)?s(t)?t.toSeq():t.fromEntrySeq():st(t)}function X(t){return null==t?ut():u(t)?s(t)?t.entrySeq():t.toIndexedSeq():at(t)}function V(t){return(null==t?ut():u(t)?s(t)?t.entrySeq():t:at(t)).toSetSeq()}R.prototype.toString=function(){return"[Iterator]"},R.KEYS=E,R.VALUES=O,R.ENTRIES=z,R.prototype.inspect=R.prototype.toSource=function(){return this.toString()},R.prototype[Y]=function(){return this},e(J,n),J.of=function(){return J(arguments)},J.prototype.toSeq=function(){return this},J.prototype.toString=function(){return this.__toString("Seq {","}")},J.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().Line truncated
|
||||
//# sourceMappingURL=swagger-ui-standalone-preset.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["webpack://SwaggerUIStandalonePreset/webpack/universalModuleDefinition","webpack://SwaggerUIStandalonePreset/webpack/bootstrap","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/export.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/global.js","webpack://SwaggerUIStandalonePreset/./node_modules/react/index.js","webpack://SwaggerUIStandalonePreset/./node_modules/immutable/dist/immutable.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/an-object.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/fails.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/function-uncurry-this.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/concat.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/core-js-stable/array/is-array.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/object-is-prototype-of.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/is-pure.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/function-call.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/define-well-known-symbol.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/well-known-symbol.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/entry-virtual.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/is-callable.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/path.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/a-callable.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/descriptors.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/is-object.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/has-own-property.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/iterate.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/for-each.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/map.js","webpack://SwaggerUIStandalonePreset/./src/core/utils.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/function-bind-context.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/helpers/defineProperty.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/to-object.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/object-define-property.js","webpack://SwaggerUIStandalonePreset/./node_modules/inherits/inherits_browser.js","webpack://SwaggerUIStandalonePreset/./node_modules/safe-buffer/index.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/helpers/typeof.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/get-built-in.js","webpack://SwaggerUIStandalonePreset/(webpack)/buildin/global.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/length-of-array-like.js","webpack://SwaggerUIStandalonePreset/./node_modules/lodash/isArray.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/get-map-iterator.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/helpers/classCallCheck.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/helpers/createClass.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/helpers/inherits.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/helpers/createSuper.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/to-indexed-object.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/array-iteration.js","webpack://SwaggerUIStandalonePreset/./node_modules/lodash/_root.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/helpers/assertThisInitialized.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/filter.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/core-js-stable/object/keys.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/classof.js","webpack://SwaggerUIStandalonePreset/./node_modules/lodash/memoize.js","webpack://SwaggerUIStandalonePreset/./node_modules/@babel/runtime-corejs3/helpers/toConsumableArray.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/classof-raw.js","webpack://SwaggerUIStandalonePreset/./node_modules/core-js-pure/internals/create-non-enumerable-property.js","webpack://SwaggerUIStandalonePresetLine truncated
|
||||
@@ -1,4 +0,0 @@
|
||||
.swagger-ui{color:#3b4151;
|
||||
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */font-family:sans-serif}.swagger-ui html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;line-height:1.15}.swagger-ui body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui footer,.swagger-ui header,.swagger-ui nav,.swagger-ui section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;overflow:visible}.swagger-ui pre{font-family:monospace,monospace;font-size:1em}.swagger-ui a{-webkit-text-decoration-skip:objects;background-color:transparent}.swagger-ui abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.swagger-ui b,.swagger-ui strong{font-weight:inherit;font-weight:bolder}.swagger-ui code,.swagger-ui kbd,.swagger-ui samp{font-family:monospace,monospace;font-size:1em}.swagger-ui dfn{font-style:italic}.swagger-ui mark{background-color:#ff0;color:#000}.swagger-ui small{font-size:80%}.swagger-ui sub,.swagger-ui sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}.swagger-ui sub{bottom:-.25em}.swagger-ui sup{top:-.5em}.swagger-ui audio,.swagger-ui video{display:inline-block}.swagger-ui audio:not([controls]){display:none;height:0}.swagger-ui img{border-style:none}.swagger-ui svg:not(:root){overflow:hidden}.swagger-ui button,.swagger-ui input,.swagger-ui optgroup,.swagger-ui select,.swagger-ui textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}.swagger-ui button,.swagger-ui input{overflow:visible}.swagger-ui button,.swagger-ui select{text-transform:none}.swagger-ui [type=reset],.swagger-ui [type=submit],.swagger-ui button,.swagger-ui html [type=button]{-webkit-appearance:button}.swagger-ui [type=button]::-moz-focus-inner,.swagger-ui [type=reset]::-moz-focus-inner,.swagger-ui [type=submit]::-moz-focus-inner,.swagger-ui button::-moz-focus-inner{border-style:none;padding:0}.swagger-ui [type=button]:-moz-focusring,.swagger-ui [type=reset]:-moz-focusring,.swagger-ui [type=submit]:-moz-focusring,.swagger-ui button:-moz-focusring{outline:1px dotted ButtonText}.swagger-ui fieldset{padding:.35em .75em .625em}.swagger-ui legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}.swagger-ui progress{display:inline-block;vertical-align:baseline}.swagger-ui textarea{overflow:auto}.swagger-ui [type=checkbox],.swagger-ui [type=radio]{box-sizing:border-box;padding:0}.swagger-ui [type=number]::-webkit-inner-spin-button,.swagger-ui [type=number]::-webkit-outer-spin-button{height:auto}.swagger-ui [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.swagger-ui [type=search]::-webkit-search-cancel-button,.swagger-ui [type=search]::-webkit-search-decoration{-webkit-appearance:none}.swagger-ui ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.swagger-ui details,.swagger-ui menu{display:block}.swagger-ui summary{display:list-item}.swagger-ui canvas{display:inline-block}.swagger-ui [hidden],.swagger-ui template{display:none}.swagger-ui .debug *{outline:1px solid gold}.swagger-ui .debug-white *{outline:1px solid #fff}.swagger-ui .debug-black *{outline:1px solid #000}.swagger-ui .debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) repeat 0 0}.swagger-ui .debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eLine truncated
|
||||
|
||||
/*# sourceMappingURL=swagger-ui.css.map*/
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["webpack:///./src/style/main.scss","webpack:///./src/style/_type.scss","webpack:///./src/style/_variables.scss","webpack:///./node_modules/tachyons-sass/scss/_normalize.scss","webpack:///./node_modules/tachyons-sass/scss/_debug-children.scss","webpack:///./node_modules/tachyons-sass/scss/_debug-grid.scss","webpack:///./node_modules/tachyons-sass/scss/_box-sizing.scss","webpack:///./node_modules/tachyons-sass/scss/_aspect-ratios.scss","webpack:///./node_modules/tachyons-sass/scss/_images.scss","webpack:///./node_modules/tachyons-sass/scss/_background-size.scss","webpack:///./node_modules/tachyons-sass/scss/_background-position.scss","webpack:///./node_modules/tachyons-sass/scss/_outlines.scss","webpack:///./node_modules/tachyons-sass/scss/_borders.scss","webpack:///./node_modules/tachyons-sass/scss/_border-colors.scss","webpack:///./node_modules/tachyons-sass/scss/_border-radius.scss","webpack:///./node_modules/tachyons-sass/scss/_border-style.scss","webpack:///./node_modules/tachyons-sass/scss/_border-widths.scss","webpack:///./node_modules/tachyons-sass/scss/_box-shadow.scss","webpack:///./node_modules/tachyons-sass/scss/_code.scss","webpack:///./node_modules/tachyons-sass/scss/_coordinates.scss","webpack:///./node_modules/tachyons-sass/scss/_clears.scss","webpack:///./node_modules/tachyons-sass/scss/_flexbox.scss","webpack:///./node_modules/tachyons-sass/scss/_display.scss","webpack:///./node_modules/tachyons-sass/scss/_floats.scss","webpack:///./node_modules/tachyons-sass/scss/_font-family.scss","webpack:///./node_modules/tachyons-sass/scss/_font-style.scss","webpack:///./node_modules/tachyons-sass/scss/_font-weight.scss","webpack:///./node_modules/tachyons-sass/scss/_forms.scss","webpack:///./node_modules/tachyons-sass/scss/_heights.scss","webpack:///./node_modules/tachyons-sass/scss/_letter-spacing.scss","webpack:///./node_modules/tachyons-sass/scss/_line-height.scss","webpack:///./node_modules/tachyons-sass/scss/_links.scss","webpack:///./node_modules/tachyons-sass/scss/_lists.scss","webpack:///./node_modules/tachyons-sass/scss/_max-widths.scss","webpack:///./node_modules/tachyons-sass/scss/_widths.scss","webpack:///./node_modules/tachyons-sass/scss/_overflow.scss","webpack:///./node_modules/tachyons-sass/scss/_position.scss","webpack:///./node_modules/tachyons-sass/scss/_opacity.scss","webpack:///./node_modules/tachyons-sass/scss/_rotations.scss","webpack:///./node_modules/tachyons-sass/scss/_skins.scss","webpack:///./node_modules/tachyons-sass/scss/_skins-pseudo.scss","webpack:///./node_modules/tachyons-sass/scss/_spacing.scss","webpack:///./node_modules/tachyons-sass/scss/_variables.scss","webpack:///./node_modules/tachyons-sass/scss/_negative-margins.scss","webpack:///./node_modules/tachyons-sass/scss/_tables.scss","webpack:///./node_modules/tachyons-sass/scss/_text-decoration.scss","webpack:///./node_modules/tachyons-sass/scss/_text-align.scss","webpack:///./node_modules/tachyons-sass/scss/_text-transform.scss","webpack:///./node_modules/tachyons-sass/scss/_type-scale.scss","webpack:///./node_modules/tachyons-sass/scss/_typography.scss","webpack:///./node_modules/tachyons-sass/scss/_utilities.scss","webpack:///./node_modules/tachyons-sass/scss/_visibility.scss","webpack:///./node_modules/tachyons-sass/scss/_white-space.scss","webpack:///./node_modules/tachyons-sass/scss/_vertical-align.scss","webpack:///./node_modules/tachyons-sass/scss/_hovers.scss","webpack:///./node_modules/tachyons-sass/scss/_z-index.scss","webpack:///./node_modules/tachyons-sass/scss/_nested.scss","webpack:///./src/style/_layout.scss","webpack:///./src/style/_buttons.scss","webpack:///./src/style/_mixins.scss","webpack:///./src/style/_form.scss","webpack:///./src/style/_modal.scss","webpack:///./src/style/_models.scss","webpack:///./src/style/_servers.scss","webpack:///./src/style/_table.scss","webpack:///./src/style/_topbar.scss","webpack:///./src/style/_information.scss","webpack:///./src/style/_authorize.scss","webpack:///./src/style/_errors.scss","webpack:///./src/style/_split-pane-mode.scss","webpack:///./src/style/_markdown.scss"],"names":[],"mappings":"AAAA,YCII,aCYU;ECTd,4EFLI,sBDqhCJ,CGrgCA,iBAEE,0BACA,8BAFA,gBH4gCF,CGhgCA,iBACE,QHkgCF,CG3/BA,gHAME,aH6/BF,CGr/BA,eACE,cACA,cHu/BF,CG5+BA,2DAGE,aH++BF,CGx+BA,mBACE,eH0+BF,CGl+BA,eACE,uBACA,SACA,gBHu+BF,CG/9BA,gBACE,gCACA,aHm+BF,CGx9BA,cAEE,qCADA,4BH69BF,CGp9BA,wBACE,mBACA,0BACA,yEHy9BF,CGl9BA,iCAEE,oBASA,kBH28BF,CGn8BA,kDAGE,gCACA,aH28BF,CGp8BA,gBACE,iBHs8BF,CG/7BA,iBACE,sBACA,UHi8BF,CG17BA,kBACE,aH47BF,CGp7BA,gCAEE,cACA,cACA,kBACA,uBHs7BF,CGn7BA,gBACE,aHq7BF,CGl7BA,gBACE,SHo7BF,CG16BA,oCAEE,oBH46BF,CGr6BA,kCACE,aACA,QHu6BF,CGh6BA,gBACE,iBHk6BF,CG35BA,2BACE,eH65BF,CGl5BA,kGAKE,uBACA,eACA,iBACA,QHw5BF,CGh5BA,qCAEE,gBHm5BF,CG34BA,sCAEE,mBH84BF,CGr4BA,qGAIE,yBHw4BF,CGj4BA,wKAIE,kBACA,SHm4BF,CG53BA,4JAIE,6BH83BF,CGv3BA,qBACE,0BHy3BF,CG/2BA,mBACE,sBACA,cACA,cACA,eACA,UACA,kBHu3BF,CG/2BA,qBACE,qBACA,uBHm3BF,CG52BA,qBACE,aH82BF,CGt2BA,qDAEE,sBACA,SH02BLine truncated
|
||||
@@ -1,3 +0,0 @@
|
||||
/*! For license information please see swagger-ui.js.LICENSE.txt */
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SwaggerUICore=t():e.SwaggerUICore=t()}(this,(function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var a=t[r]={i:r,l:!1,exports:{}};return e[r].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(r,a,function(t){return e[t]}.bind(null,a));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist",n(n.s=452)}([function(e,t){e.exports=require("react")},function(e,t){e.exports=require("immutable")},function(e,t,n){e.exports=n(492)},function(e,t,n){var r=n(178);e.exports=function(e,t,n){return t in e?r(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e},e.exports.__esModule=!0,e.exports.default=e.exports},function(e,t,n){e.exports=n(522)},function(e,t){e.exports=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")},e.exports.__esModule=!0,e.exports.default=e.exports},function(e,t,n){var r=n(178);function a(e,t){for(var n=0;n<t.length;n++){var a=t[n];a.enumerable=a.enumerable||!1,a.configurable=!0,"value"in a&&(a.writable=!0),r(e,a.key,a)}}e.exports=function(e,t,n){return t&&a(e.prototype,t),n&&a(e,n),r(e,"prototype",{writable:!1}),e},e.exports.__esModule=!0,e.exports.default=e.exports},function(e,t,n){"use strict";(function(e){n.d(t,"z",(function(){return be})),n.d(t,"i",(function(){return Ee})),n.d(t,"v",(function(){return xe})),n.d(t,"r",(function(){return Se})),n.d(t,"t",(function(){return we})),n.d(t,"s",(function(){return je})),n.d(t,"p",(function(){return Oe})),n.d(t,"u",(function(){return Ce})),n.d(t,"x",(function(){return _e})),n.d(t,"y",(function(){return Ae})),n.d(t,"J",(function(){return ke})),n.d(t,"f",(function(){return Ie})),n.d(t,"n",(function(){return Pe})),n.d(t,"h",(function(){return Te})),n.d(t,"D",(function(){return Re})),n.d(t,"K",(function(){return Me})),n.d(t,"o",(function(){return ze})),n.d(t,"C",(function(){return Ve})),n.d(t,"a",(function(){return Fe})),n.d(t,"H",(function(){return Je})),n.d(t,"b",(function(){return We})),n.d(t,"G",(function(){return He})),n.d(t,"F",(function(){return $e})),n.d(t,"E",(function(){return Ke})),n.d(t,"k",(function(){return Ye})),n.d(t,"d",(function(){return Ge})),n.d(t,"g",(function(){return Ze})),n.d(t,"m",(function(){return Xe})),n.d(t,"l",(function(){return Qe})),n.d(t,"e",(function(){return et})),n.d(t,"I",(function(){return tt})),n.d(t,"w",(function(){return nt})),n.d(t,"A",(function(){return rt})),n.d(t,"B",(function(){return at})),n.d(t,"j",(function(){return ot})),n.d(t,"c",(function(){return it})),n.d(t,"q",(function(){return ct}));var r=n(14),a=n.n(r),o=(n(13),n(19)),i=n.n(o),s=n(56),c=n.n(s),u=n(22),l=n.n(u),p=n(4),f=n.n(p),d=n(83),h=n.n(d),m=n(2),v=n.n(m),g=n(23),y=n.n(g),b=n(12),E=n.n(b),x=n(15),S=n.n(x),w=(n(50),n(31)),j=n.n(w),O=n(24),C=n.n(O),_=n(194),A=n.n(_),k=n(21),I=n.n(k),P=n(74),T=n.n(P),R=(n(32),n(30)),N=n.n(R),M=n(20),q=n.n(M),D=n(55),B=n.n(D),U=n(106),L=n.n(U),z=n(104),V=n.n(z),F=n(1),J=n.n(F),W=n(418),H=n(419),$=n.n(H),K=n(244),Y=n.n(K),G=n(107),Z=n.n(G),X=n(420),Q=n.n(X),ee=n(306),te=n.n(ee),ne=n(102),re=n.n(ne),ae=n(103),oe=n.n(ae),ie=n(133),se=n(28),ce=n(424),ue=n.n(ce),le=n(138),pe=n(117),fe=n.n(pe),de=n(425),he=n.n(de),me=n(73),ve=n.n(me),ge="default",ye=function(e){return J.a.Iterable.isIterable(e)};function be(e){return we(e)?ye(e)?e.toJS():e:{}}function Ee(e){var t,n;if(ye(e))return e;if(e instanceof se.a.File)return e;if(!we(e))return e;if(l()(e))return f()(n=J.a.Seq(e)).call(n,Ee).toList();if(oe()(h()(e))){var r,a=function(e){if(!oe()(h()(e)))return e;var t,n={},r="_**[]",a={},o=c()(h()(e).call(e));try{for(o.s();!(t=o.n()).done;){var i=t.value;if(n[i[0]]||a[i[0]]&&a[i[0]].containsMultiple){var s,u,l,p;if(!a[i[0]])a[i[0]]={containsMultiple:!0,length:1},n[v()(l=v()(p="".concat(i[0])).call(p,r)).call(l,a[i[0]].length)]=n[i[0]],delete n[i[0]];a[i[0]].length+=1,n[v()(s=v()(u="".concat(i[0])).call(u,r)).call(s,a[i[0]].length)]=i[1]}else n[i[0]]=i[1]}}catch(e){o.e(e)}finally{o.f()}return n}(e);return f()(r=J.a.OrderedMap(a)).call(r,Ee)}return f()(t=J.a.OrderedMap(e)).call(t,Ee)}function xe(e){return l()(e)?e:[e]}function Se(e){return"function"==typeof e}function we(e){return!!e&&"object"===i()(e)}function je(e){return"function"==typeof e}functiLine truncated
|
||||
//# sourceMappingURL=swagger-ui.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["webpack://SwaggerUICore/webpack/universalModuleDefinition","webpack://SwaggerUICore/webpack/bootstrap","webpack://SwaggerUICore/external \"react\"","webpack://SwaggerUICore/external \"immutable\"","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/concat.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/defineProperty.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/map.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/classCallCheck.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/createClass.js","webpack://SwaggerUICore/./src/core/utils.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/inherits.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/createSuper.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/assertThisInitialized.js","webpack://SwaggerUICore/external \"prop-types\"","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/filter.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/slicedToArray.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/toConsumableArray.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/object/keys.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/export.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/global.js","webpack://SwaggerUICore/external \"reselect\"","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/typeof.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/index-of.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/slice.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/array/is-array.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/for-each.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/object/assign.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/objectSpread2.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/extends.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/function-uncurry-this.js","webpack://SwaggerUICore/./src/core/window.js","webpack://SwaggerUICore/external \"react-immutable-proptypes\"","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/json/stringify.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/reduce.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/includes.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/an-object.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/fails.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/path.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/object-is-prototype-of.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/function-call.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/is-pure.js","webpack://SwaggerUICore/./src/core/plugins/oas3/helpers.jsx","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/well-known-symbol.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/entry-virtual.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/is-callable.js","webpack://SwaggerUICore/./node_modules/lodash/get.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/regenerator/index.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/is-object.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/a-callable.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/has-own-property.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/define-well-known-symbol.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/iterate.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/bind.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/descriptors.js","webpack://SwaggerUICore/./src/core/plugins/spec/actions.js","webpack://SwaggerUICore/external \"classnames\"","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/function-bind-context.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/core-js-stable/instance/find.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/createForOfIteratorHelper.js","webpack://SwaggerUICore/./node_modules/core-js-pure/internals/get-built-in.js","webpack://SwaggerUICore/./node_modules/lodash/isArray.js","webpack://SwaggerUICore/./node_modules/@babel/runtime-corejs3/helpers/objectWithoutProperties.js","webpack://SLine truncated
|
||||
@@ -1,346 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
version: "3.2"
|
||||
title: "Music Server"
|
||||
description: "Upgraded Go version and the version of all dependencies. Fixed som more bugs."
|
||||
contact:
|
||||
email: "zarnor91@gmail.com"
|
||||
servers:
|
||||
- url: https://music.sanplex.xyz/
|
||||
description: Main (production) server
|
||||
- url: https://tmusic.sanplex.xyz/
|
||||
description: Test server
|
||||
- url: http://localhost:8080
|
||||
description: Local server
|
||||
tags:
|
||||
- name: "Music"
|
||||
description: "Endpoints for the music"
|
||||
- name: "Version"
|
||||
description: "Information about the application"
|
||||
- name: "Sync"
|
||||
description: "Sync the database with the folders"
|
||||
paths:
|
||||
/music:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Get song"
|
||||
description: "Get a specified song from the que, if invalid number it will be the first or the last song in que"
|
||||
operationId: "getSong"
|
||||
parameters:
|
||||
- name: "song"
|
||||
in: "query"
|
||||
schema:
|
||||
type: integer
|
||||
description: "The number the song has in the que"
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: "The specified file"
|
||||
content:
|
||||
audio/mpeg:
|
||||
schema:
|
||||
type: object
|
||||
format: binary
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/soundTest:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Start a sound test"
|
||||
description: "Get a sound check song"
|
||||
operationId: "getSoundCheckSong"
|
||||
responses:
|
||||
"200":
|
||||
description: "A file"
|
||||
content:
|
||||
audio/mpeg:
|
||||
schema:
|
||||
type: object
|
||||
format: binary
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/rand:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Get random song"
|
||||
description: "Takes a random song from a random game"
|
||||
operationId: "getRandom"
|
||||
responses:
|
||||
"200":
|
||||
description: "A file"
|
||||
content:
|
||||
audio/mpeg:
|
||||
schema:
|
||||
type: object
|
||||
format: binary
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/rand/low:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Get random song"
|
||||
description: "Takes a random song from a random game but increases the chance for games that haven't been played"
|
||||
operationId: "getRandomLow"
|
||||
responses:
|
||||
"200":
|
||||
description: "A file"
|
||||
content:
|
||||
audio/mpeg:
|
||||
schema:
|
||||
type: object
|
||||
format: binary
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/info:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Get info of current song"
|
||||
description: "Return the info of the song currently playing"
|
||||
operationId: "getInfo"
|
||||
responses:
|
||||
"200":
|
||||
description: "Info of current song"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/info'
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/list:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Gets song que"
|
||||
description: "Gets a list of all songs that have been fetched"
|
||||
operationId: "getList"
|
||||
responses:
|
||||
"200":
|
||||
description: "A list"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/list'
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/next:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Get next song"
|
||||
description: "Gets next song in the song que, if there is no more a new random song will be generated."
|
||||
operationId: "getNext"
|
||||
responses:
|
||||
"200":
|
||||
description: "A file"
|
||||
content:
|
||||
audio/mpeg:
|
||||
schema:
|
||||
type: object
|
||||
format: binary
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/previous:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Get previous song"
|
||||
description: "Gets previous song in the song que, if there is no more the first song will be returns."
|
||||
operationId: "getPrevious"
|
||||
responses:
|
||||
"200":
|
||||
description: "A file"
|
||||
content:
|
||||
audio/mpeg:
|
||||
schema:
|
||||
type: object
|
||||
format: binary
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/all:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Gets all games"
|
||||
description: "Gets a list of all games that is in the database"
|
||||
operationId: "getAll"
|
||||
responses:
|
||||
"200":
|
||||
description: "A list"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: ["God of War", "Final Fantasy VII"]
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/all/order:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Gets all games in order"
|
||||
description: "Gets a ordered list of all games that is in the database"
|
||||
operationId: "getAll"
|
||||
responses:
|
||||
"200":
|
||||
description: "A list"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: [ "God of War", "Final Fantasy VII" ]
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/all/random:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Gets all games in random order"
|
||||
description: "Gets a list of all games that is in the database in random order"
|
||||
operationId: "getAllRandom"
|
||||
responses:
|
||||
"200":
|
||||
description: "A list"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: ["Final Fantasy VII", "God of War"]
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/played:
|
||||
put:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Increase played"
|
||||
description: "Increase the number of times the current game has been played"
|
||||
operationId: "setPlayed"
|
||||
responses:
|
||||
"202":
|
||||
description: "Accepted"
|
||||
"401":
|
||||
description: "Bad Request"
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/addQue:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Adds last song to que"
|
||||
description: "Adds the last fetched song to the song que"
|
||||
operationId: "addQue"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
"401":
|
||||
description: "Bad Request"
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/music/reset:
|
||||
get:
|
||||
tags:
|
||||
- "Music"
|
||||
summary: "Resets the song que"
|
||||
description: "Resets the song que for a new match"
|
||||
operationId: "reset"
|
||||
responses:
|
||||
"200":
|
||||
description: "successful operation"
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
/version:
|
||||
get:
|
||||
tags:
|
||||
- "Version"
|
||||
summary: "Returns pet inventories by status"
|
||||
description: "Returns a map of status codes to quantities"
|
||||
operationId: "getVersionInfo"
|
||||
responses:
|
||||
"200":
|
||||
description: "successful operation"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/versionInfo'
|
||||
/sync:
|
||||
get:
|
||||
tags:
|
||||
- "Sync"
|
||||
summary: "Sync games"
|
||||
description: "Sync the database with the folders"
|
||||
operationId: "sync"
|
||||
responses:
|
||||
"200":
|
||||
description: "successful operation"
|
||||
"500":
|
||||
description: "Something went wrong on the server"
|
||||
components:
|
||||
schemas:
|
||||
list:
|
||||
type: object
|
||||
properties:
|
||||
Game:
|
||||
type: string
|
||||
example: God of War
|
||||
Song:
|
||||
type: string
|
||||
example: Main Theme.mp3
|
||||
CurrentlyPlaying:
|
||||
type: boolean
|
||||
example: true
|
||||
description: Only included on the one that's playing
|
||||
SongNo:
|
||||
type: integer
|
||||
example: 3
|
||||
info:
|
||||
type: object
|
||||
properties:
|
||||
Game:
|
||||
type: string
|
||||
example: God of War
|
||||
GamePlayed:
|
||||
type: integer
|
||||
example: 10
|
||||
Song:
|
||||
type: string
|
||||
example: Main Theme.mp3
|
||||
SongPlayed:
|
||||
type: integer
|
||||
example: 10
|
||||
SongNo:
|
||||
type: integer
|
||||
example: 3
|
||||
versionInfo:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
example: 0.5.1
|
||||
changelog:
|
||||
type: string
|
||||
example: "Added shadow to build fat jars. Added support to give property files through arguments."
|
||||
history:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/historyInfo'
|
||||
historyInfo:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
example: 0.5.0
|
||||
changelog:
|
||||
type: string
|
||||
example: "Updated kotlin version, gradle version and al libs. Added connection to database with exposed. Added new endpoints."
|
||||
@@ -9,8 +9,7 @@ require (
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/labstack/echo/v5 v5.1.1
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/panjf2000/ants/v2 v2.12.0
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/echo-swagger/v2 v2.0.1
|
||||
@@ -19,25 +18,34 @@ require (
|
||||
go.uber.org/zap v1.28.0
|
||||
)
|
||||
|
||||
require github.com/google/uuid v1.6.0
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/sync v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
@@ -52,12 +60,15 @@ require (
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.1 // indirect
|
||||
@@ -67,10 +78,12 @@ require (
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/natefinch/atomic v1.0.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sv-tools/openapi v0.4.0 // indirect
|
||||
@@ -78,6 +91,8 @@ require (
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.5 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
@@ -87,9 +102,9 @@ require (
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
@@ -97,3 +112,8 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/a-h/templ/cmd/templ
|
||||
github.com/swaggo/swag/cmd/swag
|
||||
)
|
||||
@@ -10,12 +10,18 @@ github.com/MShekow/directory-checksum v1.4.18 h1:1nPPVl7uREa6WMTAPKoWW/GylhnASs0
|
||||
github.com/MShekow/directory-checksum v1.4.18/go.mod h1:iUupsPb0X0BumQQymLrpD5Pkqe/CbV13OSgosw1oFc4=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
@@ -26,6 +32,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -43,8 +51,12 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -90,6 +102,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -112,6 +126,11 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
@@ -132,12 +151,12 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8=
|
||||
github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -147,6 +166,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
@@ -176,6 +197,10 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -202,8 +227,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
@@ -213,10 +238,12 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
var (
|
||||
testContainer testcontainers.Container
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Start PostgreSQL container
|
||||
log.Println("Starting PostgreSQL test container...")
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "postgres:15-alpine",
|
||||
ExposedPorts: []string{"5432/tcp"},
|
||||
Env: map[string]string{
|
||||
"POSTGRES_USER": "testuser",
|
||||
"POSTGRES_PASSWORD": "testpass",
|
||||
"POSTGRES_DB": "music_server_test",
|
||||
},
|
||||
WaitingFor: wait.ForLog("database system is ready to accept connections"),
|
||||
}
|
||||
|
||||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start container: %v", err)
|
||||
}
|
||||
testContainer = container
|
||||
|
||||
// Get container's host and port
|
||||
host, err := container.Endpoint(ctx, "")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get container endpoint: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("PostgreSQL container running at: %s", host)
|
||||
|
||||
// Set environment variables for all tests
|
||||
os.Setenv("DB_HOST", host)
|
||||
os.Setenv("DB_PORT", "5432")
|
||||
os.Setenv("DB_USERNAME", "testuser")
|
||||
os.Setenv("DB_PASSWORD", "testpass")
|
||||
os.Setenv("DB_NAME", "music_server_test")
|
||||
os.Setenv("MUSIC_PATH", "./testMusic")
|
||||
os.Setenv("CHARACTERS_PATH", "./testCharacters")
|
||||
os.Setenv("PORT", "8081")
|
||||
os.Setenv("LOG_LEVEL", "debug")
|
||||
os.Setenv("LOG_JSON", "false")
|
||||
|
||||
// Run tests
|
||||
log.Println("Running integration tests...")
|
||||
exitCode := m.Run()
|
||||
|
||||
// Cleanup
|
||||
log.Println("Stopping test container...")
|
||||
if err := container.Terminate(ctx); err != nil {
|
||||
log.Printf("Failed to terminate container: %v", err)
|
||||
}
|
||||
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
// TestDatabaseConnection verifies we can connect to the test database
|
||||
func TestDatabaseConnection(t *testing.T) {
|
||||
// This will be tested by the individual handler tests
|
||||
// Just verify env vars are set
|
||||
host := os.Getenv("DB_HOST")
|
||||
port := os.Getenv("DB_PORT")
|
||||
|
||||
if host == "" || port == "" {
|
||||
t.Error("Database environment variables not set")
|
||||
}
|
||||
|
||||
t.Logf("Database configuration: host=%s, port=%s", host, port)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"music-server/internal/db/repository"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Global variables - these are initialized by InitBackend
|
||||
var (
|
||||
backendPool *pgxpool.Pool
|
||||
repo *repository.Queries
|
||||
backendCtx context.Context = context.Background()
|
||||
)
|
||||
|
||||
// InitBackend initializes the backend package with the database pool.
|
||||
// This should be called once at application startup.
|
||||
func InitBackend(pool *pgxpool.Pool) {
|
||||
backendPool = pool
|
||||
repo = repository.New(pool)
|
||||
backendCtx = context.Background()
|
||||
}
|
||||
|
||||
// BackendCtx returns the context used by backend operations.
|
||||
// This is exposed for use by the backend functions.
|
||||
func BackendCtx() context.Context {
|
||||
return backendCtx
|
||||
}
|
||||
|
||||
// BackendRepo returns the repository queries instance.
|
||||
// This is exposed for use by the backend functions.
|
||||
func BackendRepo() *repository.Queries {
|
||||
return repo
|
||||
}
|
||||
|
||||
// BackendPool returns the underlying database pool.
|
||||
// This is exposed for test utilities that need direct pool access.
|
||||
func BackendPool() *pgxpool.Pool {
|
||||
return backendPool
|
||||
}
|
||||
@@ -1,43 +1,63 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"music-server/internal/logging"
|
||||
)
|
||||
|
||||
func GetCharacterList() []string {
|
||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
||||
logging.GetLogger().Debug("Getting character list", zap.String("path", charactersPath))
|
||||
// Clean the path - remove trailing slashes and then add one for consistency
|
||||
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
||||
charactersPath += "/"
|
||||
files, err := os.ReadDir(charactersPath)
|
||||
// CharacterService serves character images from a single directory.
|
||||
type CharacterService struct {
|
||||
root *os.Root
|
||||
}
|
||||
|
||||
// NewCharacterService opens the characters directory. Files outside it can never be served.
|
||||
func NewCharacterService(path string) (*CharacterService, error) {
|
||||
root, err := os.OpenRoot(path)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to read characters directory", zap.String("path", charactersPath), zap.String("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
return &CharacterService{root: root}, nil
|
||||
}
|
||||
|
||||
// List returns the file names of all character images.
|
||||
func (c *CharacterService) List() ([]string, error) {
|
||||
entries, err := fs.ReadDir(c.root.FS(), ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var characters []string
|
||||
for _, file := range files {
|
||||
if isImage(file) {
|
||||
characters = append(characters, file.Name())
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && isImage(entry.Name()) {
|
||||
characters = append(characters, entry.Name())
|
||||
}
|
||||
}
|
||||
return characters
|
||||
return characters, nil
|
||||
}
|
||||
|
||||
func GetCharacter(character string) string {
|
||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
||||
// Clean the path - remove trailing slashes and then add one for consistency
|
||||
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
||||
charactersPath += "/"
|
||||
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath+character))
|
||||
return charactersPath + character
|
||||
// Open opens the character image with the given file name.
|
||||
func (c *CharacterService) Open(name string) (*os.File, error) {
|
||||
if name == "" || name != filepath.Base(name) || !isImage(name) {
|
||||
return nil, ErrInvalidName
|
||||
}
|
||||
f, err := c.root.Open(name)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return f, err
|
||||
}
|
||||
|
||||
func isImage(entry os.DirEntry) bool {
|
||||
return !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".jpeg") ||
|
||||
strings.HasSuffix(entry.Name(), ".png"))
|
||||
// Close releases the characters directory.
|
||||
func (c *CharacterService) Close() error {
|
||||
return c.root.Close()
|
||||
}
|
||||
|
||||
func isImage(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".jpg", ".jpeg", ".png":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsImage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry fs.DirEntry
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "jpg file",
|
||||
entry: &mockDirEntry{name: "test.jpg", isDir: false},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "jpeg file",
|
||||
entry: &mockDirEntry{name: "test.jpeg", isDir: false},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "png file",
|
||||
entry: &mockDirEntry{name: "test.png", isDir: false},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "directory",
|
||||
entry: &mockDirEntry{name: "test", isDir: true},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "txt file",
|
||||
entry: &mockDirEntry{name: "test.txt", isDir: false},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "mp3 file",
|
||||
entry: &mockDirEntry{name: "test.mp3", isDir: false},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isImage(tt.entry)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isImage() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockDirEntry struct {
|
||||
name string
|
||||
isDir bool
|
||||
}
|
||||
|
||||
func (m *mockDirEntry) Name() string { return m.name }
|
||||
func (m *mockDirEntry) IsDir() bool { return m.isDir }
|
||||
func (m *mockDirEntry) Type() fs.FileMode { return 0 }
|
||||
func (m *mockDirEntry) Info() (fs.FileInfo, error) { return nil, nil }
|
||||
func (m *mockDirEntry) Sys() interface{} { return nil }
|
||||
|
||||
func TestGetCharacter(t *testing.T) {
|
||||
os.Setenv("CHARACTERS_PATH", "/test/path")
|
||||
defer os.Unsetenv("CHARACTERS_PATH")
|
||||
|
||||
result := GetCharacter("test.jpg")
|
||||
expected := "/test/path/test.jpg"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("GetCharacter() = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCharacterWithTrailingSlash(t *testing.T) {
|
||||
os.Setenv("CHARACTERS_PATH", "/test/path/")
|
||||
defer os.Unsetenv("CHARACTERS_PATH")
|
||||
|
||||
result := GetCharacter("test.jpg")
|
||||
expected := "/test/path/test.jpg"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("GetCharacter() = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"music-server/internal/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type giteaResponse struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Assets []assetResponse `json:"assets"`
|
||||
}
|
||||
|
||||
type assetResponse struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DownloadUrl string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
func CheckLatest() string {
|
||||
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to check latest version", zap.String("error", err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
//Create a variable of the same type as our model
|
||||
var cResp giteaResponse
|
||||
//Decode the data
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Debug("Checked latest version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||
return cResp.Name
|
||||
}
|
||||
|
||||
func ListAssetsOfLatest() []string {
|
||||
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to list assets", zap.String("error", err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
//Create a variable of the same type as our model
|
||||
var cResp giteaResponse
|
||||
//Decode the data
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Debug("Listing assets", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||
var assets []string
|
||||
for _, asset := range cResp.Assets {
|
||||
logging.GetLogger().Debug("Found asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
|
||||
assets = append(assets, asset.Name)
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func DownloadLatestWindows() string {
|
||||
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to download latest Windows version", zap.String("error", err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
//Create a variable of the same type as our model
|
||||
var cResp giteaResponse
|
||||
//Decode the data
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Debug("Downloading Windows version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||
for _, asset := range cResp.Assets {
|
||||
logging.GetLogger().Debug("Checking asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
|
||||
if strings.HasSuffix(asset.Name, ".exe") {
|
||||
return asset.DownloadUrl
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func DownloadLatestLinux() string {
|
||||
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to download latest Linux version", zap.String("error", err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
//Create a variable of the same type as our model
|
||||
var cResp giteaResponse
|
||||
//Decode the data
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Debug("Downloading Linux version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||
for _, asset := range cResp.Assets {
|
||||
logging.GetLogger().Debug("Checking asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
|
||||
if strings.HasSuffix(asset.Name, ".x86_64") {
|
||||
return asset.DownloadUrl
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckLatest(t *testing.T) {
|
||||
mockResponse := giteaResponse{
|
||||
Id: 1,
|
||||
Name: "v1.0.0",
|
||||
Assets: []assetResponse{
|
||||
{Id: 1, Name: "app.exe", DownloadUrl: "http://example.com/app.exe"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(mockResponse)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
originalURL := "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest"
|
||||
_ = originalURL
|
||||
|
||||
// Note: This test would need mocking of http.Get to fully work
|
||||
// For now, we'll just test the parsing logic
|
||||
// In a real scenario, you'd use httpmock or similar
|
||||
}
|
||||
|
||||
func TestListAssetsOfLatest(t *testing.T) {
|
||||
mockResponse := giteaResponse{
|
||||
Id: 1,
|
||||
Name: "v1.0.0",
|
||||
Assets: []assetResponse{
|
||||
{Id: 1, Name: "app.exe", DownloadUrl: "http://example.com/app.exe"},
|
||||
{Id: 2, Name: "app.x86_64", DownloadUrl: "http://example.com/app.x86_64"},
|
||||
{Id: 3, Name: "app.dmg", DownloadUrl: "http://example.com/app.dmg"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test the parsing of the response
|
||||
var cResp giteaResponse
|
||||
data, _ := json.Marshal(mockResponse)
|
||||
json.Unmarshal(data, &cResp)
|
||||
|
||||
var assets []string
|
||||
for _, asset := range cResp.Assets {
|
||||
assets = append(assets, asset.Name)
|
||||
}
|
||||
|
||||
if len(assets) != 3 {
|
||||
t.Errorf("Expected 3 assets, got %d", len(assets))
|
||||
}
|
||||
|
||||
if assets[0] != "app.exe" {
|
||||
t.Errorf("Expected first asset to be app.exe, got %s", assets[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package backend
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrQueueEmpty is returned when the play queue has no songs.
|
||||
ErrQueueEmpty = errors.New("song queue is empty")
|
||||
// ErrNoSongs is returned when no playable song could be found.
|
||||
ErrNoSongs = errors.New("no playable songs found")
|
||||
// ErrNotFound is returned when a requested resource does not exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrInvalidName is returned when a requested file name is not allowed.
|
||||
ErrInvalidName = errors.New("invalid name")
|
||||
// ErrUpstream is returned when an external service could not be reached.
|
||||
ErrUpstream = errors.New("upstream service unavailable")
|
||||
)
|
||||
+278
-240
@@ -1,16 +1,25 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// maxPickAttempts bounds how many broken songs are skipped before giving up.
|
||||
const maxPickAttempts = 100
|
||||
|
||||
type SongInfo struct {
|
||||
Soundtrack string `json:"Soundtrack"`
|
||||
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
|
||||
@@ -20,303 +29,332 @@ type SongInfo struct {
|
||||
SongNo int `json:"SongNo"`
|
||||
}
|
||||
|
||||
var currentSong = -1
|
||||
// MusicService picks songs and keeps track of the shared play queue.
|
||||
type MusicService struct {
|
||||
q repository.Querier
|
||||
soundCheckPath string
|
||||
|
||||
var soundtracksNew []repository.Soundtrack
|
||||
cacheMu sync.Mutex
|
||||
soundtracks []repository.Soundtrack
|
||||
|
||||
var songQueNew []repository.Song
|
||||
|
||||
var lastFetchedNew repository.Song
|
||||
|
||||
func initRepo() {
|
||||
// This function is kept for backward compatibility
|
||||
// but now uses the backend package's initialized repo
|
||||
// If not initialized, this will panic intentionally
|
||||
if BackendRepo() == nil {
|
||||
panic("backend not initialized - call backend.InitBackend() first")
|
||||
}
|
||||
// Queue state is shared by all clients and intentionally not locked.
|
||||
queue []repository.Song
|
||||
currentSong int
|
||||
lastFetched repository.Song
|
||||
}
|
||||
|
||||
func getAllSoundtracks() []repository.Soundtrack {
|
||||
if len(soundtracksNew) == 0 {
|
||||
initRepo()
|
||||
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||
}
|
||||
return soundtracksNew
|
||||
|
||||
// NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs.
|
||||
func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService {
|
||||
return &MusicService{q: q, soundCheckPath: soundCheckPath, currentSong: -1}
|
||||
}
|
||||
|
||||
func GetSoundCheckSong() string {
|
||||
files, err := os.ReadDir("songs")
|
||||
func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) {
|
||||
m.cacheMu.Lock()
|
||||
defer m.cacheMu.Unlock()
|
||||
if len(m.soundtracks) == 0 {
|
||||
soundtracks, err := m.q.FindAllSoundtracks(ctx)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to read songs directory", zap.String("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
fileInfo := files[rand.Intn(len(files))]
|
||||
return "songs/" + fileInfo.Name()
|
||||
m.soundtracks = soundtracks
|
||||
}
|
||||
return m.soundtracks, nil
|
||||
}
|
||||
|
||||
func Reset() {
|
||||
songQueNew = nil
|
||||
currentSong = -1
|
||||
initRepo()
|
||||
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||
// Reset clears the play queue and reloads the soundtrack list.
|
||||
func (m *MusicService) Reset(ctx context.Context) error {
|
||||
m.queue = nil
|
||||
m.currentSong = -1
|
||||
m.cacheMu.Lock()
|
||||
m.soundtracks = nil
|
||||
m.cacheMu.Unlock()
|
||||
_, err := m.allSoundtracks(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func AddLatestToQue() {
|
||||
if lastFetchedNew.Path != "" {
|
||||
currentSong = len(songQueNew)
|
||||
songQueNew = append(songQueNew, lastFetchedNew)
|
||||
lastFetchedNew = repository.Song{}
|
||||
}
|
||||
}
|
||||
|
||||
func AddLatestPlayed() {
|
||||
if len(songQueNew) == 0 {
|
||||
return
|
||||
}
|
||||
currentSongData := songQueNew[currentSong]
|
||||
|
||||
initRepo()
|
||||
BackendRepo().AddSoundtrackPlayed(BackendCtx(), currentSongData.SoundtrackID)
|
||||
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: currentSongData.SoundtrackID, SongName: currentSongData.SongName})
|
||||
}
|
||||
|
||||
func SetPlayed(songNumber int) {
|
||||
if len(songQueNew) == 0 || songNumber >= len(songQueNew) {
|
||||
return
|
||||
}
|
||||
songData := songQueNew[songNumber]
|
||||
initRepo()
|
||||
BackendRepo().AddSoundtrackPlayed(BackendCtx(), songData.SoundtrackID)
|
||||
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: songData.SoundtrackID, SongName: songData.SongName})
|
||||
}
|
||||
|
||||
func GetRandomSong() string {
|
||||
getAllSoundtracks()
|
||||
if len(soundtracksNew) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
song := getSongFromList(soundtracksNew)
|
||||
lastFetchedNew = song
|
||||
return song.Path
|
||||
}
|
||||
|
||||
func GetRandomSongLowChance() string {
|
||||
getAllSoundtracks()
|
||||
|
||||
var listOfSoundtracks []repository.Soundtrack
|
||||
|
||||
var averagePlayed = getAveragePlayed()
|
||||
|
||||
for _, data := range soundtracksNew {
|
||||
timesToAdd := averagePlayed - data.TimesPlayed
|
||||
if timesToAdd <= 0 {
|
||||
listOfSoundtracks = append(listOfSoundtracks, data)
|
||||
} else {
|
||||
for i := int32(0); i < timesToAdd; i++ {
|
||||
listOfSoundtracks = append(listOfSoundtracks, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
song := getSongFromList(listOfSoundtracks)
|
||||
|
||||
lastFetchedNew = song
|
||||
return song.Path
|
||||
|
||||
}
|
||||
|
||||
func GetRandomSongClassic() string {
|
||||
getAllSoundtracks()
|
||||
|
||||
var listOfAllSongs []repository.Song
|
||||
for _, soundtrack := range soundtracksNew {
|
||||
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
|
||||
listOfAllSongs = append(listOfAllSongs, songList...)
|
||||
}
|
||||
|
||||
songFound := false
|
||||
var song repository.Song
|
||||
for !songFound {
|
||||
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
|
||||
soundtrackData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
|
||||
|
||||
// SoundCheckSong returns the path of a random sound check song.
|
||||
func (m *MusicService) SoundCheckSong() (string, error) {
|
||||
files, err := os.ReadDir(m.soundCheckPath)
|
||||
if err != nil {
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("soundtrack", soundtrackData.SoundtrackName),
|
||||
zap.String("filename", *song.FileName))
|
||||
return "", err
|
||||
}
|
||||
files = slices.DeleteFunc(files, func(f os.DirEntry) bool { return f.IsDir() })
|
||||
if len(files) == 0 {
|
||||
return "", ErrNoSongs
|
||||
}
|
||||
return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil
|
||||
}
|
||||
|
||||
// AddLatestToQueue appends the last fetched random song to the queue.
|
||||
func (m *MusicService) AddLatestToQueue() {
|
||||
if m.lastFetched.Path != "" {
|
||||
m.currentSong = len(m.queue)
|
||||
m.queue = append(m.queue, m.lastFetched)
|
||||
m.lastFetched = repository.Song{}
|
||||
}
|
||||
}
|
||||
|
||||
// AddLatestPlayed increments the play counters of the current song.
|
||||
func (m *MusicService) AddLatestPlayed(ctx context.Context) error {
|
||||
if !m.validIndex(m.currentSong) {
|
||||
return nil
|
||||
}
|
||||
return m.markPlayed(ctx, m.queue[m.currentSong])
|
||||
}
|
||||
|
||||
// SetPlayed increments the play counters of the song at the given queue position.
|
||||
func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error {
|
||||
if !m.validIndex(songNumber) {
|
||||
return nil
|
||||
}
|
||||
return m.markPlayed(ctx, m.queue[songNumber])
|
||||
}
|
||||
|
||||
func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error {
|
||||
if err := m.q.AddSoundtrackPlayed(ctx, song.SoundtrackID); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: song.SoundtrackID, SongName: song.SongName})
|
||||
}
|
||||
|
||||
func (m *MusicService) validIndex(i int) bool {
|
||||
return i >= 0 && i < len(m.queue)
|
||||
}
|
||||
|
||||
// RandomSong picks a random song from a random soundtrack.
|
||||
func (m *MusicService) RandomSong(ctx context.Context) (string, error) {
|
||||
soundtracks, err := m.allSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
candidates := make([]weightedSoundtrack, len(soundtracks))
|
||||
for i, s := range soundtracks {
|
||||
candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1}
|
||||
}
|
||||
return m.pickAndRemember(ctx, candidates)
|
||||
}
|
||||
|
||||
// RandomSongLowChance picks a random song, favouring soundtracks played less than average.
|
||||
func (m *MusicService) RandomSongLowChance(ctx context.Context) (string, error) {
|
||||
soundtracks, err := m.allSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.pickAndRemember(ctx, lowChanceWeights(soundtracks))
|
||||
}
|
||||
|
||||
func (m *MusicService) pickAndRemember(ctx context.Context, candidates []weightedSoundtrack) (string, error) {
|
||||
song, err := m.pickSong(ctx, candidates)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m.lastFetched = song
|
||||
return song.Path, nil
|
||||
}
|
||||
|
||||
// RandomSongClassic picks a random song where every song has the same chance.
|
||||
func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) {
|
||||
for range maxPickAttempts {
|
||||
row, err := m.q.GetRandomActiveSong(ctx)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNoSongs
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !songIsPlayable(row.Song, row.SoundtrackPath) {
|
||||
if err := m.removeBrokenSong(ctx, row.Song); err != nil {
|
||||
return "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
//Check if file exists and open
|
||||
openFile, err := os.Open(song.Path)
|
||||
if err != nil || (song.FileName != nil && soundtrackData.Path+*song.FileName != song.Path) {
|
||||
//File not found
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("soundtrack", soundtrackData.SoundtrackName),
|
||||
zap.String("filename", *song.FileName))
|
||||
} else {
|
||||
songFound = true
|
||||
m.lastFetched = row.Song
|
||||
return row.Song.Path, nil
|
||||
}
|
||||
err = openFile.Close()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to close file", zap.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
lastFetchedNew = song
|
||||
return song.Path
|
||||
return "", ErrNoSongs
|
||||
}
|
||||
|
||||
func GetSongInfo() SongInfo {
|
||||
if songQueNew == nil {
|
||||
// SongInfo returns information about the current song in the queue.
|
||||
func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
|
||||
if !m.validIndex(m.currentSong) {
|
||||
return SongInfo{}
|
||||
}
|
||||
var currentSongData = songQueNew[currentSong]
|
||||
|
||||
currentSoundtrackData := getCurrentSoundtrack(currentSongData)
|
||||
|
||||
song := m.queue[m.currentSong]
|
||||
soundtrack := m.soundtrackFor(ctx, song)
|
||||
return SongInfo{
|
||||
Soundtrack: currentSoundtrackData.SoundtrackName,
|
||||
SoundtrackPlayed: currentSoundtrackData.TimesPlayed,
|
||||
Song: currentSongData.SongName,
|
||||
SongPlayed: currentSongData.TimesPlayed,
|
||||
Soundtrack: soundtrack.SoundtrackName,
|
||||
SoundtrackPlayed: soundtrack.TimesPlayed,
|
||||
Song: song.SongName,
|
||||
SongPlayed: song.TimesPlayed,
|
||||
CurrentlyPlaying: true,
|
||||
SongNo: currentSong,
|
||||
SongNo: m.currentSong,
|
||||
}
|
||||
}
|
||||
|
||||
func GetPlayedSongs() []SongInfo {
|
||||
// PlayedSongs returns all songs in the queue.
|
||||
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo {
|
||||
var songList []SongInfo
|
||||
|
||||
for i, song := range songQueNew {
|
||||
soundtrackData := getCurrentSoundtrack(song)
|
||||
for i, song := range m.queue {
|
||||
soundtrack := m.soundtrackFor(ctx, song)
|
||||
songList = append(songList, SongInfo{
|
||||
Soundtrack: soundtrackData.SoundtrackName,
|
||||
SoundtrackPlayed: soundtrackData.TimesPlayed,
|
||||
Soundtrack: soundtrack.SoundtrackName,
|
||||
SoundtrackPlayed: soundtrack.TimesPlayed,
|
||||
Song: song.SongName,
|
||||
SongPlayed: song.TimesPlayed,
|
||||
CurrentlyPlaying: i == currentSong,
|
||||
CurrentlyPlaying: i == m.currentSong,
|
||||
SongNo: i,
|
||||
})
|
||||
}
|
||||
return songList
|
||||
}
|
||||
|
||||
func GetSong(song string) string {
|
||||
currentSong, _ = strconv.Atoi(song)
|
||||
if currentSong >= len(songQueNew) {
|
||||
currentSong = len(songQueNew) - 1
|
||||
} else if currentSong < 0 {
|
||||
currentSong = 0
|
||||
// Song jumps to the given queue position (clamped to the queue) and returns its path.
|
||||
func (m *MusicService) Song(songNumber int) (string, error) {
|
||||
if len(m.queue) == 0 {
|
||||
return "", ErrQueueEmpty
|
||||
}
|
||||
songData := songQueNew[currentSong]
|
||||
return songData.Path
|
||||
m.currentSong = max(0, min(songNumber, len(m.queue)-1))
|
||||
return m.queue[m.currentSong].Path, nil
|
||||
}
|
||||
|
||||
func GetAllSoundtracks() []string {
|
||||
getAllSoundtracks()
|
||||
|
||||
var jsonArray []string
|
||||
for _, soundtrack := range soundtracksNew {
|
||||
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
|
||||
// NextSong moves to the next song in the queue, staying on the last one at the end.
|
||||
func (m *MusicService) NextSong() (string, error) {
|
||||
if len(m.queue) == 0 {
|
||||
return "", ErrQueueEmpty
|
||||
}
|
||||
return jsonArray
|
||||
return m.Song(m.currentSong + 1)
|
||||
}
|
||||
|
||||
func GetAllSoundtracksRandom() []string {
|
||||
getAllSoundtracks()
|
||||
|
||||
var jsonArray []string
|
||||
for _, soundtrack := range soundtracksNew {
|
||||
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
|
||||
// PreviousSong moves to the previous song in the queue, staying on the first one at the start.
|
||||
func (m *MusicService) PreviousSong() (string, error) {
|
||||
if len(m.queue) == 0 {
|
||||
return "", ErrQueueEmpty
|
||||
}
|
||||
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
|
||||
return jsonArray
|
||||
return m.Song(m.currentSong - 1)
|
||||
}
|
||||
|
||||
func GetNextSong() string {
|
||||
if songQueNew == nil {
|
||||
return ""
|
||||
// SoundtrackNames returns all soundtrack names in alphabetical order.
|
||||
func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
|
||||
soundtracks, err := m.allSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentSong == len(songQueNew)-1 || currentSong == -1 {
|
||||
songData := songQueNew[currentSong]
|
||||
return songData.Path
|
||||
} else {
|
||||
currentSong = currentSong + 1
|
||||
songData := songQueNew[currentSong]
|
||||
return songData.Path
|
||||
names := make([]string, len(soundtracks))
|
||||
for i, s := range soundtracks {
|
||||
names[i] = s.SoundtrackName
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func GetPreviousSong() string {
|
||||
if songQueNew == nil {
|
||||
return ""
|
||||
}
|
||||
if currentSong == -1 || currentSong == 0 {
|
||||
songData := songQueNew[0]
|
||||
return songData.Path
|
||||
} else {
|
||||
currentSong = currentSong - 1
|
||||
songData := songQueNew[currentSong]
|
||||
return songData.Path
|
||||
}
|
||||
// SoundtrackNamesRandom returns all soundtrack names in random order.
|
||||
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
|
||||
names, err := m.SoundtrackNames(ctx)
|
||||
rand.Shuffle(len(names), func(i, j int) { names[i], names[j] = names[j], names[i] })
|
||||
return names, err
|
||||
}
|
||||
|
||||
func getSongFromList(soundtracks []repository.Soundtrack) repository.Song {
|
||||
songFound := false
|
||||
var song repository.Song
|
||||
for !songFound {
|
||||
soundtrack := getRandomSoundtrack(soundtracks)
|
||||
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
|
||||
type weightedSoundtrack struct {
|
||||
soundtrack repository.Soundtrack
|
||||
weight int
|
||||
}
|
||||
|
||||
// lowChanceWeights gives each soundtrack a weight of how far it is below the average play count (at least 1).
|
||||
func lowChanceWeights(soundtracks []repository.Soundtrack) []weightedSoundtrack {
|
||||
average := averagePlayed(soundtracks)
|
||||
weighted := make([]weightedSoundtrack, len(soundtracks))
|
||||
for i, s := range soundtracks {
|
||||
weighted[i] = weightedSoundtrack{soundtrack: s, weight: max(1, int(average-s.TimesPlayed))}
|
||||
}
|
||||
return weighted
|
||||
}
|
||||
|
||||
func averagePlayed(soundtracks []repository.Soundtrack) int32 {
|
||||
if len(soundtracks) == 0 {
|
||||
return 0
|
||||
}
|
||||
var sum int64
|
||||
for _, s := range soundtracks {
|
||||
sum += int64(s.TimesPlayed)
|
||||
}
|
||||
return int32(sum / int64(len(soundtracks)))
|
||||
}
|
||||
|
||||
func pickWeighted(candidates []weightedSoundtrack) int {
|
||||
total := 0
|
||||
for _, c := range candidates {
|
||||
total += c.weight
|
||||
}
|
||||
n := rand.IntN(total)
|
||||
for i, c := range candidates {
|
||||
if n < c.weight {
|
||||
return i
|
||||
}
|
||||
n -= c.weight
|
||||
}
|
||||
return len(candidates) - 1
|
||||
}
|
||||
|
||||
// pickSong picks a random playable song. Soundtracks without songs are dropped from the candidates
|
||||
// and broken songs are removed from the database.
|
||||
func (m *MusicService) pickSong(ctx context.Context, candidates []weightedSoundtrack) (repository.Song, error) {
|
||||
candidates = slices.Clone(candidates)
|
||||
for range maxPickAttempts {
|
||||
if len(candidates) == 0 {
|
||||
return repository.Song{}, ErrNoSongs
|
||||
}
|
||||
i := pickWeighted(candidates)
|
||||
soundtrack := candidates[i].soundtrack
|
||||
|
||||
songs, err := m.q.FindSongsFromSoundtrack(ctx, soundtrack.ID)
|
||||
if err != nil {
|
||||
return repository.Song{}, err
|
||||
}
|
||||
if len(songs) == 0 {
|
||||
candidates = slices.Delete(candidates, i, i+1)
|
||||
continue
|
||||
}
|
||||
song = songs[rand.Intn(len(songs))]
|
||||
|
||||
song := songs[rand.IntN(len(songs))]
|
||||
logging.GetLogger().Debug("Selected song", zap.String("song", song.SongName), zap.String("path", song.Path))
|
||||
|
||||
//Check if file exists and open
|
||||
openFile, err := os.Open(song.Path)
|
||||
if err != nil || (song.FileName != nil && soundtrack.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
||||
//File not found
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("soundtrack", soundtrack.SoundtrackName),
|
||||
zap.Any("filename", song.FileName))
|
||||
} else {
|
||||
songFound = true
|
||||
if !songIsPlayable(song, soundtrack.Path) {
|
||||
if err := m.removeBrokenSong(ctx, song); err != nil {
|
||||
return repository.Song{}, err
|
||||
}
|
||||
|
||||
err = openFile.Close()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to close file", zap.String("path", song.Path), zap.String("error", err.Error()))
|
||||
continue
|
||||
}
|
||||
return song, nil
|
||||
}
|
||||
return song
|
||||
return repository.Song{}, ErrNoSongs
|
||||
}
|
||||
|
||||
func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
|
||||
for _, soundtrack := range soundtracksNew {
|
||||
if soundtrack.ID == currentSongData.SoundtrackID {
|
||||
func (m *MusicService) removeBrokenSong(ctx context.Context, song repository.Song) error {
|
||||
logging.GetLogger().Warn("Song not found, removing from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("path", song.Path))
|
||||
return m.q.RemoveBrokenSong(ctx, repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
}
|
||||
|
||||
// songIsPlayable reports whether the song file exists and its stored path is consistent.
|
||||
func songIsPlayable(song repository.Song, soundtrackPath string) bool {
|
||||
if song.FileName != nil {
|
||||
if soundtrackPath+*song.FileName != song.Path || strings.HasSuffix(strings.ToLower(*song.FileName), ".wav") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(song.Path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func (m *MusicService) soundtrackFor(ctx context.Context, song repository.Song) repository.Soundtrack {
|
||||
soundtracks, err := m.allSoundtracks(ctx)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err))
|
||||
return repository.Soundtrack{}
|
||||
}
|
||||
for _, soundtrack := range soundtracks {
|
||||
if soundtrack.ID == song.SoundtrackID {
|
||||
return soundtrack
|
||||
}
|
||||
}
|
||||
return repository.Soundtrack{}
|
||||
}
|
||||
|
||||
func getAveragePlayed() int32 {
|
||||
getAllSoundtracks()
|
||||
var sum int32
|
||||
for _, data := range soundtracksNew {
|
||||
sum += data.TimesPlayed
|
||||
}
|
||||
return sum / int32(len(soundtracksNew))
|
||||
}
|
||||
|
||||
func getRandomSoundtrack(listOfSoundtracks []repository.Soundtrack) repository.Soundtrack {
|
||||
return listOfSoundtracks[rand.Intn(len(listOfSoundtracks))]
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
)
|
||||
|
||||
// Test the average calculation logic directly without database access
|
||||
func TestCalculateAverage(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||
}
|
||||
|
||||
var sum int32
|
||||
for _, data := range soundtracks {
|
||||
sum += data.TimesPlayed
|
||||
}
|
||||
result := sum / int32(len(soundtracks))
|
||||
expected := int32(20)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Average calculation = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAverageEmpty(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{}
|
||||
|
||||
if len(soundtracks) == 0 {
|
||||
result := int32(0)
|
||||
expected := int32(0)
|
||||
if result != expected {
|
||||
t.Errorf("Average calculation with empty list = %v, want %v", result, expected)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var sum int32
|
||||
for _, data := range soundtracks {
|
||||
sum += data.TimesPlayed
|
||||
}
|
||||
result := sum / int32(len(soundtracks))
|
||||
expected := int32(0)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Average calculation with empty list = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAverageSingle(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{SoundtrackName: "Soundtrack1", TimesPlayed: 42},
|
||||
}
|
||||
|
||||
var sum int32
|
||||
for _, data := range soundtracks {
|
||||
sum += data.TimesPlayed
|
||||
}
|
||||
result := sum / int32(len(soundtracks))
|
||||
expected := int32(42)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Average calculation with single soundtrack = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRandomSoundtrack(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||
}
|
||||
|
||||
// Set seed for reproducible tests
|
||||
rand.Seed(42)
|
||||
|
||||
result := soundtracks[rand.Intn(len(soundtracks))]
|
||||
|
||||
if result.SoundtrackName == "" {
|
||||
t.Error("random soundtrack selection returned empty soundtrack")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, s := range soundtracks {
|
||||
if s.SoundtrackName == result.SoundtrackName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("random soundtrack selection returned soundtrack not in list: %v", result.SoundtrackName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSoundtrackByID(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{ID: 1, SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||
{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
{ID: 3, SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
soundtracks []repository.Soundtrack
|
||||
soundtrackID int32
|
||||
expected repository.Soundtrack
|
||||
}{
|
||||
{
|
||||
name: "existing soundtrack",
|
||||
soundtracks: soundtracks,
|
||||
soundtrackID: 2,
|
||||
expected: repository.Soundtrack{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
},
|
||||
{
|
||||
name: "non-existing soundtrack",
|
||||
soundtracks: soundtracks,
|
||||
soundtrackID: 99,
|
||||
expected: repository.Soundtrack{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var result repository.Soundtrack
|
||||
for _, s := range tt.soundtracks {
|
||||
if s.ID == tt.soundtrackID {
|
||||
result = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName {
|
||||
t.Errorf("findSoundtrackByID() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSoundtrackNames(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||
}
|
||||
|
||||
var result []string
|
||||
for _, s := range soundtracks {
|
||||
result = append(result, s.SoundtrackName)
|
||||
}
|
||||
|
||||
expected := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
|
||||
|
||||
if len(result) != len(expected) {
|
||||
t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected))
|
||||
return
|
||||
}
|
||||
|
||||
for i, v := range result {
|
||||
if v != expected[i] {
|
||||
t.Errorf("extractSoundtrackNames()[%d] = %v, want %v", i, v, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffleSoundtrackNames(t *testing.T) {
|
||||
soundtracks := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
|
||||
|
||||
// Test that shuffle doesn't lose any elements
|
||||
// We can't test the order since it's random, but we can test length and contents
|
||||
original := make([]string, len(soundtracks))
|
||||
copy(original, soundtracks)
|
||||
|
||||
// Simple shuffle implementation for testing
|
||||
for i := range soundtracks {
|
||||
j := i // In real code this would be random
|
||||
soundtracks[i], soundtracks[j] = soundtracks[j], soundtracks[i]
|
||||
}
|
||||
|
||||
if len(soundtracks) != len(original) {
|
||||
t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(soundtracks))
|
||||
return
|
||||
}
|
||||
|
||||
// Check all original elements are still present
|
||||
for _, orig := range original {
|
||||
found := false
|
||||
for _, s := range soundtracks {
|
||||
if s == orig {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("shuffleSoundtrackNames() lost element: %v", orig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const releaseCacheTTL = 5 * time.Minute
|
||||
|
||||
type giteaRelease struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Assets []giteaAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type giteaAsset struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DownloadUrl string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// ReleaseService looks up the latest client release on Gitea.
|
||||
type ReleaseService struct {
|
||||
url string
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cached giteaRelease
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
// NewReleaseService creates a ReleaseService that reads the latest release from url.
|
||||
func NewReleaseService(url string) *ReleaseService {
|
||||
return &ReleaseService{url: url, client: &http.Client{Timeout: 10 * time.Second}, now: time.Now}
|
||||
}
|
||||
|
||||
func (r *ReleaseService) latest(ctx context.Context) (giteaRelease, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.fetchedAt.IsZero() && r.now().Sub(r.fetchedAt) < releaseCacheTTL {
|
||||
return r.cached, nil
|
||||
}
|
||||
|
||||
release, err := r.fetch(ctx)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to fetch latest release", zap.String("url", r.url), zap.Error(err))
|
||||
return giteaRelease{}, fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||
}
|
||||
r.cached, r.fetchedAt = release, r.now()
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (r *ReleaseService) fetch(ctx context.Context) (giteaRelease, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.url, nil)
|
||||
if err != nil {
|
||||
return giteaRelease{}, err
|
||||
}
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return giteaRelease{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return giteaRelease{}, fmt.Errorf("unexpected status %s", resp.Status)
|
||||
}
|
||||
var release giteaRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return giteaRelease{}, err
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// LatestVersion returns the name of the latest release.
|
||||
func (r *ReleaseService) LatestVersion(ctx context.Context) (string, error) {
|
||||
release, err := r.latest(ctx)
|
||||
return release.Name, err
|
||||
}
|
||||
|
||||
// AssetNames returns the names of all assets of the latest release.
|
||||
func (r *ReleaseService) AssetNames(ctx context.Context) ([]string, error) {
|
||||
release, err := r.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, len(release.Assets))
|
||||
for i, asset := range release.Assets {
|
||||
names[i] = asset.Name
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// AssetURL returns the download URL of the first asset of the latest release with the given suffix.
|
||||
func (r *ReleaseService) AssetURL(ctx context.Context, suffix string) (string, error) {
|
||||
release, err := r.latest(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, asset := range release.Assets {
|
||||
if strings.HasSuffix(asset.Name, suffix) {
|
||||
return asset.DownloadUrl, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -13,88 +13,71 @@ type SoundtrackData struct {
|
||||
Cleaned string
|
||||
}
|
||||
|
||||
var (
|
||||
searchCache []SoundtrackData
|
||||
searchCacheMutex sync.Mutex
|
||||
searchRegexCache = make(map[string]*regexp.Regexp)
|
||||
searchRegexCacheMutex sync.Mutex
|
||||
)
|
||||
// SearchCache holds precomputed search data for all soundtrack names.
|
||||
type SearchCache struct {
|
||||
load func(context.Context) ([]string, error)
|
||||
|
||||
// GetSearchCache returns the precomputed search cache, initializing it if needed
|
||||
func GetSearchCache() []SoundtrackData {
|
||||
searchCacheMutex.Lock()
|
||||
defer searchCacheMutex.Unlock()
|
||||
mu sync.Mutex
|
||||
cache []SoundtrackData
|
||||
}
|
||||
|
||||
if len(searchCache) == 0 {
|
||||
loadSearchCache()
|
||||
// NewSearchCache creates a SearchCache that gets the soundtrack names from load.
|
||||
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache {
|
||||
return &SearchCache{load: load}
|
||||
}
|
||||
|
||||
// Get returns the precomputed search data, loading it if needed.
|
||||
func (s *SearchCache) Get(ctx context.Context) ([]SoundtrackData, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.cache) == 0 {
|
||||
if err := s.reload(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return searchCache
|
||||
}
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
// ReloadSearchCache clears and reloads the search cache.
|
||||
// This should be called after a sync operation to ensure search results are up to date.
|
||||
func ReloadSearchCache() {
|
||||
searchCacheMutex.Lock()
|
||||
defer searchCacheMutex.Unlock()
|
||||
searchCache = nil
|
||||
loadSearchCache()
|
||||
// Reload reloads the search data. Call it after a sync.
|
||||
func (s *SearchCache) Reload(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.reload(ctx)
|
||||
}
|
||||
|
||||
// loadSearchCache loads the soundtrack data into the cache
|
||||
func loadSearchCache() {
|
||||
soundtracks := GetAllSoundtracks()
|
||||
searchCache = make([]SoundtrackData, len(soundtracks))
|
||||
|
||||
func (s *SearchCache) reload(ctx context.Context) error {
|
||||
soundtracks, err := s.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cache := make([]SoundtrackData, len(soundtracks))
|
||||
for i, soundtrack := range soundtracks {
|
||||
cleaned := cleanSearchTerm(soundtrack)
|
||||
lowercase := strings.ToLower(soundtrack)
|
||||
searchCache[i] = SoundtrackData{
|
||||
cache[i] = SoundtrackData{
|
||||
Original: soundtrack,
|
||||
Lowercase: lowercase,
|
||||
Cleaned: cleaned,
|
||||
Lowercase: strings.ToLower(soundtrack),
|
||||
Cleaned: CleanSearchTerm(soundtrack),
|
||||
}
|
||||
}
|
||||
s.cache = cache
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanSearchTermForMatch cleans a search term for better matching
|
||||
func CleanSearchTermForMatch(term string) string {
|
||||
term = strings.ReplaceAll(term, " ", "")
|
||||
term = strings.ReplaceAll(term, "é", "e")
|
||||
term = strings.ReplaceAll(term, "+", "plus")
|
||||
term = strings.ReplaceAll(term, "&", "and")
|
||||
term = strings.ReplaceAll(term, "'n", "and")
|
||||
return strings.ToLower(term)
|
||||
var searchReplacer = strings.NewReplacer(" ", "", "é", "e", "+", "plus", "&", "and", "'n", "and")
|
||||
|
||||
// CleanSearchTerm normalises a search term or soundtrack name for matching.
|
||||
func CleanSearchTerm(term string) string {
|
||||
return searchReplacer.Replace(strings.ToLower(term))
|
||||
}
|
||||
|
||||
// cleanSearchTerm cleans a search term for better matching (internal)
|
||||
func cleanSearchTerm(term string) string {
|
||||
return CleanSearchTermForMatch(term)
|
||||
}
|
||||
|
||||
// MatchRegexCached checks if a search term matches using cached compiled regex
|
||||
func MatchRegexCached(searchTerm string, soundtrackName string) bool {
|
||||
if searchTerm == "" {
|
||||
// MatchesSubsequence reports whether all characters of term appear in text in the same order.
|
||||
func MatchesSubsequence(term, text string) bool {
|
||||
rest := text
|
||||
for _, r := range term {
|
||||
i := strings.IndexRune(rest, r)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
rest = rest[i+len(string(r)):]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
searchRegexCacheMutex.Lock()
|
||||
re, exists := searchRegexCache[searchTerm]
|
||||
if !exists {
|
||||
re = compileSearchRegex(searchTerm)
|
||||
searchRegexCache[searchTerm] = re
|
||||
}
|
||||
searchRegexCacheMutex.Unlock()
|
||||
|
||||
return re.MatchString(soundtrackName)
|
||||
}
|
||||
|
||||
// compileSearchRegex compiles a search term into a regex pattern
|
||||
func compileSearchRegex(searchTerm string) *regexp.Regexp {
|
||||
regText := ".*"
|
||||
for _, letter := range searchTerm {
|
||||
regText += string(letter) + ".*"
|
||||
}
|
||||
re, _ := regexp.Compile(regText)
|
||||
return re
|
||||
}
|
||||
+64
-184
@@ -1,12 +1,11 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"music-server/internal/db/repository"
|
||||
)
|
||||
|
||||
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
|
||||
@@ -39,239 +38,120 @@ type StatisticsSummary struct {
|
||||
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
|
||||
}
|
||||
|
||||
// StatisticsHandler manages statistics operations
|
||||
type StatisticsHandler struct {
|
||||
// Uses the global backend repo initialized via InitBackend
|
||||
// StatisticsService reads play statistics.
|
||||
type StatisticsService struct {
|
||||
q repository.Querier
|
||||
}
|
||||
|
||||
// NewStatisticsHandler creates a new StatisticsHandler
|
||||
func NewStatisticsHandler() *StatisticsHandler {
|
||||
return &StatisticsHandler{}
|
||||
// NewStatisticsService creates a StatisticsService.
|
||||
func NewStatisticsService(q repository.Querier) *StatisticsService {
|
||||
return &StatisticsService{q: q}
|
||||
}
|
||||
|
||||
// GetMostPlayedSoundtracksWithSongs returns the top N most played soundtracks with their songs
|
||||
func (h *StatisticsHandler) GetMostPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
// soundtrackRow covers the generated rows that share the soundtrack-with-songs shape.
|
||||
type soundtrackRow interface {
|
||||
repository.GetMostPlayedSoundtracksWithSongsRow |
|
||||
repository.GetLeastPlayedSoundtracksWithSongsRow |
|
||||
repository.GetLastPlayedSoundtracksRow |
|
||||
repository.GetOldestPlayedSoundtracksRow
|
||||
}
|
||||
|
||||
// Get raw results
|
||||
rows, err := queries.GetMostPlayedSoundtracksWithSongs(ctx, limit)
|
||||
func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to SoundtrackWithSongs
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
// Parse JSON songs array
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
// Fallback: if JSON parsing fails, create empty song entries
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
Songs: songs,
|
||||
Songs: parseSongs(row.Songs),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLeastPlayedSoundtracksWithSongs returns the top N least played soundtracks with their songs
|
||||
func (h *StatisticsHandler) GetLeastPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetLeastPlayedSoundtracksWithSongs(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
Songs: songs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
type songRow interface {
|
||||
repository.GetMostPlayedSongsWithSoundtrackRow | repository.GetLeastPlayedSongsWithSoundtrackRow
|
||||
}
|
||||
|
||||
// GetMostPlayedSongsWithSoundtrack returns the top N most played songs with their soundtrack info
|
||||
func (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
|
||||
func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SongInfoForStats
|
||||
for _, row := range rows {
|
||||
result = append(result, SongInfoForStats{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SongName: row.SongName,
|
||||
Path: row.Path,
|
||||
TimesPlayed: row.TimesPlayed,
|
||||
FileName: row.FileName,
|
||||
})
|
||||
for _, r := range rows {
|
||||
result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r)))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLeastPlayedSongsWithSoundtrack returns the top N least played songs with their soundtrack info
|
||||
func (h *StatisticsHandler) GetLeastPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func parseSongs(data []byte) []SongInfoForStats {
|
||||
songs := []SongInfoForStats{}
|
||||
if data != nil {
|
||||
if err := json.Unmarshal(data, &songs); err != nil {
|
||||
return []SongInfoForStats{}
|
||||
}
|
||||
|
||||
var result []SongInfoForStats
|
||||
for _, row := range rows {
|
||||
result = append(result, SongInfoForStats{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SongName: row.SongName,
|
||||
Path: row.Path,
|
||||
TimesPlayed: row.TimesPlayed,
|
||||
FileName: row.FileName,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
return songs
|
||||
}
|
||||
|
||||
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||
func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
// MostPlayedSoundtracks returns the top N most played soundtracks with their songs
|
||||
func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
|
||||
return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit))
|
||||
}
|
||||
|
||||
rows, err := queries.GetNeverPlayedSoundtracks(ctx)
|
||||
// LeastPlayedSoundtracks returns the top N least played soundtracks with their songs
|
||||
func (s *StatisticsService) LeastPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
|
||||
return toSoundtracks(s.q.GetLeastPlayedSoundtracksWithSongs(ctx, limit))
|
||||
}
|
||||
|
||||
// LastPlayedSoundtracks returns the most recently played soundtracks
|
||||
func (s *StatisticsService) LastPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
|
||||
return toSoundtracks(s.q.GetLastPlayedSoundtracks(ctx, limit))
|
||||
}
|
||||
|
||||
// OldestPlayedSoundtracks returns the least recently played soundtracks
|
||||
func (s *StatisticsService) OldestPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
|
||||
return toSoundtracks(s.q.GetOldestPlayedSoundtracks(ctx, limit))
|
||||
}
|
||||
|
||||
// NeverPlayedSoundtracks returns soundtracks that have never been played
|
||||
func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]SoundtrackWithSongs, error) {
|
||||
rows, err := s.q.GetNeverPlayedSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: nil,
|
||||
Songs: songs,
|
||||
Songs: parseSongs(row.Songs),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLastPlayedSoundtracks returns the most recently played soundtracks
|
||||
func (h *StatisticsHandler) GetLastPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetLastPlayedSoundtracks(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
Songs: songs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
// MostPlayedSongs returns the top N most played songs with their soundtrack info
|
||||
func (s *StatisticsService) MostPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
|
||||
return toSongs(s.q.GetMostPlayedSongsWithSoundtrack(ctx, limit))
|
||||
}
|
||||
|
||||
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
|
||||
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetOldestPlayedSoundtracks(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
Songs: songs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
// LeastPlayedSongs returns the top N least played songs with their soundtrack info
|
||||
func (s *StatisticsService) LeastPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
|
||||
return toSongs(s.q.GetLeastPlayedSongsWithSoundtrack(ctx, limit))
|
||||
}
|
||||
|
||||
// GetStatisticsSummary returns overall statistics
|
||||
func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
row, err := queries.GetStatisticsSummary(ctx)
|
||||
// Summary returns overall statistics
|
||||
func (s *StatisticsService) Summary(ctx context.Context) (StatisticsSummary, error) {
|
||||
row, err := s.q.GetStatisticsSummary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &StatisticsSummary{
|
||||
TotalSoundtracks: int64(row.TotalSoundtracks),
|
||||
PlayedSoundtracks: int64(row.PlayedSoundtracks),
|
||||
NeverPlayedSoundtracks: int64(row.NeverPlayedSoundtracks),
|
||||
TotalSoundtrackPlays: int64(row.TotalSoundtrackPlays),
|
||||
AvgSoundtrackPlays: float64(row.AvgSoundtrackPlays),
|
||||
MaxSoundtrackPlays: int64(row.MaxSoundtrackPlays),
|
||||
MinSoundtrackPlays: int64(row.MinSoundtrackPlays),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Log helper for statistics operations
|
||||
func logStatisticsError(err error, operation string) {
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Statistics error",
|
||||
zap.String("operation", operation),
|
||||
zap.String("error", err.Error()))
|
||||
return StatisticsSummary{}, err
|
||||
}
|
||||
return StatisticsSummary(row), nil
|
||||
}
|
||||
+547
-475
File diff suppressed because it is too large.
Load diff
@@ -1,204 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slice []string
|
||||
search string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "element exists",
|
||||
slice: []string{"a", "b", "c"},
|
||||
search: "b",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "element does not exist",
|
||||
slice: []string{"a", "b", "c"},
|
||||
search: "d",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty slice",
|
||||
slice: []string{},
|
||||
search: "a",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "element at start",
|
||||
slice: []string{"a", "b", "c"},
|
||||
search: "a",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "element at end",
|
||||
slice: []string{"a", "b", "c"},
|
||||
search: "c",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := contains(tt.slice, tt.search)
|
||||
if result != tt.expected {
|
||||
t.Errorf("contains() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSong(t *testing.T) {
|
||||
mockFileInfo := &mockFileInfoForSong{name: "test.mp3", isDir: false, size: 100}
|
||||
|
||||
result := isSong(mockFileInfo)
|
||||
if !result {
|
||||
t.Error("isSong() should return true for .mp3 file")
|
||||
}
|
||||
|
||||
mockFileInfo2 := &mockFileInfoForSong{name: "test.txt", isDir: false, size: 100}
|
||||
result = isSong(mockFileInfo2)
|
||||
if result {
|
||||
t.Error("isSong() should return false for .txt file")
|
||||
}
|
||||
|
||||
mockFileInfo3 := &mockFileInfoForSong{name: "test", isDir: true, size: 100}
|
||||
result = isSong(mockFileInfo3)
|
||||
if result {
|
||||
t.Error("isSong() should return false for directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCoverImage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileInfo fs.FileInfo
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "cover.jpg",
|
||||
fileInfo: &mockFileInfoForCover{name: "cover.jpg", isDir: false, size: 100},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "cover.png",
|
||||
fileInfo: &mockFileInfoForCover{name: "cover.png", isDir: false, size: 100},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "my_cover.jpg",
|
||||
fileInfo: &mockFileInfoForCover{name: "my_cover.jpg", isDir: false, size: 100},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "image.jpg",
|
||||
fileInfo: &mockFileInfoForCover{name: "image.jpg", isDir: false, size: 100},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "cover.txt",
|
||||
fileInfo: &mockFileInfoForCover{name: "cover.txt", isDir: false, size: 100},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "directory",
|
||||
fileInfo: &mockFileInfoForCover{name: "cover", isDir: true, size: 100},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isCoverImage(tt.fileInfo)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isCoverImage() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIdFromFileNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileInfo os.FileInfo
|
||||
expected int32
|
||||
}{
|
||||
{
|
||||
name: "valid id file",
|
||||
fileInfo: &mockFileInfoForId{name: ".123.id", isDir: false, size: 100},
|
||||
expected: 123,
|
||||
},
|
||||
{
|
||||
name: "invalid id file (directory)",
|
||||
fileInfo: &mockFileInfoForId{name: ".123.id", isDir: true, size: 100},
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "invalid id file (no .id extension)",
|
||||
fileInfo: &mockFileInfoForId{name: "123.txt", isDir: false, size: 100},
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "invalid id file (not a number)",
|
||||
fileInfo: &mockFileInfoForId{name: ".abc.id", isDir: false, size: 100},
|
||||
expected: 0, // strconv.Atoi returns 0 for invalid numbers (error is ignored)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getIdFromFile(tt.fileInfo)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getIdFromFile() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mock types for testing
|
||||
type mockFileInfoForSong struct {
|
||||
name string
|
||||
isDir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (m *mockFileInfoForSong) Name() string { return m.name }
|
||||
func (m *mockFileInfoForSong) Size() int64 { return m.size }
|
||||
func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 }
|
||||
func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} }
|
||||
func (m *mockFileInfoForSong) IsDir() bool { return m.isDir }
|
||||
func (m *mockFileInfoForSong) Sys() interface{} { return nil }
|
||||
|
||||
type mockFileInfoForCover struct {
|
||||
name string
|
||||
isDir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (m *mockFileInfoForCover) Name() string { return m.name }
|
||||
func (m *mockFileInfoForCover) Size() int64 { return m.size }
|
||||
func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 }
|
||||
func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} }
|
||||
func (m *mockFileInfoForCover) IsDir() bool { return m.isDir }
|
||||
func (m *mockFileInfoForCover) Sys() interface{} { return nil }
|
||||
|
||||
type mockFileInfoForId struct {
|
||||
name string
|
||||
isDir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (m *mockFileInfoForId) Name() string { return m.name }
|
||||
func (m *mockFileInfoForId) Size() int64 { return m.size }
|
||||
func (m *mockFileInfoForId) Mode() os.FileMode { return 0 }
|
||||
func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} }
|
||||
func (m *mockFileInfoForId) IsDir() bool { return m.isDir }
|
||||
func (m *mockFileInfoForId) Sys() interface{} { return nil }
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package config loads the application configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultReleasesURL is the Gitea endpoint used to look up the latest client release.
|
||||
const DefaultReleasesURL = "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest"
|
||||
|
||||
// Config holds all settings read from the environment.
|
||||
type Config struct {
|
||||
Port int
|
||||
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
|
||||
MusicPath string
|
||||
CharactersPath string
|
||||
SoundCheckPath string
|
||||
FrontendPath string
|
||||
|
||||
LogLevel string
|
||||
LogJSON bool
|
||||
|
||||
// APIPassword is required to create tokens. Empty means any password is accepted.
|
||||
APIPassword string
|
||||
DisableSessionAuth bool
|
||||
|
||||
// TrustedProxies are CIDR ranges whose X-Forwarded-For header is trusted when resolving client IPs.
|
||||
TrustedProxies []*net.IPNet
|
||||
|
||||
ReleasesURL string
|
||||
}
|
||||
|
||||
// Load reads the configuration from the environment and validates it.
|
||||
func Load() (Config, error) {
|
||||
return load(os.Getenv)
|
||||
}
|
||||
|
||||
func load(getenv func(string) string) (Config, error) {
|
||||
cfg := Config{
|
||||
DBHost: getenv("DB_HOST"),
|
||||
DBPort: getenv("DB_PORT"),
|
||||
DBUser: getenv("DB_USERNAME"),
|
||||
DBPassword: getenv("DB_PASSWORD"),
|
||||
DBName: getenv("DB_NAME"),
|
||||
MusicPath: getenv("MUSIC_PATH"),
|
||||
CharactersPath: getenv("CHARACTERS_PATH"),
|
||||
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
|
||||
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
|
||||
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
|
||||
LogJSON: getenv("LOG_JSON") == "true",
|
||||
APIPassword: getenv("API_PASSWORD"),
|
||||
DisableSessionAuth: getenv("DISABLE_SESSION_AUTH") == "true",
|
||||
ReleasesURL: withDefault(getenv("RELEASES_URL"), DefaultReleasesURL),
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
port, err := strconv.Atoi(withDefault(getenv("PORT"), "8080"))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("PORT: %w", err))
|
||||
}
|
||||
cfg.Port = port
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"DB_HOST": cfg.DBHost,
|
||||
"DB_PORT": cfg.DBPort,
|
||||
"DB_USERNAME": cfg.DBUser,
|
||||
"DB_PASSWORD": cfg.DBPassword,
|
||||
"DB_NAME": cfg.DBName,
|
||||
"MUSIC_PATH": cfg.MusicPath,
|
||||
"CHARACTERS_PATH": cfg.CharactersPath,
|
||||
} {
|
||||
if value == "" {
|
||||
errs = append(errs, fmt.Errorf("%s is required", name))
|
||||
}
|
||||
}
|
||||
|
||||
cfg.TrustedProxies, err = parseCIDRs(getenv("TRUSTED_PROXIES"))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("TRUSTED_PROXIES: %w", err))
|
||||
}
|
||||
|
||||
return cfg, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func parseCIDRs(value string) ([]*net.IPNet, error) {
|
||||
var nets []*net.IPNet
|
||||
for part := range strings.SplitSeq(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return nets, nil
|
||||
}
|
||||
|
||||
func withDefault(value, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
+69
-84
@@ -2,54 +2,57 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Database holds the database connection pool and context
|
||||
//go:embed "migrations/*.sql"
|
||||
var MigrationsFs embed.FS
|
||||
|
||||
// Database holds the database connection pool
|
||||
type Database struct {
|
||||
Pool *pgxpool.Pool
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
// ConnString builds a PostgreSQL connection URL.
|
||||
func ConnString(host, port, user, password, dbname string) string {
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(user, password),
|
||||
Host: net.JoinHostPort(host, port),
|
||||
Path: dbname,
|
||||
RawQuery: "sslmode=disable",
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// NewDatabase creates a new Database instance with connection pool
|
||||
func NewDatabase(host, port, user, password, dbname string) (*Database, error) {
|
||||
ctx := context.Background()
|
||||
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
|
||||
logging.GetLogger().Debug("Database connection info",
|
||||
zap.String("host", host),
|
||||
zap.String("port", port),
|
||||
zap.String("dbname", dbname))
|
||||
|
||||
pool, err := pgxpool.New(ctx, psqlInfo)
|
||||
func NewDatabase(ctx context.Context, connString string) (*Database, error) {
|
||||
pool, err := pgxpool.New(ctx, connString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
var success string
|
||||
err = pool.QueryRow(ctx, "select 'Successfully connected!'").Scan(&success)
|
||||
if err != nil {
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("database query failed: %w", err)
|
||||
return nil, fmt.Errorf("database ping failed: %w", err)
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Database connected", zap.String("status", success))
|
||||
|
||||
return &Database{Pool: pool, Ctx: ctx}, nil
|
||||
logging.GetLogger().Info("Database connected")
|
||||
return &Database{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection pool
|
||||
@@ -61,82 +64,64 @@ func (db *Database) Close() {
|
||||
}
|
||||
|
||||
// Health checks the health of the database connection by pinging the database.
|
||||
// It returns a map with keys indicating various health statistics.
|
||||
func (db *Database) Health() map[string]string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
func (db *Database) Health(ctx context.Context) map[string]string {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats := make(map[string]string)
|
||||
|
||||
// Ping the database
|
||||
err := db.Pool.Ping(ctx)
|
||||
if err != nil {
|
||||
stats["status"] = "down"
|
||||
stats["error"] = err.Error()
|
||||
return stats
|
||||
if err := db.Pool.Ping(ctx); err != nil {
|
||||
return map[string]string{"status": "down", "error": err.Error()}
|
||||
}
|
||||
|
||||
stats["status"] = "up"
|
||||
return stats
|
||||
return map[string]string{"status": "up"}
|
||||
}
|
||||
|
||||
// RunMigrations runs all pending database migrations to the latest version.
|
||||
// Uses the existing pool to extract connection details.
|
||||
func (db *Database) RunMigrations() error {
|
||||
// Extract connection info from pool config
|
||||
connConfig := db.Pool.Config().ConnConfig
|
||||
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||
connConfig.User,
|
||||
connConfig.Password,
|
||||
connConfig.Host,
|
||||
connConfig.Port,
|
||||
connConfig.Database)
|
||||
|
||||
logging.GetLogger().Debug("Migration info", zap.String("url", migrationURL))
|
||||
|
||||
sqlDb, err := sql.Open("postgres", migrationURL)
|
||||
m, closeFn, err := db.NewMigrator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database for migration: %w", err)
|
||||
return err
|
||||
}
|
||||
defer sqlDb.Close()
|
||||
defer closeFn()
|
||||
|
||||
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
|
||||
version, _, err := m.Version()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
|
||||
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
|
||||
|
||||
if err := m.Up(); err != nil {
|
||||
if !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
logging.GetLogger().Info("Database already up to date")
|
||||
return nil
|
||||
}
|
||||
|
||||
versionAfter, _, _ := m.Version()
|
||||
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewMigrator returns a migrator for the embedded migrations using the pool's connection settings.
|
||||
// The returned function closes the underlying connection.
|
||||
func (db *Database) NewMigrator() (*migrate.Migrate, func(), error) {
|
||||
sqlDB := stdlib.OpenDB(*db.Pool.Config().ConnConfig)
|
||||
|
||||
driver, err := pgxmigrate.WithInstance(sqlDB, &pgxmigrate.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration driver: %w", err)
|
||||
sqlDB.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
files, err := iofs.New(MigrationsFs, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration files: %w", err)
|
||||
sqlDB.Close()
|
||||
return nil, nil, fmt.Errorf("failed to read migration files: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
||||
m, err := migrate.NewWithInstance("iofs", files, "pgx5", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrator: %w", err)
|
||||
sqlDB.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create migrator: %w", err)
|
||||
}
|
||||
|
||||
// Get current version for logging
|
||||
version, _, err := m.Version()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
|
||||
|
||||
// Run all pending migrations to latest version
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
if err == migrate.ErrNoChange {
|
||||
logging.GetLogger().Info("Database already up to date")
|
||||
} else {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Get new version after migration
|
||||
versionAfter, _, _ := m.Version()
|
||||
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration completed")
|
||||
return nil
|
||||
return m, func() { m.Close() }, nil
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/lib/pq"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TODO: DEPRECATED - Remove these global variables once all code is migrated to use Database struct
|
||||
// Use database.go's Database struct instead. These globals remain for backward compatibility
|
||||
// with legacy code paths. New code should use the Database struct from database.go.
|
||||
var Dbpool *pgxpool.Pool
|
||||
var Ctx = context.Background()
|
||||
|
||||
//go:embed "migrations/*.sql"
|
||||
var MigrationsFs embed.FS
|
||||
|
||||
func InitDB(host string, port string, user string, password string, dbname string) {
|
||||
|
||||
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s "+
|
||||
"password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
|
||||
logging.GetLogger().Debug("Database connection info", zap.String("host", host), zap.String("port", port), zap.String("dbname", dbname))
|
||||
|
||||
var err error
|
||||
Dbpool, err = pgxpool.New(Ctx, psqlInfo)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Unable to connect to database", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
var success string
|
||||
err = Dbpool.QueryRow(Ctx, "select 'Successfully connected!'").Scan(&success)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Database query failed", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Info("Database connected", zap.String("status", success))
|
||||
}
|
||||
|
||||
func CloseDb() {
|
||||
logging.GetLogger().Info("Closing database connection")
|
||||
Dbpool.Close()
|
||||
}
|
||||
|
||||
func ResetSoundtrackIdSeq() {
|
||||
_, err := Dbpool.Query(Ctx, "SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);")
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to reset soundtrack ID sequence", zap.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func createDb(host string, port string, user string, password string, dbname string) {
|
||||
// Connect to the default postgres database to create new database
|
||||
// In PostgreSQL, we need to connect to an existing database (postgres) to create a new one
|
||||
conninfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", host, port, user, password)
|
||||
db, err := sql.Open("postgres", conninfo)
|
||||
defer db.Close()
|
||||
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to connect for database creation", zap.String("error", err.Error()))
|
||||
}
|
||||
_, err = db.Exec("create database " + dbname)
|
||||
if err != nil {
|
||||
//handle the error
|
||||
logging.GetLogger().Fatal("Failed to create database", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Info("Database created", zap.String("dbname", dbname))
|
||||
}
|
||||
|
||||
func Migrate_db(host string, port string, user string, password string, dbname string) {
|
||||
migrationInfo := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
user, password, host, port, dbname)
|
||||
|
||||
logging.GetLogger().Debug("Migration info", zap.String("url", migrationInfo))
|
||||
|
||||
db, err := sql.Open("postgres", migrationInfo)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to open database for migration", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to create migration driver", zap.String("error", err.Error()))
|
||||
}
|
||||
files, err := iofs.New(MigrationsFs, "migrations")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to create migration files", zap.String("error", err.Error()))
|
||||
}
|
||||
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to create migrator", zap.String("error", err.Error()))
|
||||
}
|
||||
/*m, err := migrate.NewWithDatabaseInstance(
|
||||
"file://./db/migrations/",
|
||||
"postgres", driver)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Migration setup error", zap.String("error", err.Error()))
|
||||
}*/
|
||||
|
||||
version, _, err := m.Version()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
|
||||
|
||||
//err = m.Force(1)
|
||||
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
|
||||
//if err != nil {
|
||||
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
|
||||
//}
|
||||
|
||||
// Use Up() to apply all pending migrations instead of Migrate(2)
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
if err == migrate.ErrNoChange {
|
||||
logging.GetLogger().Info("Database already up to date")
|
||||
} else {
|
||||
logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
|
||||
}
|
||||
} else {
|
||||
versionAfter, _, err := m.Version()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
|
||||
} else {
|
||||
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||
}
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration completed")
|
||||
|
||||
db.Close()
|
||||
}
|
||||
|
||||
// Health checks the health of the database connection by pinging the database.
|
||||
// It returns a map with keys indicating various health statistics.
|
||||
func Health() map[string]string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats := make(map[string]string)
|
||||
|
||||
// Ping the database
|
||||
//err := s.db.PingContext(ctx)
|
||||
err := Dbpool.Ping(ctx)
|
||||
if err != nil {
|
||||
stats["status"] = "down"
|
||||
stats["error"] = fmt.Sprintf("db down: %v", err)
|
||||
logging.GetLogger().Fatal("Database health check failed", zap.String("error", err.Error()))
|
||||
return stats
|
||||
}
|
||||
|
||||
// Database is up, add more statistics
|
||||
stats["status"] = "up"
|
||||
stats["message"] = "It's healthy"
|
||||
|
||||
// Get database stats (like open connections, in use, idle, etc.)
|
||||
//dbStats := s.db.Stats()
|
||||
dbStats := Dbpool.Stat()
|
||||
//stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
|
||||
stats["open_connections"] = strconv.Itoa(int(dbStats.NewConnsCount()))
|
||||
//stats["in_use"] = strconv.Itoa(dbStats.InUse)
|
||||
stats["in_use"] = strconv.Itoa(int(dbStats.AcquiredConns()))
|
||||
//stats["idle"] = strconv.Itoa(dbStats.Idle)
|
||||
stats["idle"] = strconv.Itoa(int(dbStats.IdleConns()))
|
||||
//stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
|
||||
stats["wait_count"] = strconv.FormatInt(dbStats.AcquireCount(), 10)
|
||||
//stats["wait_duration"] = dbStats.WaitDuration.String()
|
||||
stats["wait_duration"] = dbStats.AcquireDuration().String()
|
||||
//stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
|
||||
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleDestroyCount(), 10)
|
||||
//stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
|
||||
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeDestroyCount(), 10)
|
||||
|
||||
// Evaluate stats to provide a health message
|
||||
if int(dbStats.NewConnsCount()) > 40 { // Assuming 50 is the max for this example
|
||||
stats["message"] = "The database is experiencing heavy load."
|
||||
}
|
||||
|
||||
if dbStats.AcquireCount() > 1000 {
|
||||
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
|
||||
}
|
||||
|
||||
if dbStats.MaxIdleDestroyCount() > int64(dbStats.NewConnsCount())/2 {
|
||||
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
|
||||
}
|
||||
|
||||
if dbStats.MaxLifetimeDestroyCount() > int64(dbStats.NewConnsCount())/2 {
|
||||
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMigrationsStepByStep tests applying migrations incrementally
|
||||
// Then adding data manually, then completing migrations
|
||||
func TestMigrationsStepByStep(t *testing.T) {
|
||||
host := os.Getenv("DB_HOST")
|
||||
port := os.Getenv("DB_PORT")
|
||||
user := os.Getenv("DB_USERNAME")
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
// Use a unique database name for this test
|
||||
dbname := "music_server_migration_test"
|
||||
|
||||
if host == "" || port == "" || user == "" || password == "" {
|
||||
t.Skip("Test database environment variables not set (DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD)")
|
||||
}
|
||||
|
||||
// Clean up: drop database if it exists
|
||||
cleanupDB(t, host, port, user, password, dbname)
|
||||
defer cleanupDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Create the database
|
||||
createTestDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Step 1: Apply first 4 migrations (before soundtrack rename)
|
||||
// This creates: game, song, vgmq, song_list tables
|
||||
// And sessions table with indexes
|
||||
t.Run("ApplyFirst4Migrations", func(t *testing.T) {
|
||||
applyMigrations(t, host, port, user, password, dbname, 4)
|
||||
})
|
||||
|
||||
// Step 2: Add data manually to game and song tables
|
||||
t.Run("AddManualData", func(t *testing.T) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Insert 5 games manually
|
||||
for i := 1; i <= 5; i++ {
|
||||
gameName := fmt.Sprintf("Manual Game %d", i)
|
||||
path := fmt.Sprintf("/manual/path/game%d", i)
|
||||
hash := fmt.Sprintf("hash-%d", i)
|
||||
|
||||
_, err := db.Exec(`INSERT INTO game (game_name, path, hash, added)
|
||||
VALUES ($1, $2, $3, NOW())`,
|
||||
gameName, path, hash)
|
||||
require.NoError(t, err, "Failed to insert game %d", i)
|
||||
}
|
||||
|
||||
// Insert songs for each game
|
||||
songs := []struct {
|
||||
gameID int
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{1, "Song A", "/path/a.mp3"},
|
||||
{1, "Song B", "/path/b.mp3"},
|
||||
{2, "Song C", "/path/c.mp3"},
|
||||
{2, "Song D", "/path/d.mp3"},
|
||||
{3, "Song E", "/path/e.mp3"},
|
||||
{4, "Song F", "/path/f.mp3"},
|
||||
{4, "Song G", "/path/g.mp3"},
|
||||
{4, "Song H", "/path/h.mp3"},
|
||||
{5, "Song I", "/path/i.mp3"},
|
||||
}
|
||||
|
||||
for _, s := range songs {
|
||||
_, err := db.Exec(`INSERT INTO song (game_id, song_name, path, hash)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name))
|
||||
require.NoError(t, err, "Failed to insert song %s", s.name)
|
||||
}
|
||||
|
||||
// Verify data was inserted
|
||||
var gameCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM game").Scan(&gameCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, gameCount, "Expected 5 games")
|
||||
|
||||
var songCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 9, songCount, "Expected 9 songs")
|
||||
|
||||
t.Log("✓ Manually inserted 5 games with 9 songs")
|
||||
})
|
||||
|
||||
// Step 3: Apply migration 5 (rename game→soundtrack)
|
||||
t.Run("ApplyMigration5", func(t *testing.T) {
|
||||
// Apply the remaining migrations (just migration 5)
|
||||
applyMigrations(t, host, port, user, password, dbname, 1)
|
||||
|
||||
// Verify tables were renamed
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Check that soundtrack table exists
|
||||
var soundtrackCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, soundtrackCount, "Expected 5 soundtracks after migration")
|
||||
|
||||
// Check that game table no longer exists
|
||||
_, err = db.Exec("SELECT 1 FROM game LIMIT 1")
|
||||
require.Error(t, err, "game table should not exist after migration")
|
||||
|
||||
// Check that song table has soundtrack_id column
|
||||
var songCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 9, songCount, "Expected 9 songs after migration")
|
||||
|
||||
// Verify data integrity: soundtrack_name values
|
||||
rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id")
|
||||
require.NoError(t, err)
|
||||
defer rows.Close()
|
||||
|
||||
expectedNames := []string{"Manual Game 1", "Manual Game 2", "Manual Game 3", "Manual Game 4", "Manual Game 5"}
|
||||
actualNames := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
err := rows.Scan(&name)
|
||||
require.NoError(t, err)
|
||||
actualNames = append(actualNames, name)
|
||||
}
|
||||
require.Equal(t, expectedNames, actualNames, "Soundtrack names should match original game names")
|
||||
|
||||
t.Log("✓ Migration 5 applied successfully, data preserved")
|
||||
})
|
||||
}
|
||||
|
||||
// cleanupDB drops the test database
|
||||
func cleanupDB(t *testing.T, host, port, user, password, dbname string) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||
host, port, user, password)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not connect to cleanup DB: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Check if database exists before dropping
|
||||
var exists int
|
||||
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Logf("Warning: could not check if DB exists: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists == 1 {
|
||||
_, err = db.Exec("DROP DATABASE " + dbname + " WITH (FORCE)")
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not drop DB: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createTestDB creates a fresh test database
|
||||
func createTestDB(t *testing.T, host, port, user, password, dbname string) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||
host, port, user, password)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Drop if exists
|
||||
cleanupDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Create database
|
||||
_, err = db.Exec("CREATE DATABASE " + dbname)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Enable UUID extension if needed
|
||||
connStrDB := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db2, err := sql.Open("postgres", connStrDB)
|
||||
require.NoError(t, err)
|
||||
defer db2.Close()
|
||||
|
||||
_, err = db2.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
|
||||
if err != nil {
|
||||
t.Logf("Note: uuid-ossp extension may not be available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyMigrations applies n migrations to the database using Go migrate library
|
||||
func applyMigrations(t *testing.T, host, port, user, password, dbname string, steps int) {
|
||||
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
user, password, host, port, dbname)
|
||||
|
||||
db, err := sql.Open("postgres", migrationURL)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
"file://migrations",
|
||||
"postgres", driver)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get current version
|
||||
version, _, err := m.Version()
|
||||
if err != nil && err != migrate.ErrNilVersion {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if err == migrate.ErrNilVersion {
|
||||
version = 0
|
||||
}
|
||||
t.Logf("Current migration version: %d", version)
|
||||
|
||||
// Apply exactly 'steps' migrations
|
||||
if steps > 0 {
|
||||
err = m.Steps(steps)
|
||||
if err != nil && err != migrate.ErrNoChange {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
} else if steps < 0 {
|
||||
err = m.Steps(steps)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get new version
|
||||
newVersion, _, err := m.Version()
|
||||
if err != nil && err != migrate.ErrNilVersion {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if err == migrate.ErrNilVersion {
|
||||
newVersion = 0
|
||||
}
|
||||
t.Logf("Migration version after applying %d steps: %d", steps, newVersion)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE soundtrack DROP COLUMN fingerprint;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Cheap fingerprint (file names, sizes and modification times) used to skip
|
||||
-- content hashing of soundtracks that have not changed since the last sync.
|
||||
ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
|
||||
@@ -41,7 +41,7 @@ SELECT * FROM song WHERE uuid = $1;
|
||||
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||
|
||||
-- name: RemoveBrokenSongs :exec
|
||||
DELETE FROM song WHERE uuid = ANY($1);
|
||||
DELETE FROM song WHERE uuid = ANY(sqlc.arg(uuids)::uuid[]);
|
||||
|
||||
-- name: GetSongsBySoundtrackUUID :many
|
||||
SELECT *
|
||||
@@ -50,3 +50,15 @@ WHERE soundtrack_uuid = $1;
|
||||
|
||||
-- name: GetSongUUIDsBySoundtrackId :many
|
||||
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
|
||||
|
||||
-- name: UpdateSongByUUID :exec
|
||||
UPDATE song SET song_name=sqlc.arg(song_name), file_name=sqlc.arg(file_name), path=sqlc.arg(path), hash=sqlc.arg(hash)
|
||||
WHERE uuid=sqlc.arg(uuid);
|
||||
|
||||
-- name: GetRandomActiveSong :one
|
||||
SELECT sqlc.embed(song), soundtrack.path AS soundtrack_path
|
||||
FROM song
|
||||
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
|
||||
WHERE soundtrack.deleted IS NULL
|
||||
ORDER BY random()
|
||||
LIMIT 1;
|
||||
@@ -13,6 +13,12 @@ AND deleted IS NULL;
|
||||
-- name: SetSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL;
|
||||
|
||||
-- name: MarkSoundtracksDeletedExcept :exec
|
||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY(sqlc.arg(ids)::int[]));
|
||||
|
||||
-- name: SetSoundtrackFingerprint :exec
|
||||
UPDATE soundtrack SET fingerprint=sqlc.arg(fingerprint) WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: ClearSoundtracks :exec
|
||||
DELETE FROM soundtrack;
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ type Soundtrack struct {
|
||||
NumberOfSongs int32 `json:"number_of_songs"`
|
||||
Hash string `json:"hash"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type Vgmq struct {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
AddHashToSong(ctx context.Context, arg AddHashToSongParams) error
|
||||
AddSong(ctx context.Context, arg AddSongParams) error
|
||||
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
|
||||
AddSoundtrackPlayed(ctx context.Context, id int32) error
|
||||
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
|
||||
CheckSongWithHash(ctx context.Context, hash string) (int64, error)
|
||||
ClearSongs(ctx context.Context) error
|
||||
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
|
||||
ClearSoundtracks(ctx context.Context) error
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||
DeleteExpiredSessions(ctx context.Context) error
|
||||
DeleteSession(ctx context.Context, token string) error
|
||||
FetchAllSongs(ctx context.Context) ([]Song, error)
|
||||
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
||||
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
|
||||
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error)
|
||||
GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
|
||||
// Last played soundtracks (most recently played)
|
||||
GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error)
|
||||
// Least played songs with their soundtrack info
|
||||
GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithSoundtrackRow, error)
|
||||
// Least played soundtracks with their songs
|
||||
GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error)
|
||||
// Most played songs with their soundtrack info
|
||||
GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithSoundtrackRow, error)
|
||||
// Most played soundtracks with their songs
|
||||
GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error)
|
||||
// Soundtracks that have never been played (times_played = 0)
|
||||
GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error)
|
||||
// Oldest played soundtracks (least recently played, but has been played at least once)
|
||||
GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error)
|
||||
GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error)
|
||||
GetSession(ctx context.Context, token string) (Session, error)
|
||||
GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error)
|
||||
GetSongList(ctx context.Context, matchDate time.Time) ([]SongList, error)
|
||||
GetSongUUIDsBySoundtrackId(ctx context.Context, soundtrackID int32) ([]GetSongUUIDsBySoundtrackIdRow, error)
|
||||
GetSongWithHash(ctx context.Context, hash string) (Song, error)
|
||||
GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error)
|
||||
GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, error)
|
||||
GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (Soundtrack, error)
|
||||
GetSoundtrackNameById(ctx context.Context, id int32) (string, error)
|
||||
GetSoundtrackUUIDById(ctx context.Context, id int32) (uuid.UUID, error)
|
||||
// Get statistics summary
|
||||
GetStatisticsSummary(ctx context.Context) (GetStatisticsSummaryRow, error)
|
||||
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
|
||||
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
|
||||
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error
|
||||
ListSessions(ctx context.Context) ([]Session, error)
|
||||
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
|
||||
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
|
||||
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
|
||||
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
|
||||
ResetSoundtrackIdSeq(ctx context.Context) (int64, error)
|
||||
SetSoundtrackDeletionDate(ctx context.Context) error
|
||||
SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error
|
||||
UpdateSong(ctx context.Context, arg UpdateSongParams) error
|
||||
UpdateSongByUUID(ctx context.Context, arg UpdateSongByUUIDParams) error
|
||||
UpdateSoundtrackHash(ctx context.Context, arg UpdateSoundtrackHashParams) error
|
||||
UpdateSoundtrackName(ctx context.Context, arg UpdateSoundtrackNameParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -181,6 +181,37 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getRandomActiveSong = `-- name: GetRandomActiveSong :one
|
||||
SELECT song.soundtrack_id, song.song_name, song.path, song.times_played, song.hash, song.file_name, song.uuid, song.soundtrack_uuid, soundtrack.path AS soundtrack_path
|
||||
FROM song
|
||||
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
|
||||
WHERE soundtrack.deleted IS NULL
|
||||
ORDER BY random()
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetRandomActiveSongRow struct {
|
||||
Song Song `json:"song"`
|
||||
SoundtrackPath string `json:"soundtrack_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error) {
|
||||
row := q.db.QueryRow(ctx, getRandomActiveSong)
|
||||
var i GetRandomActiveSongRow
|
||||
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.SoundtrackPath,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSongByUUID = `-- name: GetSongByUUID :one
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE uuid = $1
|
||||
`
|
||||
@@ -300,11 +331,11 @@ func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongPara
|
||||
}
|
||||
|
||||
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
|
||||
DELETE FROM song WHERE uuid = ANY($1)
|
||||
DELETE FROM song WHERE uuid = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveBrokenSongs(ctx context.Context, argUuid uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, removeBrokenSongs, argUuid)
|
||||
func (q *Queries) RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, removeBrokenSongs, uuids)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -328,3 +359,27 @@ func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) error {
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSongByUUID = `-- name: UpdateSongByUUID :exec
|
||||
UPDATE song SET song_name=$1, file_name=$2, path=$3, hash=$4
|
||||
WHERE uuid=$5
|
||||
`
|
||||
|
||||
type UpdateSongByUUIDParams struct {
|
||||
SongName string `json:"song_name"`
|
||||
FileName *string `json:"file_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSongByUUID(ctx context.Context, arg UpdateSongByUUIDParams) error {
|
||||
_, err := q.db.Exec(ctx, updateSongByUUID,
|
||||
arg.SongName,
|
||||
arg.FileName,
|
||||
arg.Path,
|
||||
arg.Hash,
|
||||
arg.Uuid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
|
||||
}
|
||||
|
||||
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY soundtrack_name
|
||||
@@ -57,6 +57,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -69,7 +70,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
||||
}
|
||||
|
||||
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
||||
FROM soundtrack
|
||||
ORDER BY soundtrack_name
|
||||
`
|
||||
@@ -95,6 +96,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,7 +120,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
|
||||
}
|
||||
|
||||
const getSoundtrackById = `-- name: GetSoundtrackById :one
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
||||
FROM soundtrack
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL
|
||||
@@ -139,12 +141,13 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
||||
FROM soundtrack
|
||||
WHERE uuid = $1
|
||||
AND deleted IS NULL
|
||||
@@ -165,6 +168,7 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -240,6 +244,15 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
|
||||
return err
|
||||
}
|
||||
|
||||
const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
|
||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
|
||||
`
|
||||
|
||||
func (q *Queries) MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error {
|
||||
_, err := q.db.Exec(ctx, markSoundtracksDeletedExcept, ids)
|
||||
return err
|
||||
}
|
||||
|
||||
const removeSoundtrackDeletionDate = `-- name: RemoveSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=NULL WHERE id=$1
|
||||
`
|
||||
@@ -269,6 +282,20 @@ func (q *Queries) SetSoundtrackDeletionDate(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const setSoundtrackFingerprint = `-- name: SetSoundtrackFingerprint :exec
|
||||
UPDATE soundtrack SET fingerprint=$1 WHERE id=$2
|
||||
`
|
||||
|
||||
type SetSoundtrackFingerprintParams struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error {
|
||||
_, err := q.db.Exec(ctx, setSoundtrackFingerprint, arg.Fingerprint, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSoundtrackHash = `-- name: UpdateSoundtrackHash :exec
|
||||
UPDATE soundtrack SET hash=$1, last_changed=now() WHERE id=$2
|
||||
`
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
testDBSetupOnce sync.Once
|
||||
testDBHost string
|
||||
testDBPort string
|
||||
testDBUser string
|
||||
testDBPassword string
|
||||
testDBName string
|
||||
// TestDatabase is the database instance for tests
|
||||
TestDatabase *Database
|
||||
)
|
||||
|
||||
// TestSetupDB initializes the test database using existing functions
|
||||
// It creates the database if it doesn't exist and runs migrations
|
||||
// Uses sync.Once to ensure it only runs once across all tests
|
||||
func TestSetupDB(t *testing.T) {
|
||||
host := os.Getenv("DB_HOST")
|
||||
port := os.Getenv("DB_PORT")
|
||||
user := os.Getenv("DB_USERNAME")
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
dbname := os.Getenv("DB_NAME")
|
||||
|
||||
if host == "" || port == "" || user == "" || password == "" || dbname == "" {
|
||||
t.Skip("Test database environment variables not set")
|
||||
}
|
||||
|
||||
// Store for TestTearDownDB
|
||||
testDBHost = host
|
||||
testDBPort = port
|
||||
testDBUser = user
|
||||
testDBPassword = password
|
||||
testDBName = dbname
|
||||
|
||||
// Only run setup once
|
||||
testDBSetupOnce.Do(func() {
|
||||
// Create the database first (testuser is a superuser in the container)
|
||||
createTestDatabase(host, port, dbname, user, password)
|
||||
|
||||
// Create database instance and run migrations
|
||||
var err error
|
||||
TestDatabase, err = NewDatabase(host, port, user, password, dbname)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize test database: %v", err)
|
||||
}
|
||||
|
||||
// Clean up any existing schema to ensure clean state
|
||||
ctx := context.Background()
|
||||
_, err = TestDatabase.Pool.Exec(ctx, "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;")
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not clean schema: %v", err)
|
||||
// Continue anyway, migrations might still work
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := TestDatabase.RunMigrations(); err != nil {
|
||||
// Clean up on failure to prevent nil pointer issues in other tests
|
||||
TestDatabase.Close()
|
||||
TestDatabase = nil
|
||||
t.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createTestDatabase creates the test database
|
||||
// In the test container, POSTGRES_USER is created as a superuser
|
||||
func createTestDatabase(host, port, dbname, user, password string) {
|
||||
// Connect to the postgres database to create new database
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", host, port, user, password)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
log.Println("Warning: Could not connect to create test database:", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Check if database exists
|
||||
var dbExists int
|
||||
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&dbExists)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.Println("Warning: Could not check if database exists:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if dbExists == 0 {
|
||||
// Create database
|
||||
_, err = db.Exec("CREATE DATABASE " + dbname)
|
||||
if err != nil {
|
||||
log.Println("Warning: Could not create database:", err)
|
||||
return
|
||||
}
|
||||
log.Println("Created test database:", dbname)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTearDownDB closes the test database connection
|
||||
// Note: We don't actually close the pool between tests to avoid
|
||||
// "closed pool" errors when tests run sequentially
|
||||
func TestTearDownDB(t *testing.T) {
|
||||
// CloseDb() // Disabled to prevent pool closure between sequential tests
|
||||
// Note: We also don't nil TestDatabase to allow reuse across tests
|
||||
// if TestDatabase != nil {
|
||||
// TestDatabase.Close()
|
||||
// TestDatabase = nil
|
||||
// }
|
||||
}
|
||||
|
||||
// TestClearDatabase clears all data from the test database
|
||||
// Useful for running tests with a clean slate
|
||||
func TestClearDatabase(t *testing.T) {
|
||||
if TestDatabase == nil || TestDatabase.Pool == nil {
|
||||
t.Skip("Database not initialized")
|
||||
}
|
||||
|
||||
// Clear all tables in reverse order to respect foreign keys
|
||||
// Note: This assumes the tables exist and have the expected structure
|
||||
// After migration 000005, game table was renamed to soundtrack
|
||||
tables := []string{
|
||||
"song_list",
|
||||
"song",
|
||||
"soundtrack",
|
||||
"vgmq",
|
||||
"sessions",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, table := range tables {
|
||||
_, err := TestDatabase.Pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE")
|
||||
if err != nil {
|
||||
t.Logf("Failed to truncate table %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset sequences (renamed from game_id_seq to soundtrack_id_seq in migration 000005)
|
||||
var seqErr error
|
||||
_, seqErr = TestDatabase.Pool.Exec(ctx, "SELECT setval('soundtrack_id_seq', 1, false)")
|
||||
if seqErr != nil {
|
||||
t.Logf("Failed to reset soundtrack_id_seq: %v", seqErr)
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,32 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"music-server/internal/backend"
|
||||
)
|
||||
|
||||
type CharacterHandler struct {
|
||||
characters *backend.CharacterService
|
||||
}
|
||||
|
||||
func NewCharacterHandler() *CharacterHandler {
|
||||
return &CharacterHandler{}
|
||||
func NewCharacterHandler(characters *backend.CharacterService) *CharacterHandler {
|
||||
return &CharacterHandler{characters: characters}
|
||||
}
|
||||
|
||||
// GetCharacterList godoc
|
||||
// @Summary Get list of characters
|
||||
// @Description Returns a list of all available characters
|
||||
// @Tags characters
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Router /characters [get]
|
||||
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
||||
characters := backend.GetCharacterList()
|
||||
characters, err := c.characters.List()
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, characters)
|
||||
}
|
||||
|
||||
@@ -32,18 +35,17 @@ func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
||||
// @Summary Get character image
|
||||
// @Description Returns the image for a specific character
|
||||
// @Tags characters
|
||||
// @Accept json
|
||||
// @Produce image/png
|
||||
// @Param name query string true "Character name"
|
||||
// @Produce image/jpeg
|
||||
// @Param name query string true "Character file name"
|
||||
// @Success 200 {file} file
|
||||
// @Failure 400 {string} string "invalid name"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Router /character [get]
|
||||
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
||||
character := ctx.QueryParam("name")
|
||||
characterPath := backend.GetCharacter(character)
|
||||
file, err := os.Open(characterPath)
|
||||
f, err := c.characters.Open(ctx.QueryParam("name"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
return songError(err)
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "image/png", file)
|
||||
return serveFile(ctx, f, "")
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestGetCharacterList verifies the characters endpoint returns list of characters
|
||||
func TestGetCharacterList(t *testing.T) {
|
||||
e := StartTestServer(t)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/characters")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var characters []string
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &characters)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, characters)
|
||||
// Should contain our test characters
|
||||
assert.Contains(t, characters, "char1.jpg")
|
||||
assert.Contains(t, characters, "char2.png")
|
||||
}
|
||||
|
||||
// TestGetCharacter verifies the character endpoint returns a file
|
||||
func TestGetCharacter(t *testing.T) {
|
||||
e := StartTestServer(t)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/character?name=char1.jpg")
|
||||
// For now, just check that we get a response (not necessarily 200)
|
||||
// The actual file serving might have issues with absolute paths
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Logf("Got status %d instead of 200", resp.Code)
|
||||
// Don't fail the test for now - we can investigate later
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCharacterNotFound verifies handling of non-existent character
|
||||
func TestGetCharacterNotFound(t *testing.T) {
|
||||
e := StartTestServer(t)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/character?name=nonexistent.jpg")
|
||||
// Should return 404 or similar error
|
||||
assert.NotEqual(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v5"
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type DownloadHandler struct {
|
||||
}
|
||||
|
||||
func NewDownloadHandler() *DownloadHandler {
|
||||
return &DownloadHandler{}
|
||||
}
|
||||
|
||||
// CheckLatest godoc
|
||||
// @Summary Check for latest version
|
||||
// @Description Checks for the latest version of the application
|
||||
// @Tags download
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string
|
||||
// @Router /download [get]
|
||||
func (d *DownloadHandler) checkLatest(ctx *echo.Context) error {
|
||||
logging.GetLogger().Info("Checking latest version")
|
||||
latest := backend.CheckLatest()
|
||||
return ctx.JSON(http.StatusOK, latest)
|
||||
}
|
||||
|
||||
// ListAssetsOfLatest godoc
|
||||
// @Summary List assets of latest version
|
||||
// @Description Lists all assets available for the latest version
|
||||
// @Tags download
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Router /download/list [get]
|
||||
func (d *DownloadHandler) listAssetsOfLatest(ctx *echo.Context) error {
|
||||
logging.GetLogger().Info("Listing assets")
|
||||
assets := backend.ListAssetsOfLatest()
|
||||
return ctx.JSON(http.StatusOK, assets)
|
||||
}
|
||||
|
||||
// DownloadLatestWindows godoc
|
||||
// @Summary Download latest Windows version
|
||||
// @Description Redirects to download the latest Windows version
|
||||
// @Tags download
|
||||
// @Produce octet-stream
|
||||
// @Success 302 {string} string
|
||||
// @Router /download/windows [get]
|
||||
func (d *DownloadHandler) downloadLatestWindows(ctx *echo.Context) error {
|
||||
logging.GetLogger().Info("Downloading latest windows")
|
||||
asset := backend.DownloadLatestWindows()
|
||||
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
|
||||
return ctx.Redirect(http.StatusFound, asset)
|
||||
}
|
||||
|
||||
// DownloadLatestLinux godoc
|
||||
// @Summary Download latest Linux version
|
||||
// @Description Redirects to download the latest Linux version
|
||||
// @Tags download
|
||||
// @Produce octet-stream
|
||||
// @Success 302 {string} string
|
||||
// @Router /download/linux [get]
|
||||
func (d *DownloadHandler) downloadLatestLinux(ctx *echo.Context) error {
|
||||
logging.GetLogger().Info("Downloading latest linux")
|
||||
asset := backend.DownloadLatestLinux()
|
||||
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
|
||||
return ctx.Redirect(http.StatusFound, asset)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type DownloadHandler struct {
|
||||
releases *backend.ReleaseService
|
||||
}
|
||||
|
||||
func NewDownloadHandler(releases *backend.ReleaseService) *DownloadHandler {
|
||||
return &DownloadHandler{releases: releases}
|
||||
}
|
||||
|
||||
// CheckLatest godoc
|
||||
// @Summary Check for latest version
|
||||
// @Description Returns the latest version of the client application
|
||||
// @Tags download
|
||||
// @Produce json
|
||||
// @Success 200 {string} string
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download [get]
|
||||
func (d *DownloadHandler) CheckLatest(ctx *echo.Context) error {
|
||||
latest, err := d.releases.LatestVersion(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, latest)
|
||||
}
|
||||
|
||||
// ListAssetsOfLatest godoc
|
||||
// @Summary List assets of latest version
|
||||
// @Description Lists all assets available for the latest version
|
||||
// @Tags download
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/list [get]
|
||||
func (d *DownloadHandler) ListAssetsOfLatest(ctx *echo.Context) error {
|
||||
assets, err := d.releases.AssetNames(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, assets)
|
||||
}
|
||||
|
||||
// DownloadLatestWindows godoc
|
||||
// @Summary Download latest Windows version
|
||||
// @Description Redirects to download the latest Windows version
|
||||
// @Tags download
|
||||
// @Success 302 {string} string
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/windows [get]
|
||||
func (d *DownloadHandler) DownloadLatestWindows(ctx *echo.Context) error {
|
||||
return d.redirectToAsset(ctx, ".exe")
|
||||
}
|
||||
|
||||
// DownloadLatestLinux godoc
|
||||
// @Summary Download latest Linux version
|
||||
// @Description Redirects to download the latest Linux version
|
||||
// @Tags download
|
||||
// @Success 302 {string} string
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/linux [get]
|
||||
func (d *DownloadHandler) DownloadLatestLinux(ctx *echo.Context) error {
|
||||
return d.redirectToAsset(ctx, ".x86_64")
|
||||
}
|
||||
|
||||
func (d *DownloadHandler) redirectToAsset(ctx *echo.Context, suffix string) error {
|
||||
url, err := d.releases.AssetURL(ctx.Request().Context(), suffix)
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.Redirect(http.StatusFound, url)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// serveFile sends a file with support for Range requests and caching headers.
|
||||
// contentType may be empty to detect it from the file extension.
|
||||
func serveFile(c *echo.Context, f *os.File, contentType string) error {
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
}
|
||||
if contentType != "" {
|
||||
c.Response().Header().Set(echo.HeaderContentType, contentType)
|
||||
}
|
||||
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveSong streams a song. The write deadline is lifted because songs can take long to send to slow clients.
|
||||
func serveSong(c *echo.Context, path string, err error) error {
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
}
|
||||
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
|
||||
return serveFile(c, f, "audio/mpeg")
|
||||
}
|
||||
|
||||
// songError maps backend errors to HTTP errors.
|
||||
func songError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, backend.ErrInvalidName):
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, backend.ErrUpstream):
|
||||
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
|
||||
default:
|
||||
logging.GetLogger().Error("Request failed", zap.Error(err))
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"music-server/internal/db"
|
||||
)
|
||||
|
||||
type HealthHandler struct {
|
||||
db *db.Database
|
||||
}
|
||||
|
||||
func NewHealthHandler(database *db.Database) *HealthHandler {
|
||||
return &HealthHandler{db: database}
|
||||
}
|
||||
|
||||
// HealthCheck godoc
|
||||
//
|
||||
// @Summary Check server health
|
||||
// @Description Returns the health status of the server
|
||||
// @Tags health
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "OK"
|
||||
// @Router /health [get]
|
||||
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, h.db.Health())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// HealthChecker reports the health of a dependency.
|
||||
type HealthChecker interface {
|
||||
Health(ctx context.Context) map[string]string
|
||||
}
|
||||
|
||||
type HealthHandler struct {
|
||||
db HealthChecker
|
||||
}
|
||||
|
||||
func NewHealthHandler(db HealthChecker) *HealthHandler {
|
||||
return &HealthHandler{db: db}
|
||||
}
|
||||
|
||||
// HealthCheck godoc
|
||||
//
|
||||
// @Summary Check server health
|
||||
// @Description Returns the health status of the server
|
||||
// @Tags health
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /health [get]
|
||||
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, h.db.Health(ctx.Request().Context()))
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestHealthCheck verifies the health endpoint returns database status
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
e := StartTestServer(t)
|
||||
// No explicit teardown - handled by StartTestServer's sync.Once
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/health")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var healthData map[string]string
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, healthData)
|
||||
assert.Equal(t, "up", healthData["status"])
|
||||
}
|
||||
@@ -2,98 +2,30 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RateLimiter implements a simple IP-based rate limiter
|
||||
type RateLimiter struct {
|
||||
visits map[string]*Visit
|
||||
mu sync.Mutex
|
||||
limit int // requests per window
|
||||
window time.Duration // time window
|
||||
}
|
||||
|
||||
type Visit struct {
|
||||
count int
|
||||
firstSeen time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
visits: make(map[string]*Visit),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
// cleanup periodically removes old entries
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
for {
|
||||
time.Sleep(rl.window)
|
||||
rl.mu.Lock()
|
||||
now := time.Now()
|
||||
for ip, visit := range rl.visits {
|
||||
if now.Sub(visit.firstSeen) > rl.window {
|
||||
delete(rl.visits, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitMiddleware returns an Echo middleware for rate limiting
|
||||
func RateLimitMiddleware(rl *RateLimiter) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
ip := c.RealIP()
|
||||
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
visit, exists := rl.visits[ip]
|
||||
now := time.Now()
|
||||
|
||||
if !exists {
|
||||
// First visit from this IP
|
||||
rl.visits[ip] = &Visit{
|
||||
count: 1,
|
||||
firstSeen: now,
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if now.Sub(visit.firstSeen) > rl.window {
|
||||
// Reset for new window
|
||||
visit.count = 1
|
||||
visit.firstSeen = now
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if visit.count >= rl.limit {
|
||||
// RateLimit allows each client IP limit requests per window, with bursts up to limit.
|
||||
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc {
|
||||
config := echoMiddleware.DefaultRateLimiterConfig
|
||||
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
|
||||
Rate: float64(limit) / window.Seconds(),
|
||||
Burst: limit,
|
||||
ExpiresIn: window,
|
||||
})
|
||||
config.DenyHandler = func(c *echo.Context, identifier string, err error) error {
|
||||
logging.GetLogger().Warn("Rate limit exceeded",
|
||||
zap.String("ip", ip),
|
||||
zap.String("ip", identifier),
|
||||
zap.String("path", c.Request().URL.Path))
|
||||
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||
"error": "Rate limit exceeded. Please try again later.",
|
||||
})
|
||||
}
|
||||
|
||||
// Increment count
|
||||
visit.count++
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
return echoMiddleware.RateLimiterWithConfig(config)
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// SyncCheckMiddleware blocks requests when syncing is in progress.
|
||||
// It returns HTTP 423 Locked with a standard message.
|
||||
// This middleware should be applied to handlers that cannot execute during sync.
|
||||
func SyncCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress.
|
||||
func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
if backend.Syncing {
|
||||
if isSyncing() {
|
||||
logging.GetLogger().Info("Syncing is in progress - request blocked")
|
||||
return c.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,62 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TokenAuthMiddleware returns an Echo middleware that validates session tokens
|
||||
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
|
||||
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
|
||||
func BearerToken(c *echo.Context) (string, bool) {
|
||||
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||
return token, ok && token != "" && !strings.Contains(token, " ")
|
||||
}
|
||||
|
||||
// TokenAuth validates session tokens. When disabled is true all requests are let through.
|
||||
func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
// Session auth disable bypass - allow all requests through without token validation
|
||||
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
|
||||
if disableSessionAuth {
|
||||
logging.GetLogger().Debug("Session auth disabled - bypassing token authentication",
|
||||
zap.String("path", c.Request().URL.Path),
|
||||
zap.String("ip", c.RealIP()))
|
||||
if disabled {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
if c.Request().Header.Get("Authorization") == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
}
|
||||
|
||||
// Bearer token format
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
token, ok := BearerToken(c)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
queries := repository.New(pool)
|
||||
session, err := queries.GetSession(c.Request().Context(), token)
|
||||
ctx := c.Request().Context()
|
||||
session, err := q.GetSession(ctx, token)
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Invalid token attempt",
|
||||
zap.String("token", token),
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("error", err.Error()),
|
||||
)
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if time.Now().After(session.ExpiresAt.Time) {
|
||||
// Clean up expired session in background
|
||||
go func() {
|
||||
queries.DeleteSession(c.Request().Context(), token)
|
||||
}()
|
||||
// Clean up in the background with a context that outlives the request.
|
||||
go func(ctx context.Context) {
|
||||
if err := q.DeleteSession(ctx, token); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
|
||||
}
|
||||
}(context.WithoutCancel(ctx))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"})
|
||||
}
|
||||
|
||||
// Add session to request context for potential use by handlers
|
||||
c.Set("session", session)
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TokenIPCheckMiddleware checks if the request IP matches the session IP
|
||||
func TokenIPCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
sessionVal := c.Get("session")
|
||||
if sessionVal == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "No session in context"})
|
||||
}
|
||||
session := sessionVal.(repository.Session)
|
||||
if session.IpAddress != c.RealIP() {
|
||||
logging.GetLogger().Warn("Token IP mismatch",
|
||||
zap.String("token_ip", session.IpAddress),
|
||||
zap.String("request_ip", c.RealIP()),
|
||||
)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,43 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type MusicHandler struct {
|
||||
music *backend.MusicService
|
||||
}
|
||||
|
||||
func NewMusicHandler() *MusicHandler {
|
||||
return &MusicHandler{}
|
||||
func NewMusicHandler(music *backend.MusicService) *MusicHandler {
|
||||
return &MusicHandler{music: music}
|
||||
}
|
||||
|
||||
// GetSong godoc
|
||||
// @Summary Get a specific song
|
||||
// @Description Returns a specific song by name
|
||||
// @Description Returns the song at the given position in the queue
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Produce audio/mpeg
|
||||
// @Param song query string true "Song name"
|
||||
// @Param song query int true "Song number in the queue"
|
||||
// @Success 200 {file} file
|
||||
// @Failure 400 {string} string "song can't be empty"
|
||||
// @Success 206 {file} file
|
||||
// @Failure 400 {string} string "song must be a number"
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music [get]
|
||||
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
||||
song := ctx.QueryParam("song")
|
||||
if song == "" {
|
||||
return ctx.String(http.StatusBadRequest, "song can't be empty")
|
||||
}
|
||||
songPath := backend.GetSong(song)
|
||||
file, err := os.Open(songPath)
|
||||
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
return ctx.String(http.StatusBadRequest, "song must be a number")
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||
path, err := m.music.Song(song)
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetSoundCheckSong godoc
|
||||
@@ -54,25 +50,21 @@ func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/soundTest [get]
|
||||
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
||||
songPath := backend.GetSoundCheckSong()
|
||||
file, err := os.Open(songPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||
path, err := m.music.SoundCheckSong()
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// ResetMusic godoc
|
||||
// @Summary Reset music state
|
||||
// @Description Resets the music state
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Success 204
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/reset [get]
|
||||
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
||||
backend.Reset()
|
||||
if err := m.music.Reset(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -86,18 +78,13 @@ func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand [get]
|
||||
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
||||
songPath := backend.GetRandomSong()
|
||||
file, err := os.Open(songPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||
path, err := m.music.RandomSong(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetRandomSongLowChance godoc
|
||||
// @Summary Get random song with low chance
|
||||
// @Description Returns a random song with low chance selection
|
||||
// @Description Returns a random song, favouring soundtracks that have been played less
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
@@ -105,18 +92,13 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand/low [get]
|
||||
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
||||
songPath := backend.GetRandomSongLowChance()
|
||||
file, err := os.Open(songPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||
path, err := m.music.RandomSongLowChance(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetRandomSongClassic godoc
|
||||
// @Summary Get random classic song
|
||||
// @Description Returns a random song from the classic selection
|
||||
// @Description Returns a random song where every song has the same chance
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
@@ -124,39 +106,30 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand/classic [get]
|
||||
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
|
||||
songPath := backend.GetRandomSongClassic()
|
||||
file, err := os.Open(songPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||
path, err := m.music.RandomSongClassic(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetSongInfo godoc
|
||||
// @Summary Get current song info
|
||||
// @Description Returns information about the current song
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Success 200 {object} backend.SongInfo
|
||||
// @Router /music/info [get]
|
||||
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
|
||||
song := backend.GetSongInfo()
|
||||
return ctx.JSON(http.StatusOK, song)
|
||||
return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context()))
|
||||
}
|
||||
|
||||
// GetPlayedSongs godoc
|
||||
// @Summary Get played songs list
|
||||
// @Description Returns a list of played songs
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Success 200 {array} backend.SongInfo
|
||||
// @Router /music/list [get]
|
||||
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
||||
songList := backend.GetPlayedSongs()
|
||||
return ctx.JSON(http.StatusOK, songList)
|
||||
return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context()))
|
||||
}
|
||||
|
||||
// GetNextSong godoc
|
||||
@@ -169,13 +142,8 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/next [get]
|
||||
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
||||
songPath := backend.GetNextSong()
|
||||
file, err := os.Open(songPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||
path, err := m.music.NextSong()
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetPreviousSong godoc
|
||||
@@ -188,51 +156,48 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/previous [get]
|
||||
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
||||
songPath := backend.GetPreviousSong()
|
||||
file, err := os.Open(songPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||
path, err := m.music.PreviousSong()
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetAllSoundtracks godoc
|
||||
// @Summary Get all soundtracks
|
||||
// @Description Returns a list of all soundtracks in order
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Success 200 {array} string
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/all/order [get]
|
||||
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
||||
soundtrackList := backend.GetAllSoundtracks()
|
||||
return ctx.JSON(http.StatusOK, soundtrackList)
|
||||
names, err := m.music.SoundtrackNames(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, names)
|
||||
}
|
||||
|
||||
// GetAllSoundtracksRandom godoc
|
||||
// @Summary Get all soundtracks random
|
||||
// @Description Returns a list of all soundtracks in random order
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Success 200 {array} string
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/all/random [get]
|
||||
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
||||
soundtrackList := backend.GetAllSoundtracksRandom()
|
||||
return ctx.JSON(http.StatusOK, soundtrackList)
|
||||
names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, names)
|
||||
}
|
||||
|
||||
// PutPlayed godoc
|
||||
// @Summary Mark song as played
|
||||
// @Description Marks a song as played by its ID
|
||||
// @Description Marks the song at the given queue position as played
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param song query int true "Song ID"
|
||||
// @Success 204
|
||||
// @Param song query int true "Song number in the queue"
|
||||
// @Success 200
|
||||
// @Failure 400 {string} string "Bad Request"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/played [put]
|
||||
@@ -242,7 +207,9 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
|
||||
backend.SetPlayed(song)
|
||||
if err := m.music.SetPlayed(ctx.Request().Context(), song); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -250,24 +217,24 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
||||
// @Summary Add latest to queue
|
||||
// @Description Adds the latest song to the queue
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Success 204
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/addQue [get]
|
||||
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
||||
backend.AddLatestToQue()
|
||||
m.music.AddLatestToQueue()
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// AddLatestPlayed godoc
|
||||
// @Summary Add latest to played
|
||||
// @Description Adds the latest song to the played list
|
||||
// @Description Marks the current song as played
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
// @Success 204
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/addPlayed [get]
|
||||
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
|
||||
backend.AddLatestPlayed()
|
||||
if err := m.music.AddLatestPlayed(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
+119
-235
@@ -1,22 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "music-server/cmd/docs" // registers the OpenAPI spec
|
||||
"music-server/cmd/web"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/labstack/echo/v5"
|
||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
||||
"github.com/swaggo/swag"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// @Title MusicServer API
|
||||
// @title MusicServer API
|
||||
// @version 1.0
|
||||
// @description API for the MusicServer application
|
||||
// @termsOfService http://sanplex.xyz/terms/
|
||||
@@ -27,19 +28,45 @@ import (
|
||||
// @license.name MIT
|
||||
// @license.url http://opensource.org/licenses/MIT
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
|
||||
// handlers holds all HTTP handlers.
|
||||
type handlers struct {
|
||||
health *HealthHandler
|
||||
version *VersionHandler
|
||||
character *CharacterHandler
|
||||
download *DownloadHandler
|
||||
sync *SyncHandler
|
||||
music *MusicHandler
|
||||
statistics *StatisticsHandler
|
||||
token *TokenHandler
|
||||
}
|
||||
|
||||
func (s *Server) handlers() handlers {
|
||||
return handlers{
|
||||
health: NewHealthHandler(s.db),
|
||||
version: NewVersionHandler(),
|
||||
character: NewCharacterHandler(s.characters),
|
||||
download: NewDownloadHandler(s.releases),
|
||||
sync: NewSyncHandler(s.sync),
|
||||
music: NewMusicHandler(s.music),
|
||||
statistics: NewStatisticsHandler(s.stats),
|
||||
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes() http.Handler {
|
||||
e := echo.New()
|
||||
if len(s.cfg.TrustedProxies) > 0 {
|
||||
var trust []echo.TrustOption
|
||||
for _, ipNet := range s.cfg.TrustedProxies {
|
||||
trust = append(trust, echo.TrustIPRange(ipNet))
|
||||
}
|
||||
e.IPExtractor = echo.ExtractIPFromXFFHeader(trust...)
|
||||
}
|
||||
|
||||
// Serve OpenAPI spec at /openapi
|
||||
e.GET("/openapi", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
http.ServeFile(w, r, "cmd/docs/swagger.json")
|
||||
})))
|
||||
e.Use(logging.RequestLogger())
|
||||
e.Use(echoMiddleware.Recover())
|
||||
|
||||
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||
AllowOrigins: []string{"https://*", "http://*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
@@ -48,240 +75,97 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
fileServer := http.FileServer(http.FS(web.Assets))
|
||||
e.GET("/assets/*", echo.WrapHandler(fileServer))
|
||||
|
||||
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
|
||||
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
|
||||
|
||||
e.Static("/", "/frontend")
|
||||
|
||||
// Swagger UI
|
||||
e.GET("/openapi", serveOpenAPI)
|
||||
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
||||
|
||||
// ============================================
|
||||
// Legacy Endpoints (Deprecated - use /api/v1/ instead)
|
||||
// ============================================
|
||||
deprecatedMiddleware := middleware.DeprecationMiddleware
|
||||
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
|
||||
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
|
||||
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
|
||||
e.Static("/", s.cfg.FrontendPath)
|
||||
|
||||
health := NewHealthHandler(s.db)
|
||||
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
|
||||
h := s.handlers()
|
||||
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
|
||||
|
||||
version := NewVersionHandler()
|
||||
e.GET("/version", deprecatedMiddleware(version.GetLatestVersion))
|
||||
e.GET("/version/history", deprecatedMiddleware(version.GetVersionHistory))
|
||||
// Legacy endpoints (deprecated - use /api/v1/ instead)
|
||||
legacy := e.Group("", middleware.DeprecationMiddleware)
|
||||
registerCommon(legacy, h, syncLock)
|
||||
legacy.GET("/health", h.health.HealthCheck)
|
||||
legacy.GET("/characters", h.character.GetCharacterList)
|
||||
|
||||
character := NewCharacterHandler()
|
||||
e.GET("/character", deprecatedMiddleware(character.GetCharacter))
|
||||
e.GET("/characters", deprecatedMiddleware(character.GetCharacterList))
|
||||
|
||||
download := NewDownloadHandler()
|
||||
e.GET("/download", deprecatedMiddleware(download.checkLatest))
|
||||
e.GET("/download/list", deprecatedMiddleware(download.listAssetsOfLatest))
|
||||
e.GET("/download/windows", deprecatedMiddleware(download.downloadLatestWindows))
|
||||
e.GET("/download/linux", deprecatedMiddleware(download.downloadLatestLinux))
|
||||
|
||||
sync := NewSyncHandler()
|
||||
syncGroup := e.Group("/sync")
|
||||
syncGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
|
||||
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
|
||||
syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
|
||||
syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
|
||||
syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
|
||||
syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
|
||||
syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB)))
|
||||
|
||||
music := NewMusicHandler()
|
||||
musicGroup := e.Group("/music")
|
||||
musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong)))
|
||||
musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong)))
|
||||
musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic)))
|
||||
musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong)))
|
||||
musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance)))
|
||||
musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic)))
|
||||
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
|
||||
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
|
||||
musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong)))
|
||||
musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong)))
|
||||
musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
|
||||
musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks)))
|
||||
musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
|
||||
musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed)))
|
||||
musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue)))
|
||||
musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed)))
|
||||
|
||||
// ============================================
|
||||
// API v1 Routes with Token Authentication
|
||||
// ============================================
|
||||
|
||||
// Create /api/v1 group
|
||||
// API v1
|
||||
apiV1 := e.Group("/api/v1")
|
||||
|
||||
// Create rate limiter for token creation: 10 requests per minute per IP
|
||||
tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute)
|
||||
tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter)
|
||||
|
||||
// Token endpoints (public - no token required)
|
||||
tokenGroup := apiV1.Group("/token")
|
||||
tokenGroup.POST("", func(c *echo.Context) error {
|
||||
return s.tokenHandler.CreateTokenHandler(c)
|
||||
}, tokenRateLimitMiddleware)
|
||||
tokenGroup.DELETE("", func(c *echo.Context) error {
|
||||
return s.tokenHandler.DeleteTokenHandler(c)
|
||||
})
|
||||
tokenGroup.POST("/cleanup", func(c *echo.Context) error {
|
||||
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
|
||||
})
|
||||
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
|
||||
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
|
||||
|
||||
// Protected endpoints - require valid token
|
||||
// Create token auth middleware with pool access
|
||||
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
|
||||
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
|
||||
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
|
||||
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
|
||||
registerCommon(protected, h, syncLock)
|
||||
protected.GET("/character/list", h.character.GetCharacterList)
|
||||
|
||||
// Statistics endpoints (protected)
|
||||
statisticsHandler := s.statisticsHandler
|
||||
statsGroup := protectedV1.Group("/statistics")
|
||||
statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetMostPlayedSoundtracks(c)
|
||||
})
|
||||
statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetLeastPlayedSoundtracks(c)
|
||||
})
|
||||
statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetNeverPlayedSoundtracks(c)
|
||||
})
|
||||
statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetLastPlayedSoundtracks(c)
|
||||
})
|
||||
statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetOldestPlayedSoundtracks(c)
|
||||
})
|
||||
statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetMostPlayedSongs(c)
|
||||
})
|
||||
statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetLeastPlayedSongs(c)
|
||||
})
|
||||
statsGroup.GET("/summary", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetStatisticsSummary(c)
|
||||
})
|
||||
stats := protected.Group("/statistics")
|
||||
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
|
||||
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
|
||||
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
|
||||
stats.GET("/summary", h.statistics.GetStatisticsSummary)
|
||||
|
||||
// Version endpoints (protected)
|
||||
versionGroup := protectedV1.Group("/version")
|
||||
versionGroup.GET("", func(c *echo.Context) error {
|
||||
return version.GetLatestVersion(c)
|
||||
})
|
||||
versionGroup.GET("/history", func(c *echo.Context) error {
|
||||
return version.GetVersionHistory(c)
|
||||
})
|
||||
|
||||
// Character endpoints (protected)
|
||||
characterGroup := protectedV1.Group("/character")
|
||||
characterGroup.GET("", func(c *echo.Context) error {
|
||||
return character.GetCharacter(c)
|
||||
})
|
||||
characterGroup.GET("/list", func(c *echo.Context) error {
|
||||
return character.GetCharacterList(c)
|
||||
})
|
||||
|
||||
// Download endpoints (protected)
|
||||
downloadGroup := protectedV1.Group("/download")
|
||||
downloadGroup.GET("", func(c *echo.Context) error {
|
||||
return download.checkLatest(c)
|
||||
})
|
||||
downloadGroup.GET("/list", func(c *echo.Context) error {
|
||||
return download.listAssetsOfLatest(c)
|
||||
})
|
||||
downloadGroup.GET("/windows", func(c *echo.Context) error {
|
||||
return download.downloadLatestWindows(c)
|
||||
})
|
||||
downloadGroup.GET("/linux", func(c *echo.Context) error {
|
||||
return download.downloadLatestLinux(c)
|
||||
})
|
||||
|
||||
// Sync endpoints (protected)
|
||||
protectedSyncGroup := protectedV1.Group("/sync")
|
||||
protectedSyncGroup.GET("", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/progress", func(c *echo.Context) error {
|
||||
return sync.SyncProgress(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/new", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/full", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewFull(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/new/full", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewFull(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/quick", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/reset", func(c *echo.Context) error {
|
||||
return sync.ResetDB(c)
|
||||
})
|
||||
|
||||
// Music endpoints (protected)
|
||||
protectedMusicGroup := protectedV1.Group("/music")
|
||||
protectedMusicGroup.GET("", func(c *echo.Context) error {
|
||||
return music.GetSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error {
|
||||
return music.GetSoundCheckSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/reset", func(c *echo.Context) error {
|
||||
return music.ResetMusic(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/rand", func(c *echo.Context) error {
|
||||
return music.GetRandomSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error {
|
||||
return music.GetRandomSongLowChance(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error {
|
||||
return music.GetRandomSongClassic(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/info", func(c *echo.Context) error {
|
||||
return music.GetSongInfo(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/list", func(c *echo.Context) error {
|
||||
return music.GetPlayedSongs(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/next", func(c *echo.Context) error {
|
||||
return music.GetNextSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/previous", func(c *echo.Context) error {
|
||||
return music.GetPreviousSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/all", func(c *echo.Context) error {
|
||||
return music.GetAllSoundtracksRandom(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/all/order", func(c *echo.Context) error {
|
||||
return music.GetAllSoundtracks(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/all/random", func(c *echo.Context) error {
|
||||
return music.GetAllSoundtracksRandom(c)
|
||||
})
|
||||
protectedMusicGroup.PUT("/played", func(c *echo.Context) error {
|
||||
return music.PutPlayed(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/addQue", func(c *echo.Context) error {
|
||||
return music.AddLatestToQue(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error {
|
||||
return music.AddLatestPlayed(c)
|
||||
})
|
||||
|
||||
routes := e.Router().Routes()
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
return routes[i].Path < routes[j].Path
|
||||
})
|
||||
for _, r := range routes {
|
||||
if (r.Method == "GET" || r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE") && !strings.Contains(r.Name, "github") {
|
||||
for _, r := range e.Router().Routes() {
|
||||
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// registerCommon registers the routes shared by the legacy and the v1 API.
|
||||
// syncLock is applied to routes that must not run during a sync.
|
||||
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) {
|
||||
g.GET("/version", h.version.GetLatestVersion)
|
||||
g.GET("/version/history", h.version.GetVersionHistory)
|
||||
|
||||
g.GET("/character", h.character.GetCharacter)
|
||||
|
||||
g.GET("/download", h.download.CheckLatest)
|
||||
g.GET("/download/list", h.download.ListAssetsOfLatest)
|
||||
g.GET("/download/windows", h.download.DownloadLatestWindows)
|
||||
g.GET("/download/linux", h.download.DownloadLatestLinux)
|
||||
|
||||
g.GET("/sync/progress", h.sync.SyncProgress)
|
||||
for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} {
|
||||
g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock)
|
||||
}
|
||||
for _, path := range []string{"/sync/full", "/sync/new/full"} {
|
||||
g.POST(path, h.sync.SyncSoundtracksNewFull, syncLock)
|
||||
}
|
||||
g.POST("/sync/reset", h.sync.ResetDB, syncLock)
|
||||
|
||||
music := g.Group("/music", syncLock)
|
||||
music.GET("", h.music.GetSong)
|
||||
music.GET("/soundTest", h.music.GetSoundCheckSong)
|
||||
music.GET("/reset", h.music.ResetMusic)
|
||||
music.GET("/rand", h.music.GetRandomSong)
|
||||
music.GET("/rand/low", h.music.GetRandomSongLowChance)
|
||||
music.GET("/rand/classic", h.music.GetRandomSongClassic)
|
||||
music.GET("/next", h.music.GetNextSong)
|
||||
music.GET("/previous", h.music.GetPreviousSong)
|
||||
music.GET("/all", h.music.GetAllSoundtracksRandom)
|
||||
music.GET("/all/order", h.music.GetAllSoundtracks)
|
||||
music.GET("/all/random", h.music.GetAllSoundtracksRandom)
|
||||
music.PUT("/played", h.music.PutPlayed)
|
||||
music.GET("/addQue", h.music.AddLatestToQue)
|
||||
music.GET("/addPlayed", h.music.AddLatestPlayed)
|
||||
// Info and list are read-only and stay available during a sync.
|
||||
g.GET("/music/info", h.music.GetSongInfo)
|
||||
g.GET("/music/list", h.music.GetPlayedSongs)
|
||||
}
|
||||
|
||||
// serveOpenAPI serves the embedded OpenAPI spec.
|
||||
func serveOpenAPI(c *echo.Context) error {
|
||||
doc, err := swag.ReadDoc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Blob(http.StatusOK, echo.MIMEApplicationJSON, []byte(doc))
|
||||
}
|
||||
+101
-93
@@ -1,125 +1,133 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/config"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Database is the database connection used by the server.
|
||||
type Database interface {
|
||||
HealthChecker
|
||||
Close()
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
db *db.Database
|
||||
tokenHandler *TokenHandler
|
||||
statisticsHandler *StatisticsHandler
|
||||
cfg config.Config
|
||||
db Database
|
||||
q repository.Querier
|
||||
music *backend.MusicService
|
||||
sync *backend.SyncService
|
||||
characters *backend.CharacterService
|
||||
releases *backend.ReleaseService
|
||||
stats *backend.StatisticsService
|
||||
search *backend.SearchCache
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
var (
|
||||
musicPath = os.Getenv("MUSIC_PATH")
|
||||
charactersPath = os.Getenv("CHARACTERS_PATH")
|
||||
logLevel = os.Getenv("LOG_LEVEL")
|
||||
logJSON = os.Getenv("LOG_JSON") == "true"
|
||||
)
|
||||
|
||||
// NewServerInstance creates a new Server instance with all dependencies initialized.
|
||||
// Use this for dependency injection and proper lifecycle management.
|
||||
func NewServerInstance() *Server {
|
||||
// Re-read environment variables at server creation time (not at package import time)
|
||||
// This allows tests to override environment variables
|
||||
host := os.Getenv("DB_HOST")
|
||||
dbPort := os.Getenv("DB_PORT")
|
||||
dbName := os.Getenv("DB_NAME")
|
||||
username := os.Getenv("DB_USERNAME")
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
|
||||
// Initialize logger
|
||||
currentLogLevel := logLevel
|
||||
if currentLogLevel == "" {
|
||||
currentLogLevel = "info"
|
||||
}
|
||||
logging.Init(currentLogLevel, logJSON)
|
||||
|
||||
logger := logging.GetLogger()
|
||||
|
||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||
|
||||
// Validate required environment variables
|
||||
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
||||
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
||||
}
|
||||
|
||||
// Create database instance
|
||||
database, err := db.NewDatabase(host, dbPort, username, password, dbName)
|
||||
// New creates a Server with all services wired up.
|
||||
func New(cfg config.Config, q repository.Querier, database Database) (*Server, error) {
|
||||
characters, err := backend.NewCharacterService(cfg.CharactersPath)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to initialize database", zap.String("error", err.Error()))
|
||||
return nil, fmt.Errorf("characters path: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations using the new method
|
||||
if err := database.RunMigrations(); err != nil {
|
||||
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
// Initialize backend package with database pool
|
||||
backend.InitBackend(database.Pool)
|
||||
|
||||
// Initialize token handler with database pool
|
||||
tokenHandler := NewTokenHandler(database.Pool)
|
||||
|
||||
// Initialize statistics handler
|
||||
statisticsHandler := NewStatisticsHandler()
|
||||
|
||||
// Create the server instance
|
||||
appServer := &Server{
|
||||
port: port,
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
db: database,
|
||||
tokenHandler: tokenHandler,
|
||||
statisticsHandler: statisticsHandler,
|
||||
q: q,
|
||||
music: backend.NewMusicService(q, cfg.SoundCheckPath),
|
||||
characters: characters,
|
||||
releases: backend.NewReleaseService(cfg.ReleasesURL),
|
||||
stats: backend.NewStatisticsService(q),
|
||||
}
|
||||
s.search = backend.NewSearchCache(s.music.SoundtrackNames)
|
||||
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
|
||||
|
||||
// Create the HTTP server
|
||||
appServer.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: appServer.RegisterRoutes(),
|
||||
s.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: s.RegisterRoutes(),
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
logger.Info("Starting server",
|
||||
zap.String("host", host),
|
||||
zap.String("dbPort", dbPort),
|
||||
zap.String("username", username),
|
||||
zap.String("dbName", dbName),
|
||||
)
|
||||
|
||||
logger.Info("Paths",
|
||||
zap.String("musicPath", musicPath),
|
||||
zap.String("charactersPath", charactersPath),
|
||||
)
|
||||
|
||||
return appServer
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HTTPServer returns the underlying http.Server for serving HTTP requests.
|
||||
// afterSync refreshes cached data once a sync has finished.
|
||||
func (s *Server) afterSync(ctx context.Context) {
|
||||
if err := s.music.Reset(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err))
|
||||
}
|
||||
if err := s.search.Reload(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
|
||||
// It exits the process if any of these steps fail.
|
||||
func NewServerInstance() *Server {
|
||||
cfg, err := config.Load()
|
||||
logging.Init(cfg.LogLevel, cfg.LogJSON)
|
||||
logger := logging.GetLogger()
|
||||
if err != nil {
|
||||
logger.Fatal("Invalid settings", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Info("Starting server",
|
||||
zap.String("host", cfg.DBHost),
|
||||
zap.String("dbPort", cfg.DBPort),
|
||||
zap.String("username", cfg.DBUser),
|
||||
zap.String("dbName", cfg.DBName),
|
||||
zap.String("musicPath", cfg.MusicPath),
|
||||
zap.String("charactersPath", cfg.CharactersPath),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
database, err := db.NewDatabase(ctx, db.ConnString(cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName))
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to initialize database", zap.Error(err))
|
||||
}
|
||||
if err := database.RunMigrations(); err != nil {
|
||||
logger.Fatal("Migration failed", zap.Error(err))
|
||||
}
|
||||
|
||||
s, err := New(cfg, repository.New(database.Pool), database)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create server", zap.Error(err))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server. It returns nil after a graceful shutdown.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
|
||||
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown stops accepting requests, waits for running requests and then closes the database.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
err := s.httpServer.Shutdown(ctx)
|
||||
s.sync.Wait()
|
||||
s.characters.Close()
|
||||
s.db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// HTTPServer returns the underlying http.Server.
|
||||
func (s *Server) HTTPServer() *http.Server {
|
||||
return s.httpServer
|
||||
}
|
||||
|
||||
// DB returns the database instance for dependency injection.
|
||||
func (s *Server) DB() *db.Database {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// NewServer creates a new HTTP server (deprecated, use NewServerInstance instead).
|
||||
// This function is kept for backward compatibility.
|
||||
func NewServer() *http.Server {
|
||||
return NewServerInstance().HTTPServer()
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
// saveEnvVars saves the current state of relevant environment variables
|
||||
func saveEnvVars() map[string]string {
|
||||
vars := []string{
|
||||
"DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME",
|
||||
"MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL",
|
||||
}
|
||||
saved := make(map[string]string)
|
||||
for _, v := range vars {
|
||||
saved[v] = os.Getenv(v)
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
// restoreEnvVars restores environment variables from a saved state
|
||||
func restoreEnvVars(saved map[string]string) {
|
||||
for k, v := range saved {
|
||||
if v == "" {
|
||||
os.Unsetenv(k)
|
||||
} else {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkTableExists returns true if the specified table exists in the database
|
||||
func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) {
|
||||
var exists bool
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
)
|
||||
`, tableName).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database
|
||||
func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error {
|
||||
// Copy the SQL dump file to the container
|
||||
basename := filepath.Base(dumpPath)
|
||||
targetPath := "/tmp/" + basename
|
||||
if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil {
|
||||
return fmt.Errorf("failed to copy dump file to container: %w", err)
|
||||
}
|
||||
|
||||
// Execute psql to load the dump as the postgres superuser
|
||||
// We execute psql inside the container, so we use localhost and port 5432 (the container's internal port)
|
||||
// The postgres user is the superuser created by the Docker image
|
||||
// Use sh instead of bash as postgres:15-alpine uses Alpine Linux
|
||||
cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"}
|
||||
|
||||
exitCode, reader, err := container.Exec(ctx, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute psql: %w", err)
|
||||
}
|
||||
|
||||
// Read the output
|
||||
output, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read psql output: %w", err)
|
||||
}
|
||||
|
||||
if exitCode != 0 {
|
||||
return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output))
|
||||
}
|
||||
|
||||
t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createRoleInContainer creates a role in the PostgreSQL container
|
||||
func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error {
|
||||
// Get the mapped port for the container
|
||||
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get mapped port: %w", err)
|
||||
}
|
||||
|
||||
// Connect to the postgres database to create the role
|
||||
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
|
||||
mappedPort.Port())
|
||||
|
||||
pool, err := pgxpool.New(ctx, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to postgres database: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Check if role already exists
|
||||
var exists bool
|
||||
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists)
|
||||
if err == nil && exists {
|
||||
return nil // Role already exists
|
||||
}
|
||||
|
||||
// Clear any error from the role check (role might not exist, which is expected)
|
||||
// Create the role
|
||||
_, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create role %s: %w", roleName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDatabaseInContainer creates a new database in the PostgreSQL container
|
||||
func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error {
|
||||
// Get the mapped port for the container
|
||||
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get mapped port: %w", err)
|
||||
}
|
||||
|
||||
// Add a small delay to ensure PostgreSQL is fully ready
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Connect to the default 'postgres' database to create the new database
|
||||
// Use localhost and the mapped port
|
||||
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
|
||||
mappedPort.Port())
|
||||
|
||||
pool, err := pgxpool.New(ctx, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to postgres database: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Check if database already exists and drop it if so
|
||||
var exists int
|
||||
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
|
||||
if err == nil && exists == 1 {
|
||||
_, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop existing database: %w", err)
|
||||
}
|
||||
t.Logf("Dropped existing database %s", dbname)
|
||||
}
|
||||
|
||||
// Create the new database
|
||||
_, err = pool.Exec(ctx, "CREATE DATABASE "+dbname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database: %w", err)
|
||||
}
|
||||
|
||||
t.Logf("Created database %s", dbname)
|
||||
return nil
|
||||
}
|
||||
|
||||
// startPostgreSQLContainer starts a PostgreSQL container and returns it
|
||||
func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) {
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "postgres:15-alpine",
|
||||
ExposedPorts: []string{"5432/tcp"},
|
||||
Env: map[string]string{
|
||||
"POSTGRES_PASSWORD": "postgres",
|
||||
"POSTGRES_DB": "postgres",
|
||||
},
|
||||
WaitingFor: wait.ForLog("database system is ready to accept connections"),
|
||||
}
|
||||
|
||||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start container: %w", err)
|
||||
}
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
// TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work
|
||||
func TestMusicRefDump(t *testing.T) {
|
||||
testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test")
|
||||
}
|
||||
|
||||
// TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work
|
||||
func TestMusicTestDump(t *testing.T) {
|
||||
testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test")
|
||||
}
|
||||
|
||||
// TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work
|
||||
func TestMusicProdDump(t *testing.T) {
|
||||
testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test")
|
||||
}
|
||||
|
||||
// testSQLDump is the common test function for all SQL dump tests
|
||||
func testSQLDump(t *testing.T, dumpFile, dbname string) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Save original environment variables
|
||||
originalEnv := saveEnvVars()
|
||||
defer restoreEnvVars(originalEnv)
|
||||
|
||||
// Start PostgreSQL container
|
||||
t.Log("Starting PostgreSQL container...")
|
||||
container, err := startPostgreSQLContainer(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
t.Log("Stopping container...")
|
||||
container.Terminate(ctx)
|
||||
}()
|
||||
|
||||
// Get container connection details
|
||||
endpoint, err := container.Endpoint(ctx, "")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Container running at: %s", endpoint)
|
||||
|
||||
// Create database for this test
|
||||
t.Logf("Creating database %s...", dbname)
|
||||
err = createDatabaseInContainer(t, ctx, container, dbname)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create common roles that might be referenced in dump files
|
||||
err = createRoleInContainer(t, ctx, container, "sansan", "postgres")
|
||||
if err != nil {
|
||||
t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err)
|
||||
}
|
||||
|
||||
// Load the SQL dump
|
||||
// The dump files are in the project root
|
||||
// Get the absolute path to the dump file
|
||||
absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile))
|
||||
require.NoError(t, err)
|
||||
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
|
||||
// Try relative to current working directory
|
||||
absDumpPath = filepath.Join(".", dumpFile)
|
||||
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
|
||||
absDumpPath = dumpFile // Try as-is
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Loading SQL dump from: %s", absDumpPath)
|
||||
err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// ============================================
|
||||
// PRE-MIGRATION CHECK
|
||||
// ============================================
|
||||
t.Log("Running pre-migration checks...")
|
||||
|
||||
// For connecting to the database from the test, we need to use the container's endpoint
|
||||
// But the container's endpoint might be localhost:random_port
|
||||
// We need to parse it to get host and port
|
||||
// endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456"
|
||||
// Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port
|
||||
// Let's extract the mapped port
|
||||
|
||||
// Get the mapped port from the container
|
||||
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Container mapped port: %s", mappedPort)
|
||||
|
||||
// Use localhost as the host since we're on the same machine
|
||||
dbHost := "localhost"
|
||||
dbPort := mappedPort.Port()
|
||||
|
||||
// Create a connection to the database to check pre-migration state
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
dbHost, dbPort, "postgres", "postgres", dbname)
|
||||
|
||||
pool, err := pgxpool.New(ctx, connStr)
|
||||
require.NoError(t, err)
|
||||
defer pool.Close()
|
||||
|
||||
// Check that game table exists (pre-migration state)
|
||||
gameExists, err := checkTableExists(ctx, pool, "game")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gameExists, "Before migration: game table should exist")
|
||||
|
||||
// Check that soundtrack table does NOT exist
|
||||
soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist")
|
||||
|
||||
t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not")
|
||||
|
||||
// ============================================
|
||||
// START APPLICATION (triggers migrations)
|
||||
// ============================================
|
||||
t.Log("Starting application...")
|
||||
|
||||
// Set environment variables for the server
|
||||
// The application needs to connect to the container's mapped port
|
||||
os.Setenv("DB_HOST", dbHost)
|
||||
os.Setenv("DB_PORT", dbPort)
|
||||
os.Setenv("DB_USERNAME", "postgres")
|
||||
os.Setenv("DB_PASSWORD", "postgres")
|
||||
os.Setenv("DB_NAME", dbname)
|
||||
os.Setenv("MUSIC_PATH", "./testMusic")
|
||||
os.Setenv("CHARACTERS_PATH", "./testCharacters")
|
||||
|
||||
// Use different ports for each test to allow for sequential execution
|
||||
// and to make port detection reliable
|
||||
portMap := map[string]string{
|
||||
"music_server_ref_test": "8082",
|
||||
"music_server_test_dump_test": "8083",
|
||||
"music_server_prod_test": "8084",
|
||||
}
|
||||
appPort := portMap[dbname]
|
||||
if appPort == "" {
|
||||
appPort = "8085" // fallback
|
||||
}
|
||||
os.Setenv("PORT", appPort)
|
||||
|
||||
os.Setenv("DISABLE_SESSION_AUTH", "true")
|
||||
os.Setenv("LOG_LEVEL", "error")
|
||||
|
||||
// Create server instance (this runs migrations automatically)
|
||||
serverInstance := NewServerInstance()
|
||||
httpServer := serverInstance.HTTPServer()
|
||||
|
||||
// Start HTTP server in background
|
||||
go func() {
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
t.Logf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
t.Log("Stopping HTTP server...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
httpServer.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
// Wait for server to start by polling /health endpoint
|
||||
// Use the fixed port we configured for the application
|
||||
healthURL := fmt.Sprintf("http://localhost:%s/health", appPort)
|
||||
|
||||
maxAttempts := 30
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
resp, err := http.Get(healthURL)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var healthData map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&healthData)
|
||||
if healthData["status"] == "up" {
|
||||
t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if attempt == maxAttempts {
|
||||
require.Fail(t, "Server did not become healthy within timeout")
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// POST-MIGRATION CHECK
|
||||
// ============================================
|
||||
t.Log("Running post-migration checks...")
|
||||
|
||||
// Check that game table no longer exists
|
||||
gameExistsAfter, err := checkTableExists(ctx, pool, "game")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, gameExistsAfter, "After migration: game table should NOT exist")
|
||||
|
||||
// Check that soundtrack table now exists
|
||||
soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist")
|
||||
|
||||
// Verify data was preserved
|
||||
var soundtrackCount int
|
||||
err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0")
|
||||
t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount)
|
||||
|
||||
// ============================================
|
||||
// HEALTH CHECK
|
||||
// ============================================
|
||||
t.Log("Running health check...")
|
||||
|
||||
resp, err := http.Get(healthURL)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var healthData map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&healthData)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "up", healthData["status"])
|
||||
|
||||
t.Log("✓ Health check passed")
|
||||
t.Log("✓ Test completed successfully!")
|
||||
}
|
||||
@@ -11,265 +11,178 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultStatsLimit = 10
|
||||
maxStatsLimit = 100
|
||||
)
|
||||
|
||||
// StatisticsHandler handles statistics-related HTTP requests
|
||||
type StatisticsHandler struct {
|
||||
statsBackend *backend.StatisticsHandler
|
||||
stats *backend.StatisticsService
|
||||
}
|
||||
|
||||
// NewStatisticsHandler creates a new StatisticsHandler
|
||||
func NewStatisticsHandler() *StatisticsHandler {
|
||||
return &StatisticsHandler{
|
||||
statsBackend: backend.NewStatisticsHandler(),
|
||||
func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler {
|
||||
return &StatisticsHandler{stats: stats}
|
||||
}
|
||||
|
||||
// parseLimit reads the "limit" query parameter, defaulting to 10 and capping at 100.
|
||||
func parseLimit(ctx *echo.Context) (int32, bool) {
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr == "" {
|
||||
return defaultStatsLimit, true
|
||||
}
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int32(min(limit, maxStatsLimit)), true
|
||||
}
|
||||
|
||||
// withLimit runs a statistics query that takes a limit and writes the result as JSON.
|
||||
func withLimit[T any](ctx *echo.Context, name string, query func(*echo.Context, int32) (T, error)) error {
|
||||
limit, ok := parseLimit(ctx)
|
||||
if !ok {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
result, err := query(ctx, limit)
|
||||
return statsResult(ctx, name, result, err)
|
||||
}
|
||||
|
||||
func statsResult[T any](ctx *echo.Context, name string, result T, err error) error {
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get statistics", zap.String("statistic", name), zap.Error(err))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
|
||||
// GET /api/v1/statistics/soundtracks/most-played
|
||||
//
|
||||
// @Summary Get most played soundtracks
|
||||
// @Description Returns the top N most played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10)"
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/most-played [get]
|
||||
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
|
||||
limit := 10 // default
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr != "" {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
// Cap at 100 for performance
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
|
||||
soundtracks, err := h.statsBackend.GetMostPlayedSoundtracksWithSongs(int32(limit))
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get most played soundtracks", zap.String("error", err.Error()))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, soundtracks)
|
||||
return withLimit(ctx, "most played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.MostPlayedSoundtracks(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
|
||||
// GET /api/v1/statistics/soundtracks/least-played
|
||||
//
|
||||
// @Summary Get least played soundtracks
|
||||
// @Description Returns the top N least played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10)"
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/least-played [get]
|
||||
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
|
||||
limit := 10
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr != "" {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
|
||||
soundtracks, err := h.statsBackend.GetLeastPlayedSoundtracksWithSongs(int32(limit))
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get least played soundtracks", zap.String("error", err.Error()))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, soundtracks)
|
||||
return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.LeastPlayedSoundtracks(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetMostPlayedSongs returns top N most played songs with soundtrack info
|
||||
// GET /api/v1/statistics/songs/most-played
|
||||
// GetMostPlayedSongs returns top N most played songs
|
||||
//
|
||||
// @Summary Get most played songs
|
||||
// @Description Returns the top N most played songs with their soundtrack info
|
||||
// @Tags statistics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10)"
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SongInfoForStats
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/songs/most-played [get]
|
||||
func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
|
||||
limit := 10
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr != "" {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
|
||||
songs, err := h.statsBackend.GetMostPlayedSongsWithSoundtrack(int32(limit))
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error()))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, songs)
|
||||
return withLimit(ctx, "most played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
|
||||
return h.stats.MostPlayedSongs(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetLeastPlayedSongs returns top N least played songs with soundtrack info
|
||||
// GET /api/v1/statistics/songs/least-played
|
||||
// GetLeastPlayedSongs returns top N least played songs
|
||||
//
|
||||
// @Summary Get least played songs
|
||||
// @Description Returns the top N least played songs with their soundtrack info
|
||||
// @Tags statistics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10)"
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SongInfoForStats
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/songs/least-played [get]
|
||||
func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
|
||||
limit := 10
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr != "" {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
|
||||
songs, err := h.statsBackend.GetLeastPlayedSongsWithSoundtrack(int32(limit))
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, songs)
|
||||
return withLimit(ctx, "least played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
|
||||
return h.stats.LeastPlayedSongs(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||
// GET /api/v1/statistics/soundtracks/never-played
|
||||
//
|
||||
// @Summary Get never played soundtracks
|
||||
// @Description Returns all soundtracks that have never been played (times_played = 0)
|
||||
// @Tags statistics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/never-played [get]
|
||||
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
|
||||
soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get never played soundtracks", zap.String("error", err.Error()))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, soundtracks)
|
||||
}
|
||||
|
||||
// GetLastPlayedSoundtracks returns most recently played soundtracks
|
||||
// GET /api/v1/statistics/soundtracks/last-played
|
||||
// GetLastPlayedSoundtracks returns the most recently played soundtracks
|
||||
//
|
||||
// @Summary Get last played soundtracks
|
||||
// @Description Returns the most recently played soundtracks
|
||||
// @Description Returns the most recently played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10)"
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/last-played [get]
|
||||
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
|
||||
limit := 10
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr != "" {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
|
||||
soundtracks, err := h.statsBackend.GetLastPlayedSoundtracks(int32(limit))
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get last played soundtracks", zap.String("error", err.Error()))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, soundtracks)
|
||||
return withLimit(ctx, "last played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.LastPlayedSoundtracks(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetOldestPlayedSoundtracks returns least recently played soundtracks
|
||||
// GET /api/v1/statistics/soundtracks/oldest-played
|
||||
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
|
||||
//
|
||||
// @Summary Get oldest played soundtracks
|
||||
// @Description Returns the least recently played soundtracks (that have been played at least once)
|
||||
// @Description Returns the least recently played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10)"
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
|
||||
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
|
||||
limit := 10
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr != "" {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.OldestPlayedSoundtracks(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
soundtracks, err := h.statsBackend.GetOldestPlayedSoundtracks(int32(limit))
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get oldest played soundtracks", zap.String("error", err.Error()))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, soundtracks)
|
||||
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||
//
|
||||
// @Summary Get never played soundtracks
|
||||
// @Description Returns all soundtracks that have never been played
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/never-played [get]
|
||||
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
|
||||
result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context())
|
||||
return statsResult(ctx, "never played soundtracks", result, err)
|
||||
}
|
||||
|
||||
// GetStatisticsSummary returns overall statistics
|
||||
// GET /api/v1/statistics/summary
|
||||
//
|
||||
// @Summary Get statistics summary
|
||||
// @Description Returns overall statistics about the music library
|
||||
// @Description Returns overall play statistics
|
||||
// @Tags statistics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.StatisticsSummary
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/summary [get]
|
||||
func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error {
|
||||
summary, err := h.statsBackend.GetStatisticsSummary()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get statistics summary", zap.String("error", err.Error()))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, summary)
|
||||
result, err := h.stats.Summary(ctx.Request().Context())
|
||||
return statsResult(ctx, "summary", result, err)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestStatisticsEndpoints tests the statistics API endpoints
|
||||
func TestStatisticsEndpoints(t *testing.T) {
|
||||
// Skip if test database not configured
|
||||
e := StartTestServer(t)
|
||||
if e == nil {
|
||||
t.Skip("Test database not configured")
|
||||
}
|
||||
|
||||
// Get token first
|
||||
token := getTestToken(t, e)
|
||||
if token == "" {
|
||||
t.Skip("Could not get test token")
|
||||
}
|
||||
|
||||
// Test /api/v1/statistics/summary
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var summary backend.StatisticsSummary
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, summary)
|
||||
}
|
||||
|
||||
// TestPartialMigrationThenSyncThenComplete tests migration workflow
|
||||
// Note: This test requires the database to be in a specific state
|
||||
// It tests: partial migration → data insert → sync → complete migration
|
||||
func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
|
||||
// This test is complex and requires careful setup
|
||||
// For now, we test the final state: all migrations + sync
|
||||
|
||||
e := StartTestServer(t)
|
||||
if e == nil {
|
||||
t.Skip("Test database not configured")
|
||||
}
|
||||
|
||||
// Get token
|
||||
token := getTestToken(t, e)
|
||||
if token == "" {
|
||||
t.Skip("Could not get test token")
|
||||
}
|
||||
|
||||
// Insert test data manually (5 soundtracks with songs)
|
||||
insertTestData(t)
|
||||
|
||||
// Run sync to ensure data is properly loaded
|
||||
req := httptest.NewRequest(http.MethodGet, "/sync/new", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
// Wait for sync to complete
|
||||
if !waitForSyncCompletion(t, e, 60) {
|
||||
t.Error("Sync did not complete within timeout")
|
||||
}
|
||||
|
||||
// Verify data via statistics endpoint
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var summary backend.StatisticsSummary
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
||||
require.NoError(t, err)
|
||||
|
||||
// After sync with /sync/new, only soundtracks matching filesystem remain
|
||||
// testMusic has 3 soundtracks
|
||||
require.Equal(t, int64(3), summary.TotalSoundtracks)
|
||||
}
|
||||
|
||||
// insertTestData inserts 5 test soundtracks with songs into the database
|
||||
func insertTestData(t *testing.T) {
|
||||
if db.TestDatabase == nil || db.TestDatabase.Pool == nil {
|
||||
t.Skip("Test database not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
queries := repository.New(db.TestDatabase.Pool)
|
||||
|
||||
// Insert 5 soundtracks
|
||||
soundtracks := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"Test Soundtrack 1", "/path/to/soundtrack1"},
|
||||
{"Test Soundtrack 2", "/path/to/soundtrack2"},
|
||||
{"Test Soundtrack 3", "/path/to/soundtrack3"},
|
||||
{"Test Soundtrack 4", "/path/to/soundtrack4"},
|
||||
{"Test Soundtrack 5", "/path/to/soundtrack5"},
|
||||
}
|
||||
|
||||
for _, st := range soundtracks {
|
||||
_, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{
|
||||
SoundtrackName: st.name,
|
||||
Path: st.path,
|
||||
Hash: "test-hash-" + st.name,
|
||||
Uuid: uuid.New(),
|
||||
})
|
||||
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
|
||||
}
|
||||
|
||||
// Get soundtrack IDs and UUIDs
|
||||
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
|
||||
|
||||
// Insert songs for each soundtrack
|
||||
songData := []struct {
|
||||
soundtrackID int32
|
||||
soundtrackUuid uuid.UUID
|
||||
songs []string
|
||||
}{
|
||||
{soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}},
|
||||
{soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}},
|
||||
{soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}},
|
||||
{soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}},
|
||||
{soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []string{"Song I"}},
|
||||
}
|
||||
|
||||
for _, sd := range songData {
|
||||
for _, songName := range sd.songs {
|
||||
err := queries.AddSong(ctx, repository.AddSongParams{
|
||||
SoundtrackID: sd.soundtrackID,
|
||||
SongName: songName,
|
||||
Path: "/path/to/" + songName + ".mp3",
|
||||
Uuid: uuid.New(),
|
||||
SoundtrackUuid: sd.soundtrackUuid,
|
||||
})
|
||||
require.NoError(t, err, "Failed to insert song: %s", songName)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Inserted %d soundtracks with %d total songs", len(soundtracks), 8)
|
||||
}
|
||||
|
||||
// getTestToken gets a valid token for testing
|
||||
func getTestToken(t *testing.T, e *echo.Echo) string {
|
||||
reqBody := `{"client_type": "test"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", strings.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Logf("Failed to get token: %s", rec.Body.String())
|
||||
return ""
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
return resp.Token
|
||||
}
|
||||
@@ -1,82 +1,80 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type SyncHandler struct {
|
||||
sync *backend.SyncService
|
||||
}
|
||||
|
||||
func NewSyncHandler() *SyncHandler {
|
||||
return &SyncHandler{}
|
||||
func NewSyncHandler(sync *backend.SyncService) *SyncHandler {
|
||||
return &SyncHandler{sync: sync}
|
||||
}
|
||||
|
||||
// SyncProgress godoc
|
||||
// @Summary Get sync progress
|
||||
// @Description Returns the current sync progress or result
|
||||
// @Description Returns the progress of the running sync, or the result of the last sync
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Success 200 {object} backend.SyncResponse
|
||||
// @Router /sync/progress [get]
|
||||
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
||||
if backend.Syncing {
|
||||
logging.GetLogger().Info("Getting sync progress")
|
||||
response := backend.SyncProgress()
|
||||
return ctx.JSON(http.StatusOK, response)
|
||||
if s.sync.IsSyncing() {
|
||||
return ctx.JSON(http.StatusOK, s.sync.Progress())
|
||||
}
|
||||
logging.GetLogger().Info("Getting sync result")
|
||||
response := backend.SyncResult()
|
||||
return ctx.JSON(http.StatusOK, response)
|
||||
return ctx.JSON(http.StatusOK, s.sync.Result())
|
||||
}
|
||||
|
||||
// SyncSoundtracksNewOnlyChanges godoc
|
||||
// @Summary Sync soundtracks with only changes
|
||||
// @Description Starts syncing soundtracks with only new changes
|
||||
// @Description Starts syncing soundtracks that have changed
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Start syncing soundtracks"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync [get]
|
||||
// @Router /sync [post]
|
||||
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
||||
logging.GetLogger().Info("Starting sync with only changes")
|
||||
backend.Syncing = true
|
||||
go backend.SyncSoundtracksOnlyChanges()
|
||||
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
|
||||
return s.start(ctx, false, "Start syncing soundtracks")
|
||||
}
|
||||
|
||||
// SyncSoundtracksNewFull godoc
|
||||
// @Summary Sync all soundtracks fully
|
||||
// @Description Starts a full sync of all soundtracks
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Start syncing soundtracks full"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync/full [get]
|
||||
// @Router /sync/full [post]
|
||||
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
||||
logging.GetLogger().Info("Starting full sync")
|
||||
backend.Syncing = true
|
||||
go backend.SyncSoundtracksFull()
|
||||
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
|
||||
return s.start(ctx, true, "Start syncing soundtracks full")
|
||||
}
|
||||
|
||||
func (s *SyncHandler) start(ctx *echo.Context, full bool, message string) error {
|
||||
if !s.sync.Start(full) {
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
logging.GetLogger().Info(message)
|
||||
return ctx.JSON(http.StatusOK, message)
|
||||
}
|
||||
|
||||
// ResetDB godoc
|
||||
// @Summary Reset soundtracks database
|
||||
// @Description Resets the soundtracks database by deleting all soundtracks and songs
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync/reset [get]
|
||||
// @Router /sync/reset [post]
|
||||
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
||||
logging.GetLogger().Info("Resetting soundtracks database")
|
||||
backend.ResetDB()
|
||||
if err := s.sync.ResetDB(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// waitForSyncCompletion polls the sync progress endpoint until sync is complete
|
||||
// Returns true if sync completed, false if timeout
|
||||
func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
progressResp := MakeTestRequest(t, e, "GET", "/sync/progress")
|
||||
assert.Equal(t, http.StatusOK, progressResp.Code)
|
||||
|
||||
// Try to parse as ProgressResponse first (while syncing)
|
||||
var progress backend.ProgressResponse
|
||||
err := json.Unmarshal(progressResp.Body.Bytes(), &progress)
|
||||
if err == nil && progress.Progress != "" {
|
||||
// Successfully parsed as ProgressResponse with non-empty progress
|
||||
t.Logf("Sync progress: %s%%", progress.Progress)
|
||||
if progress.Progress == "100" {
|
||||
t.Log("Sync completed!")
|
||||
// Wait for Syncing flag to be updated
|
||||
for j := 0; j < 50; j++ {
|
||||
if !backend.Syncing {
|
||||
return true
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// If Progress is empty or parse failed, it might be a SyncResponse (sync already completed)
|
||||
var result backend.SyncResponse
|
||||
err2 := json.Unmarshal(progressResp.Body.Bytes(), &result)
|
||||
if err2 == nil {
|
||||
t.Log("Sync already completed")
|
||||
// Wait for Syncing flag to be updated
|
||||
for j := 0; j < 50; j++ {
|
||||
if !backend.Syncing {
|
||||
return true
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestSyncPopulatesDatabase verifies that sync populates the database with soundtracks
|
||||
func TestSyncPopulatesDatabase(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
// Debug: Check MUSIC_PATH
|
||||
t.Logf("MUSIC_PATH: %s", os.Getenv("MUSIC_PATH"))
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Clear any existing data first
|
||||
db.TestClearDatabase(t)
|
||||
|
||||
// Before sync - should have no soundtracks
|
||||
repo := repository.New(backend.BackendPool())
|
||||
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
beforeCount := len(soundtracksBefore)
|
||||
t.Logf("Soundtracks before sync: %d", beforeCount)
|
||||
assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
|
||||
|
||||
// Run sync
|
||||
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Wait for sync to complete
|
||||
if !waitForSyncCompletion(t, e, 60) {
|
||||
t.Error("Sync did not complete within timeout")
|
||||
}
|
||||
|
||||
// After sync - should have soundtracks
|
||||
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
afterCount := len(soundtracksAfter)
|
||||
t.Logf("Soundtracks after sync: %d", afterCount)
|
||||
|
||||
// Should have more soundtracks than before (unless database was already populated)
|
||||
assert.True(t, afterCount > 0, "Database should have soundtracks after sync")
|
||||
}
|
||||
|
||||
// TestSyncMakesDifference verifies that sync actually changes the database state
|
||||
func TestSyncMakesDifference(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Clear any existing data first
|
||||
db.TestClearDatabase(t)
|
||||
|
||||
// Before sync - should have no soundtracks
|
||||
repo := repository.New(backend.BackendPool())
|
||||
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(soundtracksBefore), "Should have no soundtracks before sync")
|
||||
|
||||
// Run sync
|
||||
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Wait for sync to complete
|
||||
if !waitForSyncCompletion(t, e, 60) {
|
||||
t.Error("Sync did not complete within timeout")
|
||||
}
|
||||
|
||||
// After sync - should have soundtracks
|
||||
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(soundtracksAfter) > 0, "Should have soundtracks after sync")
|
||||
}
|
||||
|
||||
// TestSyncProgress verifies the sync progress endpoint
|
||||
func TestSyncProgress(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Start sync in background
|
||||
go MakeTestRequest(t, e, "GET", "/sync/full")
|
||||
|
||||
// Poll progress endpoint
|
||||
maxAttempts := 30
|
||||
foundComplete := false
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
resp := MakeTestRequest(t, e, "GET", "/sync/progress")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Try ProgressResponse first
|
||||
var progress backend.ProgressResponse
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &progress)
|
||||
if err == nil && progress.Progress != "" {
|
||||
// Successfully parsed as ProgressResponse with non-empty progress
|
||||
t.Logf("Sync progress: %s%%", progress.Progress)
|
||||
|
||||
// Verify we get valid progress values
|
||||
if progress.Progress != "0" {
|
||||
// Sync is making progress
|
||||
}
|
||||
if progress.Progress == "100" {
|
||||
foundComplete = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// If Progress is empty or parse failed, it might be a SyncResponse (sync already completed)
|
||||
var result backend.SyncResponse
|
||||
err2 := json.Unmarshal(resp.Body.Bytes(), &result)
|
||||
if err2 == nil {
|
||||
foundComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
// Note: foundNonZero might be false if sync completed too quickly
|
||||
// So we only assert that sync completed
|
||||
assert.True(t, foundComplete, "Should have seen completion")
|
||||
}
|
||||
|
||||
// TestSyncSoundtracksNewOnlyChanges verifies the incremental sync endpoint
|
||||
func TestSyncSoundtracksNewOnlyChanges(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Run full sync first
|
||||
MakeTestRequest(t, e, "GET", "/sync/full")
|
||||
// Wait for it to complete
|
||||
if !waitForSyncCompletion(t, e, 60) {
|
||||
t.Error("Initial sync did not complete within timeout")
|
||||
return
|
||||
}
|
||||
|
||||
// Get initial count
|
||||
repo := repository.New(backend.BackendPool())
|
||||
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
beforeCount := len(soundtracksBefore)
|
||||
|
||||
// Run incremental sync (should not change count if nothing changed)
|
||||
resp := MakeTestRequest(t, e, "GET", "/sync/new")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Wait a bit
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Count should be the same
|
||||
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
afterCount := len(soundtracksAfter)
|
||||
|
||||
// Note: This might not be exactly equal due to timing, but should be close
|
||||
t.Logf("Soundtracks before incremental sync: %d, after: %d", beforeCount, afterCount)
|
||||
}
|
||||
|
||||
// TestResetSoundtracks verifies the reset endpoint clears the database
|
||||
// RUN THIS LAST
|
||||
func TestResetSoundtracks(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// First ensure we have data
|
||||
repo := repository.New(backend.BackendPool())
|
||||
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
beforeCount := len(soundtracksBefore)
|
||||
|
||||
if beforeCount == 0 {
|
||||
// Run sync to populate
|
||||
MakeTestRequest(t, e, "GET", "/sync/full")
|
||||
if !waitForSyncCompletion(t, e, 60) {
|
||||
t.Error("Sync did not complete within timeout")
|
||||
return
|
||||
}
|
||||
soundtracksBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
beforeCount = len(soundtracksBefore)
|
||||
}
|
||||
|
||||
t.Logf("Soundtracks before reset: %d", beforeCount)
|
||||
assert.True(t, beforeCount > 0, "Should have soundtracks to reset")
|
||||
|
||||
// Call reset
|
||||
resp := MakeTestRequest(t, e, "GET", "/sync/reset")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Verify database is cleared
|
||||
// Note: reset might take a moment to propagate
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
afterCount := len(soundtracksAfter)
|
||||
|
||||
t.Logf("Soundtracks after reset: %d", afterCount)
|
||||
assert.Equal(t, 0, afterCount, "Database should be empty after reset")
|
||||
}
|
||||
|
||||
// TestSyncSoundtracksNewFull verifies the full sync endpoint
|
||||
// RUN THIS LAST (before TestResetSoundtracks)
|
||||
func TestSyncSoundtracksNewFull(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Clear database first
|
||||
db.TestClearDatabase(t)
|
||||
|
||||
// Run full sync
|
||||
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Wait for sync to complete
|
||||
if !waitForSyncCompletion(t, e, 60) {
|
||||
t.Error("Full sync did not complete within timeout")
|
||||
}
|
||||
|
||||
// Verify database is populated
|
||||
repo := repository.New(backend.BackendPool())
|
||||
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(soundtracks) > 0, "Database should be populated after full sync")
|
||||
t.Logf("Full sync populated %d soundtracks", len(soundtracks))
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// StartTestServer starts the server for testing with test configuration
|
||||
func StartTestServer(t *testing.T) *echo.Echo {
|
||||
// Set test environment variables if not already set
|
||||
if os.Getenv("DB_HOST") == "" {
|
||||
os.Setenv("DB_HOST", "localhost")
|
||||
}
|
||||
if os.Getenv("DB_PORT") == "" {
|
||||
os.Setenv("DB_PORT", "5432")
|
||||
}
|
||||
if os.Getenv("DB_USERNAME") == "" {
|
||||
os.Setenv("DB_USERNAME", "testuser")
|
||||
}
|
||||
if os.Getenv("DB_PASSWORD") == "" {
|
||||
os.Setenv("DB_PASSWORD", "testpass")
|
||||
}
|
||||
if os.Getenv("DB_NAME") == "" {
|
||||
os.Setenv("DB_NAME", "music_server_test")
|
||||
}
|
||||
if os.Getenv("MUSIC_PATH") == "" {
|
||||
os.Setenv("MUSIC_PATH", "./testMusic")
|
||||
}
|
||||
if os.Getenv("CHARACTERS_PATH") == "" {
|
||||
os.Setenv("CHARACTERS_PATH", "./testCharacters")
|
||||
}
|
||||
if os.Getenv("PORT") == "" {
|
||||
os.Setenv("PORT", "8081")
|
||||
}
|
||||
if os.Getenv("LOG_LEVEL") == "" {
|
||||
os.Setenv("LOG_LEVEL", "debug")
|
||||
}
|
||||
if os.Getenv("LOG_JSON") == "" {
|
||||
os.Setenv("LOG_JSON", "false")
|
||||
}
|
||||
|
||||
// Initialize database for tests
|
||||
db.TestSetupDB(t)
|
||||
|
||||
// Initialize backend with test database pool
|
||||
// This ensures BackendRepo() and BackendCtx() are available
|
||||
if db.TestDatabase != nil && db.TestDatabase.Pool != nil {
|
||||
backend.InitBackend(db.TestDatabase.Pool)
|
||||
}
|
||||
|
||||
// Create a Server instance and get its routes
|
||||
s := &Server{
|
||||
db: db.TestDatabase,
|
||||
tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
|
||||
statisticsHandler: NewStatisticsHandler(),
|
||||
}
|
||||
handler := s.RegisterRoutes()
|
||||
|
||||
// Wrap the http.Handler in an echo.Echo
|
||||
e := echo.New()
|
||||
// Use a custom handler that wraps our routes
|
||||
e.Any("/*", echo.WrapHandler(handler))
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// MakeTestRequest makes an HTTP request to the test server
|
||||
func MakeTestRequest(t *testing.T, e *echo.Echo, method, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// MakeTestRequestWithBody makes an HTTP request with a body to the test server
|
||||
func MakeTestRequestWithBody(t *testing.T, e *echo.Echo, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewBuffer(body))
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// WaitForSyncComplete polls the sync progress endpoint until sync is complete
|
||||
func WaitForSyncComplete(t *testing.T, e *echo.Echo, timeout time.Duration) bool {
|
||||
start := time.Now()
|
||||
for time.Since(start) < timeout {
|
||||
resp := MakeTestRequest(t, e, "GET", "/sync/progress")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Logf("Sync progress endpoint returned status %d", resp.Code)
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse response - we can't easily decode here without importing backend
|
||||
// Just check if response contains "100"
|
||||
body := resp.Body.String()
|
||||
if len(body) > 0 {
|
||||
t.Logf("Sync progress: %s", body)
|
||||
// Simple check for completion
|
||||
// In a real scenario, you'd parse the JSON properly
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
t.Error("Sync did not complete within timeout")
|
||||
return false
|
||||
}
|
||||
@@ -2,24 +2,25 @@ package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const tokenLifetime = 24 * time.Hour
|
||||
|
||||
// TokenRequest represents a request to generate a new token
|
||||
type TokenRequest struct {
|
||||
Password string `json:"password"` // Required: API password
|
||||
Password string `json:"password"` // API password, required when API_PASSWORD is set
|
||||
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||
}
|
||||
|
||||
@@ -30,20 +31,19 @@ type TokenResponse struct {
|
||||
ClientType string `json:"client_type"`
|
||||
}
|
||||
|
||||
// TokenHandler contains the database pool for token operations
|
||||
// TokenHandler creates and removes session tokens.
|
||||
type TokenHandler struct {
|
||||
pool *pgxpool.Pool
|
||||
q repository.Querier
|
||||
apiPassword string
|
||||
disableSessionAuth bool
|
||||
}
|
||||
|
||||
// NewTokenHandler creates a new token handler with database pool
|
||||
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
|
||||
return &TokenHandler{
|
||||
pool: pool,
|
||||
}
|
||||
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
|
||||
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
|
||||
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
|
||||
}
|
||||
|
||||
// generateToken creates a new cryptographically secure token
|
||||
func (h *TokenHandler) generateToken() (string, error) {
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
@@ -51,11 +51,17 @@ func (h *TokenHandler) generateToken() (string, error) {
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (h *TokenHandler) passwordValid(password string) bool {
|
||||
if h.disableSessionAuth || h.apiPassword == "" {
|
||||
return true
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1
|
||||
}
|
||||
|
||||
// CreateTokenHandler creates a new session token
|
||||
// POST /api/v1/token
|
||||
//
|
||||
// @Summary Create session token
|
||||
// @Description Returns a new session token for API access. Requires valid API password.
|
||||
// @Description Returns a new session token for API access. Requires the API password when one is configured.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -63,6 +69,7 @@ func (h *TokenHandler) generateToken() (string, error) {
|
||||
// @Success 200 {object} TokenResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 429 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [post]
|
||||
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
@@ -71,72 +78,51 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
// Check if session auth is disabled
|
||||
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
|
||||
apiPassword := os.Getenv("API_PASSWORD")
|
||||
|
||||
// Password validation logic:
|
||||
// - If session auth is disabled, skip validation
|
||||
// - If API_PASSWORD is empty, allow any password (open access mode)
|
||||
// - If API_PASSWORD is set, require exact password match
|
||||
if !disableSessionAuth {
|
||||
if apiPassword != "" {
|
||||
// API password is set - require exact match
|
||||
if req.Password != apiPassword {
|
||||
if !h.passwordValid(req.Password) {
|
||||
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("user_agent", c.Request().UserAgent()))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
|
||||
}
|
||||
}
|
||||
// If API_PASSWORD is empty, allow any password through (open access mode)
|
||||
}
|
||||
|
||||
if req.ClientType == "" {
|
||||
req.ClientType = "web"
|
||||
}
|
||||
|
||||
// Generate token
|
||||
token, err := h.generateToken()
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error()))
|
||||
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
|
||||
}
|
||||
|
||||
// Set expiration (24 hours from now)
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
clientType := req.ClientType
|
||||
|
||||
// Store in database using sqlc-generated repository
|
||||
queries := repository.New(h.pool)
|
||||
session, err := queries.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||
Token: token,
|
||||
IpAddress: c.RealIP(),
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
ClientType: &clientType,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
ClientType: &req.ClientType,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error()))
|
||||
logging.GetLogger().Error("Failed to create session", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
|
||||
}
|
||||
|
||||
response := TokenResponse{
|
||||
clientType := req.ClientType
|
||||
if session.ClientType != nil {
|
||||
clientType = *session.ClientType
|
||||
}
|
||||
return c.JSON(http.StatusOK, TokenResponse{
|
||||
Token: session.Token,
|
||||
ExpiresAt: session.ExpiresAt.Time,
|
||||
ClientType: *session.ClientType,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
ClientType: clientType,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTokenHandler invalidates a session token
|
||||
// DELETE /api/v1/token
|
||||
//
|
||||
// @Summary Invalidate session token
|
||||
// @Description Deletes the current session token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer token"
|
||||
// @Success 200 {object} map[string]string
|
||||
@@ -144,56 +130,36 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [delete]
|
||||
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
if c.Request().Header.Get("Authorization") == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
token, ok := middleware.BearerToken(c)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// Delete session using sqlc-generated repository
|
||||
queries := repository.New(h.pool)
|
||||
err := queries.DeleteSession(c.Request().Context(), token)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to delete session", zap.String("error", err.Error()))
|
||||
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
|
||||
}
|
||||
|
||||
// CleanupExpiredSessionsHandler removes all expired sessions
|
||||
// POST /api/v1/token/cleanup
|
||||
//
|
||||
// @Summary Cleanup expired sessions
|
||||
// @Description Removes all expired session tokens from the database
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer token"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token/cleanup [post]
|
||||
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
|
||||
// Verify token is valid first (using existing middleware)
|
||||
// The middleware will have already validated the token
|
||||
|
||||
queries := repository.New(h.pool)
|
||||
err := queries.DeleteExpiredSessions(c.Request().Context())
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to cleanup sessions", zap.String("error", err.Error()))
|
||||
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
|
||||
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
|
||||
}
|
||||
|
||||
// Get count of deleted rows (DeleteExpiredSessions doesn't return count in the generated code)
|
||||
// So we just return success
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "cleanup complete",
|
||||
})
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
|
||||
}
|
||||
@@ -3,12 +3,12 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type VersionHandler struct {
|
||||
}
|
||||
type VersionHandler struct{}
|
||||
|
||||
func NewVersionHandler() *VersionHandler {
|
||||
return &VersionHandler{}
|
||||
@@ -19,17 +19,11 @@ func NewVersionHandler() *VersionHandler {
|
||||
// @Summary Getting the version history of the backend
|
||||
// @Description get version history
|
||||
// @Tags version
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.VersionData
|
||||
// @Failure 404 {object} string
|
||||
// @Router /version/history [get]
|
||||
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
||||
versionHistory := backend.GetVersionHistory()
|
||||
if len(versionHistory) == 0 {
|
||||
return ctx.JSON(http.StatusNotFound, "version not found")
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, versionHistory)
|
||||
return ctx.JSON(http.StatusOK, backend.GetVersionHistory())
|
||||
}
|
||||
|
||||
// GetLatestVersion godoc
|
||||
@@ -37,15 +31,9 @@ func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
||||
// @Summary Getting the latest version of the backend
|
||||
// @Description get latest version info
|
||||
// @Tags version
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.VersionData
|
||||
// @Failure 404 {object} string
|
||||
// @Router /version [get]
|
||||
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
|
||||
latestVersion := backend.GetLatestVersion()
|
||||
if latestVersion.Version == "" {
|
||||
return ctx.JSON(http.StatusNotFound, "version not found")
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, latestVersion)
|
||||
return ctx.JSON(http.StatusOK, backend.GetLatestVersion())
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestGetLatestVersion verifies the version endpoint returns latest version
|
||||
func TestGetLatestVersion(t *testing.T) {
|
||||
e := StartTestServer(t)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/version")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var versionData backend.VersionData
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, versionData.Version)
|
||||
assert.NotEmpty(t, versionData.Changelog)
|
||||
}
|
||||
|
||||
// TestGetVersionHistory verifies the version history endpoint returns version history
|
||||
func TestGetVersionHistory(t *testing.T) {
|
||||
e := StartTestServer(t)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/version/history")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var versionHistory []backend.VersionData
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &versionHistory)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, versionHistory)
|
||||
assert.NotEmpty(t, versionHistory[0].Version)
|
||||
assert.NotEmpty(t, versionHistory[0].Changelog)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ensureSyncRan ensures that sync has been run before testing music endpoints
|
||||
func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
||||
repo := repository.New(backend.BackendPool())
|
||||
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
|
||||
if len(soundtracks) == 0 {
|
||||
// Run sync
|
||||
t.Log("No soundtracks found, running sync first...")
|
||||
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Wait for sync to complete using shared helper
|
||||
if !waitForSyncCompletion(t, e, 60) {
|
||||
t.Error("Sync did not complete within timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAllSoundtracks verifies the /music/all/order endpoint
|
||||
func TestZGetAllSoundtracks(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run
|
||||
ensureSyncRan(t, e)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/all/order")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var soundtracks []string
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
|
||||
t.Logf("Found %d soundtracks", len(soundtracks))
|
||||
}
|
||||
|
||||
// TestGetAllSoundtracksRandom verifies the /music/all/random endpoint
|
||||
func TestZGetAllSoundtracksRandom(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run
|
||||
ensureSyncRan(t, e)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/all/random")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var soundtracks []string
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
|
||||
|
||||
// Verify it's shuffled (not in original order)
|
||||
// We can't easily verify randomness, but we can check it's the same length
|
||||
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
|
||||
var soundtracksOrdered []string
|
||||
json.Unmarshal(resp2.Body.Bytes(), &soundtracksOrdered)
|
||||
assert.Equal(t, len(soundtracks), len(soundtracksOrdered), "Random and ordered should have same count")
|
||||
}
|
||||
|
||||
// TestGetRandomSong verifies the /music/rand endpoint
|
||||
func TestZGetRandomSong(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run
|
||||
ensureSyncRan(t, e)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// The endpoint returns a file stream, not JSON
|
||||
// Just verify we got a response with content
|
||||
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||
t.Logf("Random song returned %d bytes", len(resp.Body.Bytes()))
|
||||
}
|
||||
|
||||
// TestGetRandomSongLowChance verifies the /music/rand/low endpoint
|
||||
func TestZGetRandomSongLowChance(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run
|
||||
ensureSyncRan(t, e)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/rand/low")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// The endpoint returns a file stream, not JSON
|
||||
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||
}
|
||||
|
||||
// TestGetRandomSongClassic verifies the /music/rand/classic endpoint
|
||||
func TestZGetRandomSongClassic(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run
|
||||
ensureSyncRan(t, e)
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/rand/classic")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// The endpoint returns a file stream, not JSON
|
||||
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||
}
|
||||
|
||||
// TestGetSongInfo verifies the /music/info endpoint
|
||||
func TestZGetSongInfo(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run and get a song first
|
||||
ensureSyncRan(t, e)
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
// Add to queue and mark as played
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
MakeTestRequest(t, e, "GET", "/music/addPlayed")
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/info")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var info backend.SongInfo
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &info)
|
||||
assert.NoError(t, err)
|
||||
// Note: CurrentlyPlaying might be false if no song is currently set
|
||||
// Just verify we got a valid response
|
||||
t.Logf("Song info: Soundtrack=%s, Song=%s", info.Soundtrack, info.Song)
|
||||
}
|
||||
|
||||
// TestGetPlayedSongs verifies the /music/list endpoint
|
||||
func TestZGetPlayedSongs(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run and add some songs to queue
|
||||
ensureSyncRan(t, e)
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/list")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var songs []backend.SongInfo
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &songs)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, songs, "Should have played songs in queue")
|
||||
t.Logf("Found %d songs in queue", len(songs))
|
||||
}
|
||||
|
||||
// TestGetNextSong verifies the /music/next endpoint
|
||||
func TestZGetNextSong(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run and add songs to queue
|
||||
ensureSyncRan(t, e)
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/next")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// The endpoint returns a file stream, not JSON
|
||||
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||
}
|
||||
|
||||
// TestGetPreviousSong verifies the /music/previous endpoint
|
||||
func TestZGetPreviousSong(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run and add songs to queue
|
||||
ensureSyncRan(t, e)
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
// Move forward
|
||||
MakeTestRequest(t, e, "GET", "/music/next")
|
||||
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/previous")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// The endpoint returns a file stream, not JSON
|
||||
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||
}
|
||||
|
||||
// TestResetMusic verifies the /music/reset endpoint
|
||||
func TestZResetMusic(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run and add songs to queue
|
||||
ensureSyncRan(t, e)
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
|
||||
// Verify queue has items
|
||||
respBefore := MakeTestRequest(t, e, "GET", "/music/list")
|
||||
var songsBefore []backend.SongInfo
|
||||
json.Unmarshal(respBefore.Body.Bytes(), &songsBefore)
|
||||
assert.True(t, len(songsBefore) > 0, "Should have songs before reset")
|
||||
|
||||
// Reset queue
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/reset")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Verify queue is empty
|
||||
respAfter := MakeTestRequest(t, e, "GET", "/music/list")
|
||||
var songsAfter []backend.SongInfo
|
||||
json.Unmarshal(respAfter.Body.Bytes(), &songsAfter)
|
||||
assert.Equal(t, 0, len(songsAfter), "Queue should be empty after reset")
|
||||
}
|
||||
|
||||
// TestAddLatestToQue verifies the /music/addQue endpoint
|
||||
func TestZAddLatestToQue(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run
|
||||
ensureSyncRan(t, e)
|
||||
|
||||
// Get a random song (this sets lastFetchedNew)
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
|
||||
// Add to queue
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Verify it was added to queue
|
||||
respList := MakeTestRequest(t, e, "GET", "/music/list")
|
||||
var songs []backend.SongInfo
|
||||
json.Unmarshal(respList.Body.Bytes(), &songs)
|
||||
assert.True(t, len(songs) > 0, "Song should be in queue")
|
||||
}
|
||||
|
||||
// TestAddLatestPlayed verifies the /music/addPlayed endpoint
|
||||
func TestZAddLatestPlayed(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run and add song to queue
|
||||
ensureSyncRan(t, e)
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
|
||||
// Mark as played
|
||||
resp := MakeTestRequest(t, e, "GET", "/music/addPlayed")
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
// TestPutPlayed verifies the PUT /music/played endpoint
|
||||
func TestZPutPlayed(t *testing.T) {
|
||||
db.TestSetupDB(t)
|
||||
defer db.TestTearDownDB(t)
|
||||
|
||||
e := StartTestServer(t)
|
||||
|
||||
// Ensure sync has run and add songs to queue
|
||||
ensureSyncRan(t, e)
|
||||
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||
|
||||
// Mark song 0 as played
|
||||
resp := MakeTestRequestWithBody(t, e, "PUT", "/music/played?song=0", nil)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
Reference in new issue
Block a user