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
+2919
-6303
No files matched your search
+204
-220
@@ -1,23 +1,23 @@
|
|||||||
// Package docs GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
|
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||||
// This file was generated by swaggo/swag
|
|
||||||
package docs
|
package docs
|
||||||
|
|
||||||
import (
|
import "github.com/swaggo/swag"
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"text/template"
|
|
||||||
|
|
||||||
"github.com/swaggo/swag"
|
const docTemplate = `{
|
||||||
)
|
|
||||||
|
|
||||||
var doc = `{
|
|
||||||
"schemes": {{ marshal .Schemes }},
|
"schemes": {{ marshal .Schemes }},
|
||||||
"swagger": "2.0",
|
"swagger": "2.0",
|
||||||
"info": {
|
"info": {
|
||||||
"description": "{{escape .Description}}",
|
"description": "{{escape .Description}}",
|
||||||
"title": "{{.Title}}",
|
"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}}"
|
"version": "{{.Version}}"
|
||||||
},
|
},
|
||||||
"host": "{{.Host}}",
|
"host": "{{.Host}}",
|
||||||
@@ -26,9 +26,6 @@ var doc = `{
|
|||||||
"/api/v1/statistics/songs/least-played": {
|
"/api/v1/statistics/songs/least-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the top N least played songs with their soundtrack info",
|
"description": "Returns the top N least played songs with their soundtrack info",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -39,7 +36,7 @@ var doc = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -78,9 +75,6 @@ var doc = `{
|
|||||||
"/api/v1/statistics/songs/most-played": {
|
"/api/v1/statistics/songs/most-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the top N most played songs with their soundtrack info",
|
"description": "Returns the top N most played songs with their soundtrack info",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -91,7 +85,7 @@ var doc = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -129,10 +123,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/api/v1/statistics/soundtracks/last-played": {
|
"/api/v1/statistics/soundtracks/last-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the most recently played soundtracks",
|
"description": "Returns the most recently played soundtracks with their songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -143,7 +134,7 @@ var doc = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -182,9 +173,6 @@ var doc = `{
|
|||||||
"/api/v1/statistics/soundtracks/least-played": {
|
"/api/v1/statistics/soundtracks/least-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the top N least played soundtracks with their songs",
|
"description": "Returns the top N least played soundtracks with their songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -195,7 +183,7 @@ var doc = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -234,9 +222,6 @@ var doc = `{
|
|||||||
"/api/v1/statistics/soundtracks/most-played": {
|
"/api/v1/statistics/soundtracks/most-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the top N most played soundtracks with their songs",
|
"description": "Returns the top N most played soundtracks with their songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -247,7 +232,7 @@ var doc = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -285,10 +270,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/api/v1/statistics/soundtracks/never-played": {
|
"/api/v1/statistics/soundtracks/never-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns all soundtracks that have never been played (times_played = 0)",
|
"description": "Returns all soundtracks that have never been played",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -320,10 +302,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/api/v1/statistics/soundtracks/oldest-played": {
|
"/api/v1/statistics/soundtracks/oldest-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the least recently played soundtracks (that have been played at least once)",
|
"description": "Returns the least recently played soundtracks with their songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -334,7 +313,7 @@ var doc = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -372,10 +351,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/api/v1/statistics/summary": {
|
"/api/v1/statistics/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns overall statistics about the music library",
|
"description": "Returns overall play statistics",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -404,7 +380,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/api/v1/token": {
|
"/api/v1/token": {
|
||||||
"post": {
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -451,6 +427,15 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error",
|
"description": "Internal Server Error",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -464,9 +449,6 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"description": "Deletes the current session token",
|
"description": "Deletes the current session token",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -517,9 +499,6 @@ var doc = `{
|
|||||||
"/api/v1/token/cleanup": {
|
"/api/v1/token/cleanup": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Removes all expired session tokens from the database",
|
"description": "Removes all expired session tokens from the database",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -541,7 +520,9 @@ var doc = `{
|
|||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
@@ -568,11 +549,9 @@ var doc = `{
|
|||||||
"/character": {
|
"/character": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the image for a specific character",
|
"description": "Returns the image for a specific character",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/png"
|
"image/png",
|
||||||
|
"image/jpeg"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"characters"
|
"characters"
|
||||||
@@ -581,7 +560,7 @@ var doc = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Character name",
|
"description": "Character file name",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -593,6 +572,18 @@ var doc = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "invalid name",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,9 +591,6 @@ var doc = `{
|
|||||||
"/characters": {
|
"/characters": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all available characters",
|
"description": "Returns a list of all available characters",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -625,10 +613,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/download": {
|
"/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Checks for the latest version of the application",
|
"description": "Returns the latest version of the client application",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -642,6 +627,12 @@ var doc = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "upstream service unavailable",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,9 +640,6 @@ var doc = `{
|
|||||||
"/download/linux": {
|
"/download/linux": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Redirects to download the latest Linux version",
|
"description": "Redirects to download the latest Linux version",
|
||||||
"produces": [
|
|
||||||
"application/octet-stream"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"download"
|
"download"
|
||||||
],
|
],
|
||||||
@@ -662,6 +650,18 @@ var doc = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"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": {
|
"/download/list": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Lists all assets available for the latest version",
|
"description": "Lists all assets available for the latest version",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -688,6 +685,12 @@ var doc = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "upstream service unavailable",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -695,9 +698,6 @@ var doc = `{
|
|||||||
"/download/windows": {
|
"/download/windows": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Redirects to download the latest Windows version",
|
"description": "Redirects to download the latest Windows version",
|
||||||
"produces": [
|
|
||||||
"application/octet-stream"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"download"
|
"download"
|
||||||
],
|
],
|
||||||
@@ -708,6 +708,18 @@ var doc = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "upstream service unavailable",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -715,9 +727,6 @@ var doc = `{
|
|||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the health status of the server",
|
"description": "Returns the health status of the server",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -729,7 +738,10 @@ var doc = `{
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,10 +749,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/music": {
|
"/music": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a specific song by name",
|
"description": "Returns the song at the given position in the queue",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"audio/mpeg"
|
"audio/mpeg"
|
||||||
],
|
],
|
||||||
@@ -750,8 +759,8 @@ var doc = `{
|
|||||||
"summary": "Get a specific song",
|
"summary": "Get a specific song",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "integer",
|
||||||
"description": "Song name",
|
"description": "Song number in the queue",
|
||||||
"name": "song",
|
"name": "song",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -764,8 +773,14 @@ var doc = `{
|
|||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"206": {
|
||||||
|
"description": "Partial Content",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "song can't be empty",
|
"description": "song must be a number",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -787,17 +802,14 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/music/addPlayed": {
|
"/music/addPlayed": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Adds the latest song to the played list",
|
"description": "Marks the current song as played",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Add latest to played",
|
"summary": "Add latest to played",
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK"
|
||||||
},
|
},
|
||||||
"423": {
|
"423": {
|
||||||
"description": "Syncing is in progress",
|
"description": "Syncing is in progress",
|
||||||
@@ -811,16 +823,13 @@ var doc = `{
|
|||||||
"/music/addQue": {
|
"/music/addQue": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Adds the latest song to the queue",
|
"description": "Adds the latest song to the queue",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Add latest to queue",
|
"summary": "Add latest to queue",
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK"
|
||||||
},
|
},
|
||||||
"423": {
|
"423": {
|
||||||
"description": "Syncing is in progress",
|
"description": "Syncing is in progress",
|
||||||
@@ -834,9 +843,6 @@ var doc = `{
|
|||||||
"/music/all/order": {
|
"/music/all/order": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all soundtracks in order",
|
"description": "Returns a list of all soundtracks in order",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -850,8 +856,7 @@ var doc = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "string"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -867,9 +872,6 @@ var doc = `{
|
|||||||
"/music/all/random": {
|
"/music/all/random": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all soundtracks in random order",
|
"description": "Returns a list of all soundtracks in random order",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -883,8 +885,7 @@ var doc = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "string"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -900,9 +901,6 @@ var doc = `{
|
|||||||
"/music/info": {
|
"/music/info": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns information about the current song",
|
"description": "Returns information about the current song",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -914,8 +912,7 @@ var doc = `{
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/backend.SongInfo"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -924,9 +921,6 @@ var doc = `{
|
|||||||
"/music/list": {
|
"/music/list": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of played songs",
|
"description": "Returns a list of played songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -940,8 +934,7 @@ var doc = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/backend.SongInfo"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -982,13 +975,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/music/played": {
|
"/music/played": {
|
||||||
"put": {
|
"put": {
|
||||||
"description": "Marks a song as played by its ID",
|
"description": "Marks the song at the given queue position as played",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
@@ -996,15 +983,15 @@ var doc = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Song ID",
|
"description": "Song number in the queue",
|
||||||
"name": "song",
|
"name": "song",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": true
|
"required": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK"
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request",
|
"description": "Bad Request",
|
||||||
@@ -1087,7 +1074,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/music/rand/classic": {
|
"/music/rand/classic": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a random song from the classic selection",
|
"description": "Returns a random song where every song has the same chance",
|
||||||
"produces": [
|
"produces": [
|
||||||
"audio/mpeg"
|
"audio/mpeg"
|
||||||
],
|
],
|
||||||
@@ -1119,7 +1106,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/music/rand/low": {
|
"/music/rand/low": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a random song with low chance selection",
|
"description": "Returns a random song, favouring soundtracks that have been played less",
|
||||||
"produces": [
|
"produces": [
|
||||||
"audio/mpeg"
|
"audio/mpeg"
|
||||||
],
|
],
|
||||||
@@ -1152,16 +1139,13 @@ var doc = `{
|
|||||||
"/music/reset": {
|
"/music/reset": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Resets the music state",
|
"description": "Resets the music state",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Reset music state",
|
"summary": "Reset music state",
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK"
|
||||||
},
|
},
|
||||||
"423": {
|
"423": {
|
||||||
"description": "Syncing is in progress",
|
"description": "Syncing is in progress",
|
||||||
@@ -1205,11 +1189,8 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/sync": {
|
"/sync": {
|
||||||
"get": {
|
"post": {
|
||||||
"description": "Starts syncing soundtracks with only new changes",
|
"description": "Starts syncing soundtracks that have changed",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1234,11 +1215,8 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/sync/full": {
|
"/sync/full": {
|
||||||
"get": {
|
"post": {
|
||||||
"description": "Starts a full sync of all soundtracks",
|
"description": "Starts a full sync of all soundtracks",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1264,10 +1242,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/sync/progress": {
|
"/sync/progress": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the current sync progress or result",
|
"description": "Returns the progress of the running sync, or the result of the last sync",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1279,19 +1254,15 @@ var doc = `{
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/backend.SyncResponse"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/sync/reset": {
|
"/sync/reset": {
|
||||||
"get": {
|
"post": {
|
||||||
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
|
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1318,9 +1289,6 @@ var doc = `{
|
|||||||
"/version": {
|
"/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get latest version info",
|
"description": "get latest version info",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1334,12 +1302,6 @@ var doc = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/backend.VersionData"
|
"$ref": "#/definitions/backend.VersionData"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1347,9 +1309,6 @@ var doc = `{
|
|||||||
"/version/history": {
|
"/version/history": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get version history",
|
"description": "get version history",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1366,18 +1325,35 @@ var doc = `{
|
|||||||
"$ref": "#/definitions/backend.VersionData"
|
"$ref": "#/definitions/backend.VersionData"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"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": {
|
"backend.SongInfoForStats": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"backend.VersionData": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1477,7 +1497,7 @@ var doc = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"description": "Required: API password",
|
"description": "API password, required when API_PASSWORD is set",
|
||||||
"type": "string"
|
"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
|
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||||
var SwaggerInfo = swaggerInfo{
|
var SwaggerInfo = &swag.Spec{
|
||||||
Version: "",
|
Version: "1.0",
|
||||||
Host: "",
|
Host: "",
|
||||||
BasePath: "",
|
BasePath: "/",
|
||||||
Schemes: []string{},
|
Schemes: []string{},
|
||||||
Title: "",
|
Title: "MusicServer API",
|
||||||
Description: "",
|
Description: "API for the MusicServer application",
|
||||||
}
|
InfoInstanceName: "swagger",
|
||||||
|
SwaggerTemplate: docTemplate,
|
||||||
type s struct{}
|
LeftDelim: "{{",
|
||||||
|
RightDelim: "}}",
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
swag.Register("swagger", &s{})
|
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||||
}
|
}
|
||||||
+193
-161
@@ -1,15 +1,24 @@
|
|||||||
{
|
{
|
||||||
"swagger": "2.0",
|
"swagger": "2.0",
|
||||||
"info": {
|
"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": {
|
"paths": {
|
||||||
"/api/v1/statistics/songs/least-played": {
|
"/api/v1/statistics/songs/least-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the top N least played songs with their soundtrack info",
|
"description": "Returns the top N least played songs with their soundtrack info",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -20,7 +29,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -59,9 +68,6 @@
|
|||||||
"/api/v1/statistics/songs/most-played": {
|
"/api/v1/statistics/songs/most-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the top N most played songs with their soundtrack info",
|
"description": "Returns the top N most played songs with their soundtrack info",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -72,7 +78,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -110,10 +116,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/statistics/soundtracks/last-played": {
|
"/api/v1/statistics/soundtracks/last-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the most recently played soundtracks",
|
"description": "Returns the most recently played soundtracks with their songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -124,7 +127,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -163,9 +166,6 @@
|
|||||||
"/api/v1/statistics/soundtracks/least-played": {
|
"/api/v1/statistics/soundtracks/least-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the top N least played soundtracks with their songs",
|
"description": "Returns the top N least played soundtracks with their songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -215,9 +215,6 @@
|
|||||||
"/api/v1/statistics/soundtracks/most-played": {
|
"/api/v1/statistics/soundtracks/most-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the top N most played soundtracks with their songs",
|
"description": "Returns the top N most played soundtracks with their songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -228,7 +225,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -266,10 +263,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/statistics/soundtracks/never-played": {
|
"/api/v1/statistics/soundtracks/never-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns all soundtracks that have never been played (times_played = 0)",
|
"description": "Returns all soundtracks that have never been played",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -301,10 +295,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/statistics/soundtracks/oldest-played": {
|
"/api/v1/statistics/soundtracks/oldest-played": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the least recently played soundtracks (that have been played at least once)",
|
"description": "Returns the least recently played soundtracks with their songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -315,7 +306,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Number of results (default: 10)",
|
"description": "Number of results (default: 10, max: 100)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -353,10 +344,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/statistics/summary": {
|
"/api/v1/statistics/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns overall statistics about the music library",
|
"description": "Returns overall play statistics",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -385,7 +373,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/token": {
|
"/api/v1/token": {
|
||||||
"post": {
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -432,6 +420,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error",
|
"description": "Internal Server Error",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -445,9 +442,6 @@
|
|||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"description": "Deletes the current session token",
|
"description": "Deletes the current session token",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -498,9 +492,6 @@
|
|||||||
"/api/v1/token/cleanup": {
|
"/api/v1/token/cleanup": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Removes all expired session tokens from the database",
|
"description": "Removes all expired session tokens from the database",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -522,7 +513,9 @@
|
|||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
@@ -549,11 +542,9 @@
|
|||||||
"/character": {
|
"/character": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the image for a specific character",
|
"description": "Returns the image for a specific character",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/png"
|
"image/png",
|
||||||
|
"image/jpeg"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"characters"
|
"characters"
|
||||||
@@ -562,7 +553,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Character name",
|
"description": "Character file name",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -574,6 +565,18 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "invalid name",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,9 +584,6 @@
|
|||||||
"/characters": {
|
"/characters": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all available characters",
|
"description": "Returns a list of all available characters",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -606,10 +606,7 @@
|
|||||||
},
|
},
|
||||||
"/download": {
|
"/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Checks for the latest version of the application",
|
"description": "Returns the latest version of the client application",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -623,6 +620,12 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "upstream service unavailable",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,9 +633,6 @@
|
|||||||
"/download/linux": {
|
"/download/linux": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Redirects to download the latest Linux version",
|
"description": "Redirects to download the latest Linux version",
|
||||||
"produces": [
|
|
||||||
"application/octet-stream"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"download"
|
"download"
|
||||||
],
|
],
|
||||||
@@ -643,6 +643,18 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "upstream service unavailable",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,9 +662,6 @@
|
|||||||
"/download/list": {
|
"/download/list": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Lists all assets available for the latest version",
|
"description": "Lists all assets available for the latest version",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -669,6 +678,12 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "upstream service unavailable",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -676,9 +691,6 @@
|
|||||||
"/download/windows": {
|
"/download/windows": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Redirects to download the latest Windows version",
|
"description": "Redirects to download the latest Windows version",
|
||||||
"produces": [
|
|
||||||
"application/octet-stream"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"download"
|
"download"
|
||||||
],
|
],
|
||||||
@@ -689,6 +701,18 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "upstream service unavailable",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,9 +720,6 @@
|
|||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the health status of the server",
|
"description": "Returns the health status of the server",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -710,7 +731,10 @@
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,10 +742,7 @@
|
|||||||
},
|
},
|
||||||
"/music": {
|
"/music": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a specific song by name",
|
"description": "Returns the song at the given position in the queue",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"audio/mpeg"
|
"audio/mpeg"
|
||||||
],
|
],
|
||||||
@@ -731,8 +752,8 @@
|
|||||||
"summary": "Get a specific song",
|
"summary": "Get a specific song",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "integer",
|
||||||
"description": "Song name",
|
"description": "Song number in the queue",
|
||||||
"name": "song",
|
"name": "song",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -745,8 +766,14 @@
|
|||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"206": {
|
||||||
|
"description": "Partial Content",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "song can't be empty",
|
"description": "song must be a number",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -768,17 +795,14 @@
|
|||||||
},
|
},
|
||||||
"/music/addPlayed": {
|
"/music/addPlayed": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Adds the latest song to the played list",
|
"description": "Marks the current song as played",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Add latest to played",
|
"summary": "Add latest to played",
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK"
|
||||||
},
|
},
|
||||||
"423": {
|
"423": {
|
||||||
"description": "Syncing is in progress",
|
"description": "Syncing is in progress",
|
||||||
@@ -792,16 +816,13 @@
|
|||||||
"/music/addQue": {
|
"/music/addQue": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Adds the latest song to the queue",
|
"description": "Adds the latest song to the queue",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Add latest to queue",
|
"summary": "Add latest to queue",
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK"
|
||||||
},
|
},
|
||||||
"423": {
|
"423": {
|
||||||
"description": "Syncing is in progress",
|
"description": "Syncing is in progress",
|
||||||
@@ -815,9 +836,6 @@
|
|||||||
"/music/all/order": {
|
"/music/all/order": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all soundtracks in order",
|
"description": "Returns a list of all soundtracks in order",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -831,8 +849,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "string"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -848,9 +865,6 @@
|
|||||||
"/music/all/random": {
|
"/music/all/random": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all soundtracks in random order",
|
"description": "Returns a list of all soundtracks in random order",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -864,8 +878,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "string"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -881,9 +894,6 @@
|
|||||||
"/music/info": {
|
"/music/info": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns information about the current song",
|
"description": "Returns information about the current song",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -895,8 +905,7 @@
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/backend.SongInfo"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -905,9 +914,6 @@
|
|||||||
"/music/list": {
|
"/music/list": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of played songs",
|
"description": "Returns a list of played songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -921,8 +927,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/backend.SongInfo"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -963,13 +968,7 @@
|
|||||||
},
|
},
|
||||||
"/music/played": {
|
"/music/played": {
|
||||||
"put": {
|
"put": {
|
||||||
"description": "Marks a song as played by its ID",
|
"description": "Marks the song at the given queue position as played",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
@@ -977,15 +976,15 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Song ID",
|
"description": "Song number in the queue",
|
||||||
"name": "song",
|
"name": "song",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": true
|
"required": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK"
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request",
|
"description": "Bad Request",
|
||||||
@@ -1068,7 +1067,7 @@
|
|||||||
},
|
},
|
||||||
"/music/rand/classic": {
|
"/music/rand/classic": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a random song from the classic selection",
|
"description": "Returns a random song where every song has the same chance",
|
||||||
"produces": [
|
"produces": [
|
||||||
"audio/mpeg"
|
"audio/mpeg"
|
||||||
],
|
],
|
||||||
@@ -1100,7 +1099,7 @@
|
|||||||
},
|
},
|
||||||
"/music/rand/low": {
|
"/music/rand/low": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a random song with low chance selection",
|
"description": "Returns a random song, favouring soundtracks that have been played less",
|
||||||
"produces": [
|
"produces": [
|
||||||
"audio/mpeg"
|
"audio/mpeg"
|
||||||
],
|
],
|
||||||
@@ -1133,16 +1132,13 @@
|
|||||||
"/music/reset": {
|
"/music/reset": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Resets the music state",
|
"description": "Resets the music state",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Reset music state",
|
"summary": "Reset music state",
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": ""
|
"description": "OK"
|
||||||
},
|
},
|
||||||
"423": {
|
"423": {
|
||||||
"description": "Syncing is in progress",
|
"description": "Syncing is in progress",
|
||||||
@@ -1186,11 +1182,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/sync": {
|
"/sync": {
|
||||||
"get": {
|
"post": {
|
||||||
"description": "Starts syncing soundtracks with only new changes",
|
"description": "Starts syncing soundtracks that have changed",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1215,11 +1208,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/sync/full": {
|
"/sync/full": {
|
||||||
"get": {
|
"post": {
|
||||||
"description": "Starts a full sync of all soundtracks",
|
"description": "Starts a full sync of all soundtracks",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1245,10 +1235,7 @@
|
|||||||
},
|
},
|
||||||
"/sync/progress": {
|
"/sync/progress": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the current sync progress or result",
|
"description": "Returns the progress of the running sync, or the result of the last sync",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1260,19 +1247,15 @@
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/backend.SyncResponse"
|
||||||
"additionalProperties": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/sync/reset": {
|
"/sync/reset": {
|
||||||
"get": {
|
"post": {
|
||||||
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
|
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1299,9 +1282,6 @@
|
|||||||
"/version": {
|
"/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get latest version info",
|
"description": "get latest version info",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1315,12 +1295,6 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/backend.VersionData"
|
"$ref": "#/definitions/backend.VersionData"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1328,9 +1302,6 @@
|
|||||||
"/version/history": {
|
"/version/history": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get version history",
|
"description": "get version history",
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1347,18 +1318,35 @@
|
|||||||
"$ref": "#/definitions/backend.VersionData"
|
"$ref": "#/definitions/backend.VersionData"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"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": {
|
"backend.SongInfoForStats": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"backend.VersionData": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1458,7 +1490,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"description": "Required: API password",
|
"description": "API password, required when API_PASSWORD is set",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-126
@@ -1,4 +1,20 @@
|
|||||||
|
basePath: /
|
||||||
definitions:
|
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:
|
backend.SongInfoForStats:
|
||||||
properties:
|
properties:
|
||||||
file_name:
|
file_name:
|
||||||
@@ -46,6 +62,35 @@ definitions:
|
|||||||
total_soundtracks:
|
total_soundtracks:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
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:
|
backend.VersionData:
|
||||||
properties:
|
properties:
|
||||||
changelog:
|
changelog:
|
||||||
@@ -65,7 +110,7 @@ definitions:
|
|||||||
description: 'Optional: "web", "mobile", "api"'
|
description: 'Optional: "web", "mobile", "api"'
|
||||||
type: string
|
type: string
|
||||||
password:
|
password:
|
||||||
description: 'Required: API password'
|
description: API password, required when API_PASSWORD is set
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
server.TokenResponse:
|
server.TokenResponse:
|
||||||
@@ -78,15 +123,22 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
info:
|
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:
|
paths:
|
||||||
/api/v1/statistics/songs/least-played:
|
/api/v1/statistics/songs/least-played:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns the top N least played songs with their soundtrack info
|
description: Returns the top N least played songs with their soundtrack info
|
||||||
parameters:
|
parameters:
|
||||||
- description: 'Number of results (default: 10)'
|
- description: 'Number of results (default: 10, max: 100)'
|
||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
@@ -116,11 +168,9 @@ paths:
|
|||||||
- statistics
|
- statistics
|
||||||
/api/v1/statistics/songs/most-played:
|
/api/v1/statistics/songs/most-played:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns the top N most played songs with their soundtrack info
|
description: Returns the top N most played songs with their soundtrack info
|
||||||
parameters:
|
parameters:
|
||||||
- description: 'Number of results (default: 10)'
|
- description: 'Number of results (default: 10, max: 100)'
|
||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
@@ -150,11 +200,9 @@ paths:
|
|||||||
- statistics
|
- statistics
|
||||||
/api/v1/statistics/soundtracks/last-played:
|
/api/v1/statistics/soundtracks/last-played:
|
||||||
get:
|
get:
|
||||||
consumes:
|
description: Returns the most recently played soundtracks with their songs
|
||||||
- application/json
|
|
||||||
description: Returns the most recently played soundtracks
|
|
||||||
parameters:
|
parameters:
|
||||||
- description: 'Number of results (default: 10)'
|
- description: 'Number of results (default: 10, max: 100)'
|
||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
@@ -184,11 +232,9 @@ paths:
|
|||||||
- statistics
|
- statistics
|
||||||
/api/v1/statistics/soundtracks/least-played:
|
/api/v1/statistics/soundtracks/least-played:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns the top N least played soundtracks with their songs
|
description: Returns the top N least played soundtracks with their songs
|
||||||
parameters:
|
parameters:
|
||||||
- description: 'Number of results (default: 10)'
|
- description: 'Number of results (default: 10, max: 100)'
|
||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
@@ -218,11 +264,9 @@ paths:
|
|||||||
- statistics
|
- statistics
|
||||||
/api/v1/statistics/soundtracks/most-played:
|
/api/v1/statistics/soundtracks/most-played:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns the top N most played soundtracks with their songs
|
description: Returns the top N most played soundtracks with their songs
|
||||||
parameters:
|
parameters:
|
||||||
- description: 'Number of results (default: 10)'
|
- description: 'Number of results (default: 10, max: 100)'
|
||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
@@ -252,10 +296,7 @@ paths:
|
|||||||
- statistics
|
- statistics
|
||||||
/api/v1/statistics/soundtracks/never-played:
|
/api/v1/statistics/soundtracks/never-played:
|
||||||
get:
|
get:
|
||||||
consumes:
|
description: Returns all soundtracks that have never been played
|
||||||
- application/json
|
|
||||||
description: Returns all soundtracks that have never been played (times_played
|
|
||||||
= 0)
|
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -276,12 +317,9 @@ paths:
|
|||||||
- statistics
|
- statistics
|
||||||
/api/v1/statistics/soundtracks/oldest-played:
|
/api/v1/statistics/soundtracks/oldest-played:
|
||||||
get:
|
get:
|
||||||
consumes:
|
description: Returns the least recently played soundtracks with their songs
|
||||||
- application/json
|
|
||||||
description: Returns the least recently played soundtracks (that have been played
|
|
||||||
at least once)
|
|
||||||
parameters:
|
parameters:
|
||||||
- description: 'Number of results (default: 10)'
|
- description: 'Number of results (default: 10, max: 100)'
|
||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
@@ -311,9 +349,7 @@ paths:
|
|||||||
- statistics
|
- statistics
|
||||||
/api/v1/statistics/summary:
|
/api/v1/statistics/summary:
|
||||||
get:
|
get:
|
||||||
consumes:
|
description: Returns overall play statistics
|
||||||
- application/json
|
|
||||||
description: Returns overall statistics about the music library
|
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -332,8 +368,6 @@ paths:
|
|||||||
- statistics
|
- statistics
|
||||||
/api/v1/token:
|
/api/v1/token:
|
||||||
delete:
|
delete:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Deletes the current session token
|
description: Deletes the current session token
|
||||||
parameters:
|
parameters:
|
||||||
- description: Bearer token
|
- description: Bearer token
|
||||||
@@ -368,8 +402,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Returns a new session token for API access. Requires valid API
|
description: Returns a new session token for API access. Requires the API password
|
||||||
password.
|
when one is configured.
|
||||||
parameters:
|
parameters:
|
||||||
- description: Password and client type
|
- description: Password and client type
|
||||||
in: body
|
in: body
|
||||||
@@ -396,6 +430,12 @@ paths:
|
|||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
"429":
|
||||||
|
description: Too Many Requests
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
"500":
|
"500":
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
schema:
|
schema:
|
||||||
@@ -407,8 +447,6 @@ paths:
|
|||||||
- auth
|
- auth
|
||||||
/api/v1/token/cleanup:
|
/api/v1/token/cleanup:
|
||||||
post:
|
post:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Removes all expired session tokens from the database
|
description: Removes all expired session tokens from the database
|
||||||
parameters:
|
parameters:
|
||||||
- description: Bearer token
|
- description: Bearer token
|
||||||
@@ -422,7 +460,8 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
additionalProperties: true
|
additionalProperties:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
"401":
|
"401":
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
@@ -441,29 +480,34 @@ paths:
|
|||||||
- auth
|
- auth
|
||||||
/character:
|
/character:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns the image for a specific character
|
description: Returns the image for a specific character
|
||||||
parameters:
|
parameters:
|
||||||
- description: Character name
|
- description: Character file name
|
||||||
in: query
|
in: query
|
||||||
name: name
|
name: name
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- image/png
|
- image/png
|
||||||
|
- image/jpeg
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: file
|
type: file
|
||||||
|
"400":
|
||||||
|
description: invalid name
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: not found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
summary: Get character image
|
summary: Get character image
|
||||||
tags:
|
tags:
|
||||||
- characters
|
- characters
|
||||||
/characters:
|
/characters:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns a list of all available characters
|
description: Returns a list of all available characters
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -479,9 +523,7 @@ paths:
|
|||||||
- characters
|
- characters
|
||||||
/download:
|
/download:
|
||||||
get:
|
get:
|
||||||
consumes:
|
description: Returns the latest version of the client application
|
||||||
- application/json
|
|
||||||
description: Checks for the latest version of the application
|
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -489,26 +531,34 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
"502":
|
||||||
|
description: upstream service unavailable
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
summary: Check for latest version
|
summary: Check for latest version
|
||||||
tags:
|
tags:
|
||||||
- download
|
- download
|
||||||
/download/linux:
|
/download/linux:
|
||||||
get:
|
get:
|
||||||
description: Redirects to download the latest Linux version
|
description: Redirects to download the latest Linux version
|
||||||
produces:
|
|
||||||
- application/octet-stream
|
|
||||||
responses:
|
responses:
|
||||||
"302":
|
"302":
|
||||||
description: Found
|
description: Found
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
"404":
|
||||||
|
description: not found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"502":
|
||||||
|
description: upstream service unavailable
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
summary: Download latest Linux version
|
summary: Download latest Linux version
|
||||||
tags:
|
tags:
|
||||||
- download
|
- download
|
||||||
/download/list:
|
/download/list:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Lists all assets available for the latest version
|
description: Lists all assets available for the latest version
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -519,26 +569,34 @@ paths:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
|
"502":
|
||||||
|
description: upstream service unavailable
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
summary: List assets of latest version
|
summary: List assets of latest version
|
||||||
tags:
|
tags:
|
||||||
- download
|
- download
|
||||||
/download/windows:
|
/download/windows:
|
||||||
get:
|
get:
|
||||||
description: Redirects to download the latest Windows version
|
description: Redirects to download the latest Windows version
|
||||||
produces:
|
|
||||||
- application/octet-stream
|
|
||||||
responses:
|
responses:
|
||||||
"302":
|
"302":
|
||||||
description: Found
|
description: Found
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
"404":
|
||||||
|
description: not found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"502":
|
||||||
|
description: upstream service unavailable
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
summary: Download latest Windows version
|
summary: Download latest Windows version
|
||||||
tags:
|
tags:
|
||||||
- download
|
- download
|
||||||
/health:
|
/health:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns the health status of the server
|
description: Returns the health status of the server
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -546,21 +604,21 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: string
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: Check server health
|
summary: Check server health
|
||||||
tags:
|
tags:
|
||||||
- health
|
- health
|
||||||
/music:
|
/music:
|
||||||
get:
|
get:
|
||||||
consumes:
|
description: Returns the song at the given position in the queue
|
||||||
- application/json
|
|
||||||
description: Returns a specific song by name
|
|
||||||
parameters:
|
parameters:
|
||||||
- description: Song name
|
- description: Song number in the queue
|
||||||
in: query
|
in: query
|
||||||
name: song
|
name: song
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: integer
|
||||||
produces:
|
produces:
|
||||||
- audio/mpeg
|
- audio/mpeg
|
||||||
responses:
|
responses:
|
||||||
@@ -568,8 +626,12 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: file
|
type: file
|
||||||
|
"206":
|
||||||
|
description: Partial Content
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
"400":
|
"400":
|
||||||
description: song can't be empty
|
description: song must be a number
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
"404":
|
"404":
|
||||||
@@ -585,12 +647,10 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/addPlayed:
|
/music/addPlayed:
|
||||||
get:
|
get:
|
||||||
consumes:
|
description: Marks the current song as played
|
||||||
- application/json
|
|
||||||
description: Adds the latest song to the played list
|
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"200":
|
||||||
description: ""
|
description: OK
|
||||||
"423":
|
"423":
|
||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
schema:
|
schema:
|
||||||
@@ -600,12 +660,10 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/addQue:
|
/music/addQue:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Adds the latest song to the queue
|
description: Adds the latest song to the queue
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"200":
|
||||||
description: ""
|
description: OK
|
||||||
"423":
|
"423":
|
||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
schema:
|
schema:
|
||||||
@@ -615,8 +673,6 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/all/order:
|
/music/all/order:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns a list of all soundtracks in order
|
description: Returns a list of all soundtracks in order
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -625,8 +681,7 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
items:
|
items:
|
||||||
additionalProperties: true
|
type: string
|
||||||
type: object
|
|
||||||
type: array
|
type: array
|
||||||
"423":
|
"423":
|
||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
@@ -637,8 +692,6 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/all/random:
|
/music/all/random:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns a list of all soundtracks in random order
|
description: Returns a list of all soundtracks in random order
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -647,8 +700,7 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
items:
|
items:
|
||||||
additionalProperties: true
|
type: string
|
||||||
type: object
|
|
||||||
type: array
|
type: array
|
||||||
"423":
|
"423":
|
||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
@@ -659,8 +711,6 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/info:
|
/music/info:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns information about the current song
|
description: Returns information about the current song
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -668,15 +718,12 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
additionalProperties: true
|
$ref: '#/definitions/backend.SongInfo'
|
||||||
type: object
|
|
||||||
summary: Get current song info
|
summary: Get current song info
|
||||||
tags:
|
tags:
|
||||||
- music
|
- music
|
||||||
/music/list:
|
/music/list:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Returns a list of played songs
|
description: Returns a list of played songs
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -685,8 +732,7 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
items:
|
items:
|
||||||
additionalProperties: true
|
$ref: '#/definitions/backend.SongInfo'
|
||||||
type: object
|
|
||||||
type: array
|
type: array
|
||||||
summary: Get played songs list
|
summary: Get played songs list
|
||||||
tags:
|
tags:
|
||||||
@@ -714,20 +760,16 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/played:
|
/music/played:
|
||||||
put:
|
put:
|
||||||
consumes:
|
description: Marks the song at the given queue position as played
|
||||||
- application/json
|
|
||||||
description: Marks a song as played by its ID
|
|
||||||
parameters:
|
parameters:
|
||||||
- description: Song ID
|
- description: Song number in the queue
|
||||||
in: query
|
in: query
|
||||||
name: song
|
name: song
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
produces:
|
|
||||||
- application/json
|
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"200":
|
||||||
description: ""
|
description: OK
|
||||||
"400":
|
"400":
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
schema:
|
schema:
|
||||||
@@ -783,7 +825,7 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/rand/classic:
|
/music/rand/classic:
|
||||||
get:
|
get:
|
||||||
description: Returns a random song from the classic selection
|
description: Returns a random song where every song has the same chance
|
||||||
produces:
|
produces:
|
||||||
- audio/mpeg
|
- audio/mpeg
|
||||||
responses:
|
responses:
|
||||||
@@ -804,7 +846,8 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/rand/low:
|
/music/rand/low:
|
||||||
get:
|
get:
|
||||||
description: Returns a random song with low chance selection
|
description: Returns a random song, favouring soundtracks that have been played
|
||||||
|
less
|
||||||
produces:
|
produces:
|
||||||
- audio/mpeg
|
- audio/mpeg
|
||||||
responses:
|
responses:
|
||||||
@@ -825,12 +868,10 @@ paths:
|
|||||||
- music
|
- music
|
||||||
/music/reset:
|
/music/reset:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Resets the music state
|
description: Resets the music state
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"200":
|
||||||
description: ""
|
description: OK
|
||||||
"423":
|
"423":
|
||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
schema:
|
schema:
|
||||||
@@ -860,10 +901,8 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- music
|
- music
|
||||||
/sync:
|
/sync:
|
||||||
get:
|
post:
|
||||||
consumes:
|
description: Starts syncing soundtracks that have changed
|
||||||
- application/json
|
|
||||||
description: Starts syncing soundtracks with only new changes
|
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -879,9 +918,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- sync
|
- sync
|
||||||
/sync/full:
|
/sync/full:
|
||||||
get:
|
post:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Starts a full sync of all soundtracks
|
description: Starts a full sync of all soundtracks
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -899,24 +936,20 @@ paths:
|
|||||||
- sync
|
- sync
|
||||||
/sync/progress:
|
/sync/progress:
|
||||||
get:
|
get:
|
||||||
consumes:
|
description: Returns the progress of the running sync, or the result of the
|
||||||
- application/json
|
last sync
|
||||||
description: Returns the current sync progress or result
|
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
additionalProperties: true
|
$ref: '#/definitions/backend.SyncResponse'
|
||||||
type: object
|
|
||||||
summary: Get sync progress
|
summary: Get sync progress
|
||||||
tags:
|
tags:
|
||||||
- sync
|
- sync
|
||||||
/sync/reset:
|
/sync/reset:
|
||||||
get:
|
post:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Resets the soundtracks database by deleting all soundtracks and
|
description: Resets the soundtracks database by deleting all soundtracks and
|
||||||
songs
|
songs
|
||||||
produces:
|
produces:
|
||||||
@@ -935,8 +968,6 @@ paths:
|
|||||||
- sync
|
- sync
|
||||||
/version:
|
/version:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: get latest version info
|
description: get latest version info
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -945,17 +976,11 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/backend.VersionData'
|
$ref: '#/definitions/backend.VersionData'
|
||||||
"404":
|
|
||||||
description: Not Found
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
summary: Getting the latest version of the backend
|
summary: Getting the latest version of the backend
|
||||||
tags:
|
tags:
|
||||||
- version
|
- version
|
||||||
/version/history:
|
/version/history:
|
||||||
get:
|
get:
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: get version history
|
description: get version history
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -966,10 +991,6 @@ paths:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/definitions/backend.VersionData'
|
$ref: '#/definitions/backend.VersionData'
|
||||||
type: array
|
type: array
|
||||||
"404":
|
|
||||||
description: Not Found
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
summary: Getting the version history of the backend
|
summary: Getting the version history of the backend
|
||||||
tags:
|
tags:
|
||||||
- version
|
- version
|
||||||
|
|||||||
+20
-63
@@ -2,84 +2,41 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"music-server/internal/logging"
|
|
||||||
"music-server/internal/server"
|
|
||||||
"net/http"
|
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/logging"
|
||||||
|
"music-server/internal/server"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
//
|
const shutdownTimeout = 5 * time.Second
|
||||||
// @Title Swagger Example API
|
|
||||||
// @version 0.5
|
|
||||||
// @description This is a sample server Petstore server.
|
|
||||||
// @termsOfService http://swagger.io/terms/
|
|
||||||
|
|
||||||
//
|
|
||||||
// @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() {
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
// Listen for the interrupt signal.
|
s := server.NewServerInstance()
|
||||||
<-ctx.Done()
|
|
||||||
|
|
||||||
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
|
serveErr := make(chan error, 1)
|
||||||
|
go func() { serveErr <- s.ListenAndServe() }()
|
||||||
// Close database connection
|
|
||||||
if appServer != nil && appServer.DB() != nil {
|
select {
|
||||||
appServer.DB().Close()
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The context is used to inform the server it has 5 seconds to finish
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
// the request it is currently handling
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := httpServer.Shutdown(ctx); err != nil {
|
if err := s.Shutdown(shutdownCtx); err != nil {
|
||||||
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Server forced to shutdown with error", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
logging.GetLogger().Info("Graceful shutdown complete")
|
||||||
logging.GetLogger().Info("Server exiting")
|
|
||||||
|
|
||||||
// Notify the main goroutine that the shutdown is complete
|
|
||||||
done <- true
|
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,3 @@ import "embed"
|
|||||||
|
|
||||||
//go:embed "assets"
|
//go:embed "assets"
|
||||||
var Assets embed.FS
|
var Assets embed.FS
|
||||||
|
|
||||||
//go:embed "swagger"
|
|
||||||
var Swagger embed.FS
|
|
||||||
+38
-58
@@ -1,77 +1,57 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"music-server/internal/backend"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
var soundtracks_added []string
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
|
soundtracks, err := cache.Get(r.Context())
|
||||||
err := r.ParseForm()
|
if err != nil {
|
||||||
if err != nil {
|
logging.GetLogger().Error("Failed to load search data", zap.Error(err))
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
search_term := r.FormValue("search_term")
|
results := search(r.FormValue("search_term"), soundtracks)
|
||||||
search(search_term)
|
if err := FoundSoundtracks(results).Render(r.Context(), w); err != nil {
|
||||||
results := soundtracks_added
|
logging.GetLogger().Error("Failed to render search results", zap.Error(err))
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
component := FoundSoundtracks(results)
|
}
|
||||||
err = component.Render(r.Context(), w)
|
})
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
log.Fatalf("Error rendering in FindSoundtrackWebHandler: %e", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func search(searchText string) {
|
// search returns the soundtracks matching searchText: exact matches first, then matches
|
||||||
soundtracks_added = nil
|
// ignoring spaces and special characters, then matches with the letters in order.
|
||||||
soundtrackData := backend.GetSearchCache()
|
func search(searchText string, soundtrackData []backend.SoundtrackData) []string {
|
||||||
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 {
|
|
||||||
var results []string
|
var results []string
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
add := func(match func(backend.SoundtrackData) bool) {
|
||||||
// Precompute cleaned and lowercase search terms once
|
for _, data := range soundtrackData {
|
||||||
cleanedSearchTerm := strings.ToLower(searchText)
|
if !seen[data.Original] && match(data) {
|
||||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, " ", "")
|
results = append(results, data.Original)
|
||||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "é", "e")
|
seen[data.Original] = true
|
||||||
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)
|
|
||||||
for _, data := range soundtrackData {
|
|
||||||
if !seen[data.Original] && (searchText == "" || strings.Contains(data.Lowercase, lowercaseSearchText)) {
|
|
||||||
results = append(results, data.Original)
|
|
||||||
seen[data.Original] = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2: Contains match with cleaned terms
|
lowercase := strings.ToLower(searchText)
|
||||||
for _, data := range soundtrackData {
|
cleaned := backend.CleanSearchTerm(searchText)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
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/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/labstack/echo/v5 v5.1.1
|
github.com/labstack/echo/v5 v5.1.1
|
||||||
github.com/lib/pq v1.12.3
|
github.com/lib/pq v1.12.3 // indirect
|
||||||
github.com/panjf2000/ants/v2 v2.12.0
|
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/swaggo/echo-swagger/v2 v2.0.1
|
github.com/swaggo/echo-swagger/v2 v2.0.1
|
||||||
@@ -19,25 +18,34 @@ require (
|
|||||||
go.uber.org/zap v1.28.0
|
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 (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // 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/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.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 v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 // 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/ebitengine/purego v0.10.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/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-errors/errors v1.5.1 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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/stringutils v0.26.0 // indirect
|
||||||
github.com/go-openapi/swag/typeutils 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/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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // 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/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
github.com/moby/moby/api v1.54.1 // 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/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // 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/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/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/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/sv-tools/openapi v0.4.0 // 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/swaggo/swag/v2 v2.0.0-rc5 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.0 // 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
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // 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.uber.org/multierr v1.10.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.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/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/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
@@ -97,3 +112,8 @@ require (
|
|||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // 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/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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
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/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 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
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=
|
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/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 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
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=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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/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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
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 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
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=
|
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/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 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
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 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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=
|
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/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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
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/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 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
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/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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
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=
|
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-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-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-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 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
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
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"music-server/internal/logging"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetCharacterList() []string {
|
// CharacterService serves character images from a single directory.
|
||||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
type CharacterService struct {
|
||||||
logging.GetLogger().Debug("Getting character list", zap.String("path", charactersPath))
|
root *os.Root
|
||||||
// Clean the path - remove trailing slashes and then add one for consistency
|
}
|
||||||
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
|
||||||
charactersPath += "/"
|
|
||||||
files, err := os.ReadDir(charactersPath)
|
|
||||||
if err != nil {
|
|
||||||
logging.GetLogger().Fatal("Failed to read characters directory", zap.String("path", charactersPath), zap.String("error", err.Error()))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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
|
var characters []string
|
||||||
for _, file := range files {
|
for _, entry := range entries {
|
||||||
if isImage(file) {
|
if !entry.IsDir() && isImage(entry.Name()) {
|
||||||
characters = append(characters, file.Name())
|
characters = append(characters, entry.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return characters
|
return characters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCharacter(character string) string {
|
// Open opens the character image with the given file name.
|
||||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
func (c *CharacterService) Open(name string) (*os.File, error) {
|
||||||
// Clean the path - remove trailing slashes and then add one for consistency
|
if name == "" || name != filepath.Base(name) || !isImage(name) {
|
||||||
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
return nil, ErrInvalidName
|
||||||
charactersPath += "/"
|
}
|
||||||
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath+character))
|
f, err := c.root.Open(name)
|
||||||
return charactersPath + character
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return f, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func isImage(entry os.DirEntry) bool {
|
// Close releases the characters directory.
|
||||||
return !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".jpeg") ||
|
func (c *CharacterService) Close() error {
|
||||||
strings.HasSuffix(entry.Name(), ".png"))
|
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")
|
||||||
|
)
|
||||||
+285
-247
@@ -1,16 +1,25 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxPickAttempts bounds how many broken songs are skipped before giving up.
|
||||||
|
const maxPickAttempts = 100
|
||||||
|
|
||||||
type SongInfo struct {
|
type SongInfo struct {
|
||||||
Soundtrack string `json:"Soundtrack"`
|
Soundtrack string `json:"Soundtrack"`
|
||||||
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
|
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
|
||||||
@@ -20,303 +29,332 @@ type SongInfo struct {
|
|||||||
SongNo int `json:"SongNo"`
|
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
|
// Queue state is shared by all clients and intentionally not locked.
|
||||||
|
queue []repository.Song
|
||||||
var lastFetchedNew repository.Song
|
currentSong int
|
||||||
|
lastFetched 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAllSoundtracks() []repository.Soundtrack {
|
// NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs.
|
||||||
if len(soundtracksNew) == 0 {
|
func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService {
|
||||||
initRepo()
|
return &MusicService{q: q, soundCheckPath: soundCheckPath, currentSong: -1}
|
||||||
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
|
||||||
}
|
|
||||||
return soundtracksNew
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSoundCheckSong() string {
|
func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) {
|
||||||
files, err := os.ReadDir("songs")
|
m.cacheMu.Lock()
|
||||||
if err != nil {
|
defer m.cacheMu.Unlock()
|
||||||
logging.GetLogger().Fatal("Failed to read songs directory", zap.String("error", err.Error()))
|
if len(m.soundtracks) == 0 {
|
||||||
}
|
soundtracks, err := m.q.FindAllSoundtracks(ctx)
|
||||||
fileInfo := files[rand.Intn(len(files))]
|
|
||||||
return "songs/" + fileInfo.Name()
|
|
||||||
}
|
|
||||||
|
|
||||||
func Reset() {
|
|
||||||
songQueNew = nil
|
|
||||||
currentSong = -1
|
|
||||||
initRepo()
|
|
||||||
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
return nil, err
|
||||||
logging.GetLogger().Warn("Song not found, removed from database",
|
}
|
||||||
zap.String("song", song.SongName),
|
m.soundtracks = soundtracks
|
||||||
zap.String("soundtrack", soundtrackData.SoundtrackName),
|
}
|
||||||
zap.String("filename", *song.FileName))
|
return m.soundtracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
m.lastFetched = row.Song
|
||||||
//Check if file exists and open
|
return row.Song.Path, nil
|
||||||
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
|
|
||||||
}
|
|
||||||
err = openFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
logging.GetLogger().Error("Failed to close file", zap.String("error", err.Error()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
lastFetchedNew = song
|
return "", ErrNoSongs
|
||||||
return song.Path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSongInfo() SongInfo {
|
// SongInfo returns information about the current song in the queue.
|
||||||
if songQueNew == nil {
|
func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
|
||||||
|
if !m.validIndex(m.currentSong) {
|
||||||
return SongInfo{}
|
return SongInfo{}
|
||||||
}
|
}
|
||||||
var currentSongData = songQueNew[currentSong]
|
song := m.queue[m.currentSong]
|
||||||
|
soundtrack := m.soundtrackFor(ctx, song)
|
||||||
currentSoundtrackData := getCurrentSoundtrack(currentSongData)
|
|
||||||
|
|
||||||
return SongInfo{
|
return SongInfo{
|
||||||
Soundtrack: currentSoundtrackData.SoundtrackName,
|
Soundtrack: soundtrack.SoundtrackName,
|
||||||
SoundtrackPlayed: currentSoundtrackData.TimesPlayed,
|
SoundtrackPlayed: soundtrack.TimesPlayed,
|
||||||
Song: currentSongData.SongName,
|
Song: song.SongName,
|
||||||
SongPlayed: currentSongData.TimesPlayed,
|
SongPlayed: song.TimesPlayed,
|
||||||
CurrentlyPlaying: true,
|
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
|
var songList []SongInfo
|
||||||
|
for i, song := range m.queue {
|
||||||
for i, song := range songQueNew {
|
soundtrack := m.soundtrackFor(ctx, song)
|
||||||
soundtrackData := getCurrentSoundtrack(song)
|
|
||||||
songList = append(songList, SongInfo{
|
songList = append(songList, SongInfo{
|
||||||
Soundtrack: soundtrackData.SoundtrackName,
|
Soundtrack: soundtrack.SoundtrackName,
|
||||||
SoundtrackPlayed: soundtrackData.TimesPlayed,
|
SoundtrackPlayed: soundtrack.TimesPlayed,
|
||||||
Song: song.SongName,
|
Song: song.SongName,
|
||||||
SongPlayed: song.TimesPlayed,
|
SongPlayed: song.TimesPlayed,
|
||||||
CurrentlyPlaying: i == currentSong,
|
CurrentlyPlaying: i == m.currentSong,
|
||||||
SongNo: i,
|
SongNo: i,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return songList
|
return songList
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSong(song string) string {
|
// Song jumps to the given queue position (clamped to the queue) and returns its path.
|
||||||
currentSong, _ = strconv.Atoi(song)
|
func (m *MusicService) Song(songNumber int) (string, error) {
|
||||||
if currentSong >= len(songQueNew) {
|
if len(m.queue) == 0 {
|
||||||
currentSong = len(songQueNew) - 1
|
return "", ErrQueueEmpty
|
||||||
} else if currentSong < 0 {
|
|
||||||
currentSong = 0
|
|
||||||
}
|
}
|
||||||
songData := songQueNew[currentSong]
|
m.currentSong = max(0, min(songNumber, len(m.queue)-1))
|
||||||
return songData.Path
|
return m.queue[m.currentSong].Path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllSoundtracks() []string {
|
// NextSong moves to the next song in the queue, staying on the last one at the end.
|
||||||
getAllSoundtracks()
|
func (m *MusicService) NextSong() (string, error) {
|
||||||
|
if len(m.queue) == 0 {
|
||||||
var jsonArray []string
|
return "", ErrQueueEmpty
|
||||||
for _, soundtrack := range soundtracksNew {
|
|
||||||
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
|
|
||||||
}
|
}
|
||||||
return jsonArray
|
return m.Song(m.currentSong + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllSoundtracksRandom() []string {
|
// PreviousSong moves to the previous song in the queue, staying on the first one at the start.
|
||||||
getAllSoundtracks()
|
func (m *MusicService) PreviousSong() (string, error) {
|
||||||
|
if len(m.queue) == 0 {
|
||||||
var jsonArray []string
|
return "", ErrQueueEmpty
|
||||||
for _, soundtrack := range soundtracksNew {
|
|
||||||
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
|
|
||||||
}
|
}
|
||||||
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
|
return m.Song(m.currentSong - 1)
|
||||||
return jsonArray
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetNextSong() string {
|
// SoundtrackNames returns all soundtrack names in alphabetical order.
|
||||||
if songQueNew == nil {
|
func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
|
||||||
return ""
|
soundtracks, err := m.allSoundtracks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if currentSong == len(songQueNew)-1 || currentSong == -1 {
|
names := make([]string, len(soundtracks))
|
||||||
songData := songQueNew[currentSong]
|
for i, s := range soundtracks {
|
||||||
return songData.Path
|
names[i] = s.SoundtrackName
|
||||||
} else {
|
|
||||||
currentSong = currentSong + 1
|
|
||||||
songData := songQueNew[currentSong]
|
|
||||||
return songData.Path
|
|
||||||
}
|
}
|
||||||
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPreviousSong() string {
|
// SoundtrackNamesRandom returns all soundtrack names in random order.
|
||||||
if songQueNew == nil {
|
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
|
||||||
return ""
|
names, err := m.SoundtrackNames(ctx)
|
||||||
}
|
rand.Shuffle(len(names), func(i, j int) { names[i], names[j] = names[j], names[i] })
|
||||||
if currentSong == -1 || currentSong == 0 {
|
return names, err
|
||||||
songData := songQueNew[0]
|
|
||||||
return songData.Path
|
|
||||||
} else {
|
|
||||||
currentSong = currentSong - 1
|
|
||||||
songData := songQueNew[currentSong]
|
|
||||||
return songData.Path
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSongFromList(soundtracks []repository.Soundtrack) repository.Song {
|
type weightedSoundtrack struct {
|
||||||
songFound := false
|
soundtrack repository.Soundtrack
|
||||||
var song repository.Song
|
weight int
|
||||||
for !songFound {
|
}
|
||||||
soundtrack := getRandomSoundtrack(soundtracks)
|
|
||||||
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
|
// 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 {
|
if len(songs) == 0 {
|
||||||
|
candidates = slices.Delete(candidates, i, i+1)
|
||||||
continue
|
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))
|
logging.GetLogger().Debug("Selected song", zap.String("song", song.SongName), zap.String("path", song.Path))
|
||||||
|
if !songIsPlayable(song, soundtrack.Path) {
|
||||||
//Check if file exists and open
|
if err := m.removeBrokenSong(ctx, song); err != nil {
|
||||||
openFile, err := os.Open(song.Path)
|
return repository.Song{}, err
|
||||||
if err != nil || (song.FileName != nil && soundtrack.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
}
|
||||||
//File not found
|
continue
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
err = openFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
logging.GetLogger().Error("Failed to close file", zap.String("path", song.Path), zap.String("error", err.Error()))
|
|
||||||
}
|
}
|
||||||
|
return song, nil
|
||||||
}
|
}
|
||||||
return song
|
return repository.Song{}, ErrNoSongs
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
|
func (m *MusicService) removeBrokenSong(ctx context.Context, song repository.Song) error {
|
||||||
for _, soundtrack := range soundtracksNew {
|
logging.GetLogger().Warn("Song not found, removing from database",
|
||||||
if soundtrack.ID == currentSongData.SoundtrackID {
|
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 soundtrack
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return repository.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
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -13,88 +13,71 @@ type SoundtrackData struct {
|
|||||||
Cleaned string
|
Cleaned string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// SearchCache holds precomputed search data for all soundtrack names.
|
||||||
searchCache []SoundtrackData
|
type SearchCache struct {
|
||||||
searchCacheMutex sync.Mutex
|
load func(context.Context) ([]string, error)
|
||||||
searchRegexCache = make(map[string]*regexp.Regexp)
|
|
||||||
searchRegexCacheMutex sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetSearchCache returns the precomputed search cache, initializing it if needed
|
mu sync.Mutex
|
||||||
func GetSearchCache() []SoundtrackData {
|
cache []SoundtrackData
|
||||||
searchCacheMutex.Lock()
|
|
||||||
defer searchCacheMutex.Unlock()
|
|
||||||
|
|
||||||
if len(searchCache) == 0 {
|
|
||||||
loadSearchCache()
|
|
||||||
}
|
|
||||||
return searchCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReloadSearchCache clears and reloads the search cache.
|
// NewSearchCache creates a SearchCache that gets the soundtrack names from load.
|
||||||
// This should be called after a sync operation to ensure search results are up to date.
|
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache {
|
||||||
func ReloadSearchCache() {
|
return &SearchCache{load: load}
|
||||||
searchCacheMutex.Lock()
|
|
||||||
defer searchCacheMutex.Unlock()
|
|
||||||
searchCache = nil
|
|
||||||
loadSearchCache()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadSearchCache loads the soundtrack data into the cache
|
// Get returns the precomputed search data, loading it if needed.
|
||||||
func loadSearchCache() {
|
func (s *SearchCache) Get(ctx context.Context) ([]SoundtrackData, error) {
|
||||||
soundtracks := GetAllSoundtracks()
|
s.mu.Lock()
|
||||||
searchCache = make([]SoundtrackData, len(soundtracks))
|
defer s.mu.Unlock()
|
||||||
|
if len(s.cache) == 0 {
|
||||||
for i, soundtrack := range soundtracks {
|
if err := s.reload(ctx); err != nil {
|
||||||
cleaned := cleanSearchTerm(soundtrack)
|
return nil, err
|
||||||
lowercase := strings.ToLower(soundtrack)
|
|
||||||
searchCache[i] = SoundtrackData{
|
|
||||||
Original: soundtrack,
|
|
||||||
Lowercase: lowercase,
|
|
||||||
Cleaned: cleaned,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return s.cache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanSearchTermForMatch cleans a search term for better matching
|
// Reload reloads the search data. Call it after a sync.
|
||||||
func CleanSearchTermForMatch(term string) string {
|
func (s *SearchCache) Reload(ctx context.Context) error {
|
||||||
term = strings.ReplaceAll(term, " ", "")
|
s.mu.Lock()
|
||||||
term = strings.ReplaceAll(term, "é", "e")
|
defer s.mu.Unlock()
|
||||||
term = strings.ReplaceAll(term, "+", "plus")
|
return s.reload(ctx)
|
||||||
term = strings.ReplaceAll(term, "&", "and")
|
|
||||||
term = strings.ReplaceAll(term, "'n", "and")
|
|
||||||
return strings.ToLower(term)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanSearchTerm cleans a search term for better matching (internal)
|
func (s *SearchCache) reload(ctx context.Context) error {
|
||||||
func cleanSearchTerm(term string) string {
|
soundtracks, err := s.load(ctx)
|
||||||
return CleanSearchTermForMatch(term)
|
if err != nil {
|
||||||
}
|
return err
|
||||||
|
|
||||||
// MatchRegexCached checks if a search term matches using cached compiled regex
|
|
||||||
func MatchRegexCached(searchTerm string, soundtrackName string) bool {
|
|
||||||
if searchTerm == "" {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
cache := make([]SoundtrackData, len(soundtracks))
|
||||||
// Check cache first
|
for i, soundtrack := range soundtracks {
|
||||||
searchRegexCacheMutex.Lock()
|
cache[i] = SoundtrackData{
|
||||||
re, exists := searchRegexCache[searchTerm]
|
Original: soundtrack,
|
||||||
if !exists {
|
Lowercase: strings.ToLower(soundtrack),
|
||||||
re = compileSearchRegex(searchTerm)
|
Cleaned: CleanSearchTerm(soundtrack),
|
||||||
searchRegexCache[searchTerm] = re
|
}
|
||||||
}
|
}
|
||||||
searchRegexCacheMutex.Unlock()
|
s.cache = cache
|
||||||
|
return nil
|
||||||
return re.MatchString(soundtrackName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// compileSearchRegex compiles a search term into a regex pattern
|
var searchReplacer = strings.NewReplacer(" ", "", "é", "e", "+", "plus", "&", "and", "'n", "and")
|
||||||
func compileSearchRegex(searchTerm string) *regexp.Regexp {
|
|
||||||
regText := ".*"
|
// CleanSearchTerm normalises a search term or soundtrack name for matching.
|
||||||
for _, letter := range searchTerm {
|
func CleanSearchTerm(term string) string {
|
||||||
regText += string(letter) + ".*"
|
return searchReplacer.Replace(strings.ToLower(term))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)):]
|
||||||
}
|
}
|
||||||
re, _ := regexp.Compile(regText)
|
return true
|
||||||
return re
|
|
||||||
}
|
}
|
||||||
+68
-188
@@ -1,12 +1,11 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"music-server/internal/logging"
|
"music-server/internal/db/repository"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
|
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
|
||||||
@@ -39,239 +38,120 @@ type StatisticsSummary struct {
|
|||||||
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
|
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatisticsHandler manages statistics operations
|
// StatisticsService reads play statistics.
|
||||||
type StatisticsHandler struct {
|
type StatisticsService struct {
|
||||||
// Uses the global backend repo initialized via InitBackend
|
q repository.Querier
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStatisticsHandler creates a new StatisticsHandler
|
// NewStatisticsService creates a StatisticsService.
|
||||||
func NewStatisticsHandler() *StatisticsHandler {
|
func NewStatisticsService(q repository.Querier) *StatisticsService {
|
||||||
return &StatisticsHandler{}
|
return &StatisticsService{q: q}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMostPlayedSoundtracksWithSongs returns the top N most played soundtracks with their songs
|
// soundtrackRow covers the generated rows that share the soundtrack-with-songs shape.
|
||||||
func (h *StatisticsHandler) GetMostPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
|
type soundtrackRow interface {
|
||||||
queries := BackendRepo()
|
repository.GetMostPlayedSoundtracksWithSongsRow |
|
||||||
ctx := BackendCtx()
|
repository.GetLeastPlayedSoundtracksWithSongsRow |
|
||||||
|
repository.GetLastPlayedSoundtracksRow |
|
||||||
|
repository.GetOldestPlayedSoundtracksRow
|
||||||
|
}
|
||||||
|
|
||||||
// Get raw results
|
func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) {
|
||||||
rows, err := queries.GetMostPlayedSoundtracksWithSongs(ctx, limit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to SoundtrackWithSongs
|
|
||||||
var result []SoundtrackWithSongs
|
var result []SoundtrackWithSongs
|
||||||
for _, row := range rows {
|
for _, r := range rows {
|
||||||
var songs []SongInfoForStats
|
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result = append(result, SoundtrackWithSongs{
|
result = append(result, SoundtrackWithSongs{
|
||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||||
Songs: songs,
|
Songs: parseSongs(row.Songs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeastPlayedSoundtracksWithSongs returns the top N least played soundtracks with their songs
|
type songRow interface {
|
||||||
func (h *StatisticsHandler) GetLeastPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
|
repository.GetMostPlayedSongsWithSoundtrackRow | repository.GetLeastPlayedSongsWithSoundtrackRow
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMostPlayedSongsWithSoundtrack returns the top N most played songs with their soundtrack info
|
func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
|
||||||
func (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
|
|
||||||
queries := BackendRepo()
|
|
||||||
ctx := BackendCtx()
|
|
||||||
|
|
||||||
rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []SongInfoForStats
|
var result []SongInfoForStats
|
||||||
for _, row := range rows {
|
for _, r := range rows {
|
||||||
result = append(result, SongInfoForStats{
|
result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r)))
|
||||||
SoundtrackID: row.SoundtrackID,
|
|
||||||
SoundtrackName: row.SoundtrackName,
|
|
||||||
SongName: row.SongName,
|
|
||||||
Path: row.Path,
|
|
||||||
TimesPlayed: row.TimesPlayed,
|
|
||||||
FileName: row.FileName,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeastPlayedSongsWithSoundtrack returns the top N least played songs with their soundtrack info
|
func parseSongs(data []byte) []SongInfoForStats {
|
||||||
func (h *StatisticsHandler) GetLeastPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
|
songs := []SongInfoForStats{}
|
||||||
queries := BackendRepo()
|
if data != nil {
|
||||||
ctx := BackendCtx()
|
if err := json.Unmarshal(data, &songs); err != nil {
|
||||||
|
return []SongInfoForStats{}
|
||||||
rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit)
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
return songs
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
// MostPlayedSoundtracks returns the top N most played soundtracks with their songs
|
||||||
func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
|
func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
|
||||||
queries := BackendRepo()
|
return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit))
|
||||||
ctx := BackendCtx()
|
}
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []SoundtrackWithSongs
|
var result []SoundtrackWithSongs
|
||||||
for _, row := range rows {
|
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{
|
result = append(result, SoundtrackWithSongs{
|
||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
SoundtrackLastPlayed: nil,
|
Songs: parseSongs(row.Songs),
|
||||||
Songs: songs,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLastPlayedSoundtracks returns the most recently played soundtracks
|
// MostPlayedSongs returns the top N most played songs with their soundtrack info
|
||||||
func (h *StatisticsHandler) GetLastPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
|
func (s *StatisticsService) MostPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
|
||||||
queries := BackendRepo()
|
return toSongs(s.q.GetMostPlayedSongsWithSoundtrack(ctx, limit))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
|
// LeastPlayedSongs returns the top N least played songs with their soundtrack info
|
||||||
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
|
func (s *StatisticsService) LeastPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
|
||||||
queries := BackendRepo()
|
return toSongs(s.q.GetLeastPlayedSongsWithSoundtrack(ctx, limit))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatisticsSummary returns overall statistics
|
// Summary returns overall statistics
|
||||||
func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
|
func (s *StatisticsService) Summary(ctx context.Context) (StatisticsSummary, error) {
|
||||||
queries := BackendRepo()
|
row, err := s.q.GetStatisticsSummary(ctx)
|
||||||
ctx := BackendCtx()
|
|
||||||
|
|
||||||
row, err := queries.GetStatisticsSummary(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return StatisticsSummary{}, 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(row), nil
|
||||||
}
|
}
|
||||||
+590
-518
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
|
||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
_ "github.com/lib/pq"
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
"go.uber.org/zap"
|
"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 {
|
type Database struct {
|
||||||
Pool *pgxpool.Pool
|
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
|
// NewDatabase creates a new Database instance with connection pool
|
||||||
func NewDatabase(host, port, user, password, dbname string) (*Database, error) {
|
func NewDatabase(ctx context.Context, connString string) (*Database, error) {
|
||||||
ctx := context.Background()
|
pool, err := pgxpool.New(ctx, connString)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to connect to database: %w", err)
|
return nil, fmt.Errorf("unable to connect to database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test connection
|
if err := pool.Ping(ctx); err != nil {
|
||||||
var success string
|
|
||||||
err = pool.QueryRow(ctx, "select 'Successfully connected!'").Scan(&success)
|
|
||||||
if err != nil {
|
|
||||||
pool.Close()
|
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))
|
logging.GetLogger().Info("Database connected")
|
||||||
|
return &Database{Pool: pool}, nil
|
||||||
return &Database{Pool: pool, Ctx: ctx}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the database connection pool
|
// 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.
|
// 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(ctx context.Context) map[string]string {
|
||||||
func (db *Database) Health() map[string]string {
|
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
stats := make(map[string]string)
|
if err := db.Pool.Ping(ctx); err != nil {
|
||||||
|
return map[string]string{"status": "down", "error": err.Error()}
|
||||||
// Ping the database
|
|
||||||
err := db.Pool.Ping(ctx)
|
|
||||||
if err != nil {
|
|
||||||
stats["status"] = "down"
|
|
||||||
stats["error"] = err.Error()
|
|
||||||
return stats
|
|
||||||
}
|
}
|
||||||
|
return map[string]string{"status": "up"}
|
||||||
stats["status"] = "up"
|
|
||||||
return stats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunMigrations runs all pending database migrations to the latest version.
|
// RunMigrations runs all pending database migrations to the latest version.
|
||||||
// Uses the existing pool to extract connection details.
|
|
||||||
func (db *Database) RunMigrations() error {
|
func (db *Database) RunMigrations() error {
|
||||||
// Extract connection info from pool config
|
m, closeFn, err := db.NewMigrator()
|
||||||
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)
|
|
||||||
if err != nil {
|
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 {
|
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")
|
files, err := iofs.New(MigrationsFs, "migrations")
|
||||||
if err != nil {
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
|
return m, func() { m.Close() }, nil
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||||
|
|
||||||
-- name: RemoveBrokenSongs :exec
|
-- name: RemoveBrokenSongs :exec
|
||||||
DELETE FROM song WHERE uuid = ANY($1);
|
DELETE FROM song WHERE uuid = ANY(sqlc.arg(uuids)::uuid[]);
|
||||||
|
|
||||||
-- name: GetSongsBySoundtrackUUID :many
|
-- name: GetSongsBySoundtrackUUID :many
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -50,3 +50,15 @@ WHERE soundtrack_uuid = $1;
|
|||||||
|
|
||||||
-- name: GetSongUUIDsBySoundtrackId :many
|
-- name: GetSongUUIDsBySoundtrackId :many
|
||||||
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
|
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
|
||||||
|
|
||||||
|
-- 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
|
-- name: SetSoundtrackDeletionDate :exec
|
||||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL;
|
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
|
-- name: ClearSoundtracks :exec
|
||||||
DELETE FROM soundtrack;
|
DELETE FROM soundtrack;
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type Soundtrack struct {
|
|||||||
NumberOfSongs int32 `json:"number_of_songs"`
|
NumberOfSongs int32 `json:"number_of_songs"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Uuid uuid.UUID `json:"uuid"`
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Vgmq struct {
|
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
|
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
|
const getSongByUUID = `-- name: GetSongByUUID :one
|
||||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE uuid = $1
|
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
|
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 {
|
func (q *Queries) RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error {
|
||||||
_, err := q.db.Exec(ctx, removeBrokenSongs, argUuid)
|
_, err := q.db.Exec(ctx, removeBrokenSongs, uuids)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,3 +359,27 @@ func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) error {
|
|||||||
)
|
)
|
||||||
return err
|
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
|
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
|
FROM soundtrack
|
||||||
WHERE deleted IS NULL
|
WHERE deleted IS NULL
|
||||||
ORDER BY soundtrack_name
|
ORDER BY soundtrack_name
|
||||||
@@ -57,6 +57,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
|||||||
&i.NumberOfSongs,
|
&i.NumberOfSongs,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
|
&i.Fingerprint,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
|
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
|
FROM soundtrack
|
||||||
ORDER BY soundtrack_name
|
ORDER BY soundtrack_name
|
||||||
`
|
`
|
||||||
@@ -95,6 +96,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
|
|||||||
&i.NumberOfSongs,
|
&i.NumberOfSongs,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
|
&i.Fingerprint,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -118,7 +120,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getSoundtrackById = `-- name: GetSoundtrackById :one
|
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
|
FROM soundtrack
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND deleted IS NULL
|
AND deleted IS NULL
|
||||||
@@ -139,12 +141,13 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
|
|||||||
&i.NumberOfSongs,
|
&i.NumberOfSongs,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
|
&i.Fingerprint,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
|
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
|
FROM soundtrack
|
||||||
WHERE uuid = $1
|
WHERE uuid = $1
|
||||||
AND deleted IS NULL
|
AND deleted IS NULL
|
||||||
@@ -165,6 +168,7 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
|
|||||||
&i.NumberOfSongs,
|
&i.NumberOfSongs,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
|
&i.Fingerprint,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -240,6 +244,15 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
|
|||||||
return err
|
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
|
const removeSoundtrackDeletionDate = `-- name: RemoveSoundtrackDeletionDate :exec
|
||||||
UPDATE soundtrack SET deleted=NULL WHERE id=$1
|
UPDATE soundtrack SET deleted=NULL WHERE id=$1
|
||||||
`
|
`
|
||||||
@@ -269,6 +282,20 @@ func (q *Queries) SetSoundtrackDeletionDate(ctx context.Context) error {
|
|||||||
return err
|
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
|
const updateSoundtrackHash = `-- name: UpdateSoundtrackHash :exec
|
||||||
UPDATE soundtrack SET hash=$1, last_changed=now() WHERE id=$2
|
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"music-server/internal/backend"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type CharacterHandler struct {
|
type CharacterHandler struct {
|
||||||
|
characters *backend.CharacterService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCharacterHandler() *CharacterHandler {
|
func NewCharacterHandler(characters *backend.CharacterService) *CharacterHandler {
|
||||||
return &CharacterHandler{}
|
return &CharacterHandler{characters: characters}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCharacterList godoc
|
// GetCharacterList godoc
|
||||||
// @Summary Get list of characters
|
// @Summary Get list of characters
|
||||||
// @Description Returns a list of all available characters
|
// @Description Returns a list of all available characters
|
||||||
// @Tags characters
|
// @Tags characters
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} string
|
// @Success 200 {array} string
|
||||||
// @Router /characters [get]
|
// @Router /characters [get]
|
||||||
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
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)
|
return ctx.JSON(http.StatusOK, characters)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,18 +35,17 @@ func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
|||||||
// @Summary Get character image
|
// @Summary Get character image
|
||||||
// @Description Returns the image for a specific character
|
// @Description Returns the image for a specific character
|
||||||
// @Tags characters
|
// @Tags characters
|
||||||
// @Accept json
|
|
||||||
// @Produce image/png
|
// @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
|
// @Success 200 {file} file
|
||||||
|
// @Failure 400 {string} string "invalid name"
|
||||||
|
// @Failure 404 {string} string "not found"
|
||||||
// @Router /character [get]
|
// @Router /character [get]
|
||||||
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
||||||
character := ctx.QueryParam("name")
|
f, err := c.characters.Open(ctx.QueryParam("name"))
|
||||||
characterPath := backend.GetCharacter(character)
|
|
||||||
file, err := os.Open(characterPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
return songError(err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
return serveFile(ctx, f, "")
|
||||||
return ctx.Stream(http.StatusOK, "image/png", file)
|
|
||||||
}
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
|
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RateLimiter implements a simple IP-based rate limiter
|
// RateLimit allows each client IP limit requests per window, with bursts up to limit.
|
||||||
type RateLimiter struct {
|
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc {
|
||||||
visits map[string]*Visit
|
config := echoMiddleware.DefaultRateLimiterConfig
|
||||||
mu sync.Mutex
|
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
|
||||||
limit int // requests per window
|
Rate: float64(limit) / window.Seconds(),
|
||||||
window time.Duration // time window
|
Burst: limit,
|
||||||
}
|
ExpiresIn: window,
|
||||||
|
})
|
||||||
type Visit struct {
|
config.DenyHandler = func(c *echo.Context, identifier string, err error) error {
|
||||||
count int
|
logging.GetLogger().Warn("Rate limit exceeded",
|
||||||
firstSeen time.Time
|
zap.String("ip", identifier),
|
||||||
}
|
zap.String("path", c.Request().URL.Path))
|
||||||
|
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||||
// NewRateLimiter creates a new rate limiter
|
"error": "Rate limit exceeded. Please try again later.",
|
||||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
})
|
||||||
rl := &RateLimiter{
|
|
||||||
visits: make(map[string]*Visit),
|
|
||||||
limit: limit,
|
|
||||||
window: window,
|
|
||||||
}
|
}
|
||||||
|
return echoMiddleware.RateLimiterWithConfig(config)
|
||||||
// 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 {
|
|
||||||
logging.GetLogger().Warn("Rate limit exceeded",
|
|
||||||
zap.String("ip", ip),
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"music-server/internal/backend"
|
|
||||||
"music-server/internal/logging"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncCheckMiddleware blocks requests when syncing is in progress.
|
// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress.
|
||||||
// It returns HTTP 423 Locked with a standard message.
|
func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc {
|
||||||
// This middleware should be applied to handlers that cannot execute during sync.
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
func SyncCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(c *echo.Context) error {
|
||||||
return func(c *echo.Context) error {
|
if isSyncing() {
|
||||||
if backend.Syncing {
|
logging.GetLogger().Info("Syncing is in progress - request blocked")
|
||||||
logging.GetLogger().Info("Syncing is in progress - request blocked")
|
return c.JSON(http.StatusLocked, "Syncing is in progress")
|
||||||
return c.JSON(http.StatusLocked, "Syncing is in progress")
|
}
|
||||||
|
return next(c)
|
||||||
}
|
}
|
||||||
return next(c)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,87 +1,62 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenAuthMiddleware returns an Echo middleware that validates session tokens
|
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
|
||||||
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
|
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(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
// Session auth disable bypass - allow all requests through without token validation
|
if disabled {
|
||||||
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()))
|
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract token from Authorization header
|
if c.Request().Header.Get("Authorization") == "" {
|
||||||
authHeader := c.Request().Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||||
}
|
}
|
||||||
|
token, ok := BearerToken(c)
|
||||||
// Bearer token format
|
if !ok {
|
||||||
parts := strings.Split(authHeader, " ")
|
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
|
||||||
}
|
}
|
||||||
|
|
||||||
token := parts[1]
|
ctx := c.Request().Context()
|
||||||
queries := repository.New(pool)
|
session, err := q.GetSession(ctx, token)
|
||||||
session, err := queries.GetSession(c.Request().Context(), token)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Warn("Invalid token attempt",
|
logging.GetLogger().Warn("Invalid token attempt",
|
||||||
zap.String("token", token),
|
|
||||||
zap.String("ip", c.RealIP()),
|
zap.String("ip", c.RealIP()),
|
||||||
zap.String("error", err.Error()),
|
zap.String("error", err.Error()),
|
||||||
)
|
)
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
|
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) {
|
if time.Now().After(session.ExpiresAt.Time) {
|
||||||
// Clean up expired session in background
|
// Clean up in the background with a context that outlives the request.
|
||||||
go func() {
|
go func(ctx context.Context) {
|
||||||
queries.DeleteSession(c.Request().Context(), token)
|
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"})
|
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)
|
c.Set("session", session)
|
||||||
|
|
||||||
return next(c)
|
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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MusicHandler struct {
|
type MusicHandler struct {
|
||||||
|
music *backend.MusicService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMusicHandler() *MusicHandler {
|
func NewMusicHandler(music *backend.MusicService) *MusicHandler {
|
||||||
return &MusicHandler{}
|
return &MusicHandler{music: music}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSong godoc
|
// GetSong godoc
|
||||||
// @Summary Get a specific song
|
// @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
|
// @Tags music
|
||||||
// @Accept json
|
|
||||||
// @Produce audio/mpeg
|
// @Produce audio/mpeg
|
||||||
// @Param song query string true "Song name"
|
// @Param song query int true "Song number in the queue"
|
||||||
// @Success 200 {file} file
|
// @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 404 {string} string "Not Found"
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music [get]
|
// @Router /music [get]
|
||||||
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
||||||
song := ctx.QueryParam("song")
|
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
||||||
if song == "" {
|
|
||||||
return ctx.String(http.StatusBadRequest, "song can't be empty")
|
|
||||||
}
|
|
||||||
songPath := backend.GetSong(song)
|
|
||||||
file, err := os.Open(songPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
return ctx.String(http.StatusBadRequest, "song must be a number")
|
||||||
}
|
}
|
||||||
defer file.Close()
|
path, err := m.music.Song(song)
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return serveSong(ctx, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSoundCheckSong godoc
|
// GetSoundCheckSong godoc
|
||||||
@@ -54,25 +50,21 @@ func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/soundTest [get]
|
// @Router /music/soundTest [get]
|
||||||
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
||||||
songPath := backend.GetSoundCheckSong()
|
path, err := m.music.SoundCheckSong()
|
||||||
file, err := os.Open(songPath)
|
return serveSong(ctx, path, err)
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetMusic godoc
|
// ResetMusic godoc
|
||||||
// @Summary Reset music state
|
// @Summary Reset music state
|
||||||
// @Description Resets the music state
|
// @Description Resets the music state
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
// @Success 200
|
||||||
// @Success 204
|
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/reset [get]
|
// @Router /music/reset [get]
|
||||||
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
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)
|
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"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/rand [get]
|
// @Router /music/rand [get]
|
||||||
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
||||||
songPath := backend.GetRandomSong()
|
path, err := m.music.RandomSong(ctx.Request().Context())
|
||||||
file, err := os.Open(songPath)
|
return serveSong(ctx, path, err)
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRandomSongLowChance godoc
|
// GetRandomSongLowChance godoc
|
||||||
// @Summary Get random song with low chance
|
// @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
|
// @Tags music
|
||||||
// @Produce audio/mpeg
|
// @Produce audio/mpeg
|
||||||
// @Success 200 {file} file
|
// @Success 200 {file} file
|
||||||
@@ -105,18 +92,13 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/rand/low [get]
|
// @Router /music/rand/low [get]
|
||||||
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
||||||
songPath := backend.GetRandomSongLowChance()
|
path, err := m.music.RandomSongLowChance(ctx.Request().Context())
|
||||||
file, err := os.Open(songPath)
|
return serveSong(ctx, path, err)
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRandomSongClassic godoc
|
// GetRandomSongClassic godoc
|
||||||
// @Summary Get random classic song
|
// @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
|
// @Tags music
|
||||||
// @Produce audio/mpeg
|
// @Produce audio/mpeg
|
||||||
// @Success 200 {file} file
|
// @Success 200 {file} file
|
||||||
@@ -124,39 +106,30 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/rand/classic [get]
|
// @Router /music/rand/classic [get]
|
||||||
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
|
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
|
||||||
songPath := backend.GetRandomSongClassic()
|
path, err := m.music.RandomSongClassic(ctx.Request().Context())
|
||||||
file, err := os.Open(songPath)
|
return serveSong(ctx, path, err)
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSongInfo godoc
|
// GetSongInfo godoc
|
||||||
// @Summary Get current song info
|
// @Summary Get current song info
|
||||||
// @Description Returns information about the current song
|
// @Description Returns information about the current song
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} map[string]interface{}
|
// @Success 200 {object} backend.SongInfo
|
||||||
// @Router /music/info [get]
|
// @Router /music/info [get]
|
||||||
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
|
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
|
||||||
song := backend.GetSongInfo()
|
return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context()))
|
||||||
return ctx.JSON(http.StatusOK, song)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlayedSongs godoc
|
// GetPlayedSongs godoc
|
||||||
// @Summary Get played songs list
|
// @Summary Get played songs list
|
||||||
// @Description Returns a list of played songs
|
// @Description Returns a list of played songs
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} map[string]interface{}
|
// @Success 200 {array} backend.SongInfo
|
||||||
// @Router /music/list [get]
|
// @Router /music/list [get]
|
||||||
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
||||||
songList := backend.GetPlayedSongs()
|
return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context()))
|
||||||
return ctx.JSON(http.StatusOK, songList)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNextSong godoc
|
// GetNextSong godoc
|
||||||
@@ -169,13 +142,8 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/next [get]
|
// @Router /music/next [get]
|
||||||
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
||||||
songPath := backend.GetNextSong()
|
path, err := m.music.NextSong()
|
||||||
file, err := os.Open(songPath)
|
return serveSong(ctx, path, err)
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPreviousSong godoc
|
// GetPreviousSong godoc
|
||||||
@@ -188,51 +156,48 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/previous [get]
|
// @Router /music/previous [get]
|
||||||
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
||||||
songPath := backend.GetPreviousSong()
|
path, err := m.music.PreviousSong()
|
||||||
file, err := os.Open(songPath)
|
return serveSong(ctx, path, err)
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllSoundtracks godoc
|
// GetAllSoundtracks godoc
|
||||||
// @Summary Get all soundtracks
|
// @Summary Get all soundtracks
|
||||||
// @Description Returns a list of all soundtracks in order
|
// @Description Returns a list of all soundtracks in order
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} map[string]interface{}
|
// @Success 200 {array} string
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/all/order [get]
|
// @Router /music/all/order [get]
|
||||||
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
||||||
soundtrackList := backend.GetAllSoundtracks()
|
names, err := m.music.SoundtrackNames(ctx.Request().Context())
|
||||||
return ctx.JSON(http.StatusOK, soundtrackList)
|
if err != nil {
|
||||||
|
return songError(err)
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, names)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllSoundtracksRandom godoc
|
// GetAllSoundtracksRandom godoc
|
||||||
// @Summary Get all soundtracks random
|
// @Summary Get all soundtracks random
|
||||||
// @Description Returns a list of all soundtracks in random order
|
// @Description Returns a list of all soundtracks in random order
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} map[string]interface{}
|
// @Success 200 {array} string
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/all/random [get]
|
// @Router /music/all/random [get]
|
||||||
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
||||||
soundtrackList := backend.GetAllSoundtracksRandom()
|
names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context())
|
||||||
return ctx.JSON(http.StatusOK, soundtrackList)
|
if err != nil {
|
||||||
|
return songError(err)
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, names)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutPlayed godoc
|
// PutPlayed godoc
|
||||||
// @Summary Mark song as played
|
// @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
|
// @Tags music
|
||||||
// @Accept json
|
// @Param song query int true "Song number in the queue"
|
||||||
// @Produce json
|
// @Success 200
|
||||||
// @Param song query int true "Song ID"
|
|
||||||
// @Success 204
|
|
||||||
// @Failure 400 {string} string "Bad Request"
|
// @Failure 400 {string} string "Bad Request"
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/played [put]
|
// @Router /music/played [put]
|
||||||
@@ -242,7 +207,9 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
|||||||
return ctx.JSON(http.StatusBadRequest, err.Error())
|
return ctx.JSON(http.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
|
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)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,24 +217,24 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
|||||||
// @Summary Add latest to queue
|
// @Summary Add latest to queue
|
||||||
// @Description Adds the latest song to the queue
|
// @Description Adds the latest song to the queue
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
// @Success 200
|
||||||
// @Success 204
|
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/addQue [get]
|
// @Router /music/addQue [get]
|
||||||
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
||||||
backend.AddLatestToQue()
|
m.music.AddLatestToQueue()
|
||||||
return ctx.NoContent(http.StatusOK)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLatestPlayed godoc
|
// AddLatestPlayed godoc
|
||||||
// @Summary Add latest to played
|
// @Summary Add latest to played
|
||||||
// @Description Adds the latest song to the played list
|
// @Description Marks the current song as played
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
// @Success 200
|
||||||
// @Success 204
|
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/addPlayed [get]
|
// @Router /music/addPlayed [get]
|
||||||
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
|
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)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
+120
-236
@@ -1,22 +1,23 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "music-server/cmd/docs" // registers the OpenAPI spec
|
||||||
"music-server/cmd/web"
|
"music-server/cmd/web"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"music-server/internal/server/middleware"
|
"music-server/internal/server/middleware"
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||||
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
||||||
|
"github.com/swaggo/swag"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @Title MusicServer API
|
// @title MusicServer API
|
||||||
// @version 1.0
|
// @version 1.0
|
||||||
// @description API for the MusicServer application
|
// @description API for the MusicServer application
|
||||||
// @termsOfService http://sanplex.xyz/terms/
|
// @termsOfService http://sanplex.xyz/terms/
|
||||||
@@ -27,19 +28,45 @@ import (
|
|||||||
// @license.name MIT
|
// @license.name MIT
|
||||||
// @license.url http://opensource.org/licenses/MIT
|
// @license.url http://opensource.org/licenses/MIT
|
||||||
|
|
||||||
// @host localhost:8080
|
|
||||||
// @BasePath /
|
// @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 {
|
func (s *Server) RegisterRoutes() http.Handler {
|
||||||
e := echo.New()
|
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(logging.RequestLogger())
|
||||||
e.Use(echoMiddleware.Recover())
|
e.Use(echoMiddleware.Recover())
|
||||||
|
|
||||||
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||||
AllowOrigins: []string{"https://*", "http://*"},
|
AllowOrigins: []string{"https://*", "http://*"},
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||||
@@ -48,240 +75,97 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
MaxAge: 300,
|
MaxAge: 300,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
fileServer := http.FileServer(http.FS(web.Assets))
|
e.GET("/openapi", serveOpenAPI)
|
||||||
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("/swagger/*", echoSwagger.WrapHandler)
|
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
||||||
|
|
||||||
// ============================================
|
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
|
||||||
// Legacy Endpoints (Deprecated - use /api/v1/ instead)
|
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
|
||||||
// ============================================
|
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
|
||||||
deprecatedMiddleware := middleware.DeprecationMiddleware
|
e.Static("/", s.cfg.FrontendPath)
|
||||||
|
|
||||||
health := NewHealthHandler(s.db)
|
h := s.handlers()
|
||||||
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
|
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
|
||||||
|
|
||||||
version := NewVersionHandler()
|
// Legacy endpoints (deprecated - use /api/v1/ instead)
|
||||||
e.GET("/version", deprecatedMiddleware(version.GetLatestVersion))
|
legacy := e.Group("", middleware.DeprecationMiddleware)
|
||||||
e.GET("/version/history", deprecatedMiddleware(version.GetVersionHistory))
|
registerCommon(legacy, h, syncLock)
|
||||||
|
legacy.GET("/health", h.health.HealthCheck)
|
||||||
|
legacy.GET("/characters", h.character.GetCharacterList)
|
||||||
|
|
||||||
character := NewCharacterHandler()
|
// API v1
|
||||||
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
|
|
||||||
apiV1 := e.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 := apiV1.Group("/token")
|
||||||
tokenGroup.POST("", func(c *echo.Context) error {
|
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
|
||||||
return s.tokenHandler.CreateTokenHandler(c)
|
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
|
||||||
}, 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)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Protected endpoints - require valid token
|
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
|
||||||
// Create token auth middleware with pool access
|
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
|
||||||
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
|
registerCommon(protected, h, syncLock)
|
||||||
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
|
protected.GET("/character/list", h.character.GetCharacterList)
|
||||||
|
|
||||||
// Statistics endpoints (protected)
|
stats := protected.Group("/statistics")
|
||||||
statisticsHandler := s.statisticsHandler
|
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
|
||||||
statsGroup := protectedV1.Group("/statistics")
|
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
|
||||||
statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
|
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
|
||||||
return statisticsHandler.GetMostPlayedSoundtracks(c)
|
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
|
||||||
})
|
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
|
||||||
statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
|
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
|
||||||
return statisticsHandler.GetLeastPlayedSoundtracks(c)
|
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
|
||||||
})
|
stats.GET("/summary", h.statistics.GetStatisticsSummary)
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Version endpoints (protected)
|
for _, r := range e.Router().Routes() {
|
||||||
versionGroup := protectedV1.Group("/version")
|
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
|
||||||
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") {
|
|
||||||
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return e
|
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))
|
||||||
|
}
|
||||||
+103
-95
@@ -1,125 +1,133 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/config"
|
||||||
"music-server/internal/db"
|
"music-server/internal/db"
|
||||||
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
// Database is the database connection used by the server.
|
||||||
port int
|
type Database interface {
|
||||||
db *db.Database
|
HealthChecker
|
||||||
tokenHandler *TokenHandler
|
Close()
|
||||||
statisticsHandler *StatisticsHandler
|
|
||||||
httpServer *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
type Server struct {
|
||||||
musicPath = os.Getenv("MUSIC_PATH")
|
cfg config.Config
|
||||||
charactersPath = os.Getenv("CHARACTERS_PATH")
|
db Database
|
||||||
logLevel = os.Getenv("LOG_LEVEL")
|
q repository.Querier
|
||||||
logJSON = os.Getenv("LOG_JSON") == "true"
|
music *backend.MusicService
|
||||||
)
|
sync *backend.SyncService
|
||||||
|
characters *backend.CharacterService
|
||||||
|
releases *backend.ReleaseService
|
||||||
|
stats *backend.StatisticsService
|
||||||
|
search *backend.SearchCache
|
||||||
|
httpServer *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
// NewServerInstance creates a new Server instance with all dependencies initialized.
|
// New creates a Server with all services wired up.
|
||||||
// Use this for dependency injection and proper lifecycle management.
|
func New(cfg config.Config, q repository.Querier, database Database) (*Server, error) {
|
||||||
func NewServerInstance() *Server {
|
characters, err := backend.NewCharacterService(cfg.CharactersPath)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
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
|
s := &Server{
|
||||||
if err := database.RunMigrations(); err != nil {
|
cfg: cfg,
|
||||||
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
|
db: database,
|
||||||
|
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)
|
||||||
|
|
||||||
// Initialize backend package with database pool
|
s.httpServer = &http.Server{
|
||||||
backend.InitBackend(database.Pool)
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
|
Handler: s.RegisterRoutes(),
|
||||||
// Initialize token handler with database pool
|
|
||||||
tokenHandler := NewTokenHandler(database.Pool)
|
|
||||||
|
|
||||||
// Initialize statistics handler
|
|
||||||
statisticsHandler := NewStatisticsHandler()
|
|
||||||
|
|
||||||
// Create the server instance
|
|
||||||
appServer := &Server{
|
|
||||||
port: port,
|
|
||||||
db: database,
|
|
||||||
tokenHandler: tokenHandler,
|
|
||||||
statisticsHandler: statisticsHandler,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the HTTP server
|
|
||||||
appServer.httpServer = &http.Server{
|
|
||||||
Addr: fmt.Sprintf(":%d", port),
|
|
||||||
Handler: appServer.RegisterRoutes(),
|
|
||||||
IdleTimeout: time.Minute,
|
IdleTimeout: time.Minute,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
return s, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (s *Server) HTTPServer() *http.Server {
|
||||||
return s.httpServer
|
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"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultStatsLimit = 10
|
||||||
|
maxStatsLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
// StatisticsHandler handles statistics-related HTTP requests
|
// StatisticsHandler handles statistics-related HTTP requests
|
||||||
type StatisticsHandler struct {
|
type StatisticsHandler struct {
|
||||||
statsBackend *backend.StatisticsHandler
|
stats *backend.StatisticsService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStatisticsHandler creates a new StatisticsHandler
|
// NewStatisticsHandler creates a new StatisticsHandler
|
||||||
func NewStatisticsHandler() *StatisticsHandler {
|
func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler {
|
||||||
return &StatisticsHandler{
|
return &StatisticsHandler{stats: stats}
|
||||||
statsBackend: backend.NewStatisticsHandler(),
|
}
|
||||||
|
|
||||||
|
// 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
|
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
|
||||||
// GET /api/v1/statistics/soundtracks/most-played
|
|
||||||
//
|
//
|
||||||
// @Summary Get most played soundtracks
|
// @Summary Get most played soundtracks
|
||||||
// @Description Returns the top N most played soundtracks with their songs
|
// @Description Returns the top N most played soundtracks with their songs
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
|
||||||
// @Produce 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
|
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/soundtracks/most-played [get]
|
// @Router /api/v1/statistics/soundtracks/most-played [get]
|
||||||
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
|
||||||
limit := 10 // default
|
return withLimit(ctx, "most played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||||
limitStr := ctx.QueryParam("limit")
|
return h.stats.MostPlayedSoundtracks(c.Request().Context(), 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
|
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
|
||||||
// GET /api/v1/statistics/soundtracks/least-played
|
|
||||||
//
|
//
|
||||||
// @Summary Get least played soundtracks
|
// @Summary Get least played soundtracks
|
||||||
// @Description Returns the top N least played soundtracks with their songs
|
// @Description Returns the top N least played soundtracks with their songs
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
|
||||||
// @Produce 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
|
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/soundtracks/least-played [get]
|
// @Router /api/v1/statistics/soundtracks/least-played [get]
|
||||||
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
|
||||||
limit := 10
|
return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||||
limitStr := ctx.QueryParam("limit")
|
return h.stats.LeastPlayedSoundtracks(c.Request().Context(), 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMostPlayedSongs returns top N most played songs with soundtrack info
|
// GetMostPlayedSongs returns top N most played songs
|
||||||
// GET /api/v1/statistics/songs/most-played
|
|
||||||
//
|
//
|
||||||
// @Summary Get most played songs
|
// @Summary Get most played songs
|
||||||
// @Description Returns the top N most played songs with their soundtrack info
|
// @Description Returns the top N most played songs with their soundtrack info
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
|
||||||
// @Produce 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
|
// @Success 200 {array} backend.SongInfoForStats
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/songs/most-played [get]
|
// @Router /api/v1/statistics/songs/most-played [get]
|
||||||
func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
|
||||||
limit := 10
|
return withLimit(ctx, "most played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
|
||||||
limitStr := ctx.QueryParam("limit")
|
return h.stats.MostPlayedSongs(c.Request().Context(), 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeastPlayedSongs returns top N least played songs with soundtrack info
|
// GetLeastPlayedSongs returns top N least played songs
|
||||||
// GET /api/v1/statistics/songs/least-played
|
|
||||||
//
|
//
|
||||||
// @Summary Get least played songs
|
// @Summary Get least played songs
|
||||||
// @Description Returns the top N least played songs with their soundtrack info
|
// @Description Returns the top N least played songs with their soundtrack info
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
|
||||||
// @Produce 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
|
// @Success 200 {array} backend.SongInfoForStats
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/songs/least-played [get]
|
// @Router /api/v1/statistics/songs/least-played [get]
|
||||||
func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
|
||||||
limit := 10
|
return withLimit(ctx, "least played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
|
||||||
limitStr := ctx.QueryParam("limit")
|
return h.stats.LeastPlayedSongs(c.Request().Context(), 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))
|
// GetLastPlayedSoundtracks returns the most recently played soundtracks
|
||||||
if err != nil {
|
//
|
||||||
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
|
// @Summary Get last played soundtracks
|
||||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
// @Description Returns the most recently played soundtracks with their songs
|
||||||
}
|
// @Tags statistics
|
||||||
return ctx.JSON(http.StatusOK, songs)
|
// @Produce json
|
||||||
|
// @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 {
|
||||||
|
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 the least recently played soundtracks
|
||||||
|
//
|
||||||
|
// @Summary Get oldest played soundtracks
|
||||||
|
// @Description Returns the least recently played soundtracks with their songs
|
||||||
|
// @Tags statistics
|
||||||
|
// @Produce json
|
||||||
|
// @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 {
|
||||||
|
return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||||
|
return h.stats.OldestPlayedSoundtracks(c.Request().Context(), limit)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||||
// GET /api/v1/statistics/soundtracks/never-played
|
|
||||||
//
|
//
|
||||||
// @Summary Get never played soundtracks
|
// @Summary Get never played soundtracks
|
||||||
// @Description Returns all soundtracks that have never been played (times_played = 0)
|
// @Description Returns all soundtracks that have never been played
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/soundtracks/never-played [get]
|
// @Router /api/v1/statistics/soundtracks/never-played [get]
|
||||||
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
|
||||||
soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
|
result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context())
|
||||||
if err != nil {
|
return statsResult(ctx, "never played soundtracks", result, err)
|
||||||
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
|
|
||||||
//
|
|
||||||
// @Summary Get last played soundtracks
|
|
||||||
// @Description Returns the most recently played soundtracks
|
|
||||||
// @Tags statistics
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param limit query int false "Number of results (default: 10)"
|
|
||||||
// @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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOldestPlayedSoundtracks returns least recently played soundtracks
|
|
||||||
// GET /api/v1/statistics/soundtracks/oldest-played
|
|
||||||
//
|
|
||||||
// @Summary Get oldest played soundtracks
|
|
||||||
// @Description Returns the least recently played soundtracks (that have been played at least once)
|
|
||||||
// @Tags statistics
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param limit query int false "Number of results (default: 10)"
|
|
||||||
// @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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatisticsSummary returns overall statistics
|
// GetStatisticsSummary returns overall statistics
|
||||||
// GET /api/v1/statistics/summary
|
|
||||||
//
|
//
|
||||||
// @Summary Get statistics summary
|
// @Summary Get statistics summary
|
||||||
// @Description Returns overall statistics about the music library
|
// @Description Returns overall play statistics
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} backend.StatisticsSummary
|
// @Success 200 {object} backend.StatisticsSummary
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/summary [get]
|
// @Router /api/v1/statistics/summary [get]
|
||||||
func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error {
|
||||||
summary, err := h.statsBackend.GetStatisticsSummary()
|
result, err := h.stats.Summary(ctx.Request().Context())
|
||||||
if err != nil {
|
return statsResult(ctx, "summary", result, err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
@@ -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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SyncHandler struct {
|
type SyncHandler struct {
|
||||||
|
sync *backend.SyncService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSyncHandler() *SyncHandler {
|
func NewSyncHandler(sync *backend.SyncService) *SyncHandler {
|
||||||
return &SyncHandler{}
|
return &SyncHandler{sync: sync}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncProgress godoc
|
// SyncProgress godoc
|
||||||
// @Summary Get sync progress
|
// @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
|
// @Tags sync
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} map[string]interface{}
|
// @Success 200 {object} backend.SyncResponse
|
||||||
// @Router /sync/progress [get]
|
// @Router /sync/progress [get]
|
||||||
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if s.sync.IsSyncing() {
|
||||||
logging.GetLogger().Info("Getting sync progress")
|
return ctx.JSON(http.StatusOK, s.sync.Progress())
|
||||||
response := backend.SyncProgress()
|
|
||||||
return ctx.JSON(http.StatusOK, response)
|
|
||||||
}
|
}
|
||||||
logging.GetLogger().Info("Getting sync result")
|
return ctx.JSON(http.StatusOK, s.sync.Result())
|
||||||
response := backend.SyncResult()
|
|
||||||
return ctx.JSON(http.StatusOK, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncSoundtracksNewOnlyChanges godoc
|
// SyncSoundtracksNewOnlyChanges godoc
|
||||||
// @Summary Sync soundtracks with only changes
|
// @Summary Sync soundtracks with only changes
|
||||||
// @Description Starts syncing soundtracks with only new changes
|
// @Description Starts syncing soundtracks that have changed
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {string} string "Start syncing soundtracks"
|
// @Success 200 {string} string "Start syncing soundtracks"
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /sync [get]
|
// @Router /sync [post]
|
||||||
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
||||||
logging.GetLogger().Info("Starting sync with only changes")
|
return s.start(ctx, false, "Start syncing soundtracks")
|
||||||
backend.Syncing = true
|
|
||||||
go backend.SyncSoundtracksOnlyChanges()
|
|
||||||
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncSoundtracksNewFull godoc
|
// SyncSoundtracksNewFull godoc
|
||||||
// @Summary Sync all soundtracks fully
|
// @Summary Sync all soundtracks fully
|
||||||
// @Description Starts a full sync of all soundtracks
|
// @Description Starts a full sync of all soundtracks
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {string} string "Start syncing soundtracks full"
|
// @Success 200 {string} string "Start syncing soundtracks full"
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /sync/full [get]
|
// @Router /sync/full [post]
|
||||||
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
||||||
logging.GetLogger().Info("Starting full sync")
|
return s.start(ctx, true, "Start syncing soundtracks full")
|
||||||
backend.Syncing = true
|
}
|
||||||
go backend.SyncSoundtracksFull()
|
|
||||||
return ctx.JSON(http.StatusOK, "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
|
// ResetDB godoc
|
||||||
// @Summary Reset soundtracks database
|
// @Summary Reset soundtracks database
|
||||||
// @Description Resets the soundtracks database by deleting all soundtracks and songs
|
// @Description Resets the soundtracks database by deleting all soundtracks and songs
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
|
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
|
||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /sync/reset [get]
|
// @Router /sync/reset [post]
|
||||||
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
||||||
logging.GetLogger().Info("Resetting soundtracks database")
|
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")
|
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 (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
|
"music-server/internal/server/middleware"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tokenLifetime = 24 * time.Hour
|
||||||
|
|
||||||
// TokenRequest represents a request to generate a new token
|
// TokenRequest represents a request to generate a new token
|
||||||
type TokenRequest struct {
|
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"
|
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,20 +31,19 @@ type TokenResponse struct {
|
|||||||
ClientType string `json:"client_type"`
|
ClientType string `json:"client_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenHandler contains the database pool for token operations
|
// TokenHandler creates and removes session tokens.
|
||||||
type TokenHandler struct {
|
type TokenHandler struct {
|
||||||
pool *pgxpool.Pool
|
q repository.Querier
|
||||||
|
apiPassword string
|
||||||
|
disableSessionAuth bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTokenHandler creates a new token handler with database pool
|
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
|
||||||
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
|
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
|
||||||
return &TokenHandler{
|
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
|
||||||
pool: pool,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateToken creates a new cryptographically secure token
|
func generateToken() (string, error) {
|
||||||
func (h *TokenHandler) generateToken() (string, error) {
|
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -51,149 +51,115 @@ func (h *TokenHandler) generateToken() (string, error) {
|
|||||||
return base64.URLEncoding.EncodeToString(b), nil
|
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
|
// CreateTokenHandler creates a new session token
|
||||||
// POST /api/v1/token
|
|
||||||
//
|
//
|
||||||
// @Summary Create session 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
|
// @Tags auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body TokenRequest true "Password and client type"
|
// @Param request body TokenRequest true "Password and client type"
|
||||||
// @Success 200 {object} TokenResponse
|
// @Success 200 {object} TokenResponse
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 401 {object} map[string]string
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 429 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/token [post]
|
// @Router /api/v1/token [post]
|
||||||
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||||
var req TokenRequest
|
var req TokenRequest
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session auth is disabled
|
if !h.passwordValid(req.Password) {
|
||||||
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
|
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
||||||
apiPassword := os.Getenv("API_PASSWORD")
|
zap.String("ip", c.RealIP()),
|
||||||
|
zap.String("user_agent", c.Request().UserAgent()))
|
||||||
// Password validation logic:
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
|
||||||
// - 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 {
|
|
||||||
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 == "" {
|
if req.ClientType == "" {
|
||||||
req.ClientType = "web"
|
req.ClientType = "web"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate token
|
token, err := generateToken()
|
||||||
token, err := h.generateToken()
|
|
||||||
if err != nil {
|
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"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set expiration (24 hours from now)
|
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||||
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{
|
|
||||||
Token: token,
|
Token: token,
|
||||||
IpAddress: c.RealIP(),
|
IpAddress: c.RealIP(),
|
||||||
UserAgent: c.Request().UserAgent(),
|
UserAgent: c.Request().UserAgent(),
|
||||||
ClientType: &clientType,
|
ClientType: &req.ClientType,
|
||||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
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"})
|
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,
|
Token: session.Token,
|
||||||
ExpiresAt: session.ExpiresAt.Time,
|
ExpiresAt: session.ExpiresAt.Time,
|
||||||
ClientType: *session.ClientType,
|
ClientType: clientType,
|
||||||
}
|
})
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTokenHandler invalidates a session token
|
// DeleteTokenHandler invalidates a session token
|
||||||
// DELETE /api/v1/token
|
|
||||||
//
|
//
|
||||||
// @Summary Invalidate session token
|
// @Summary Invalidate session token
|
||||||
// @Description Deletes the current session token
|
// @Description Deletes the current session token
|
||||||
// @Tags auth
|
// @Tags auth
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param Authorization header string true "Bearer token"
|
// @Param Authorization header string true "Bearer token"
|
||||||
// @Success 200 {object} map[string]string
|
// @Success 200 {object} map[string]string
|
||||||
// @Failure 401 {object} map[string]string
|
// @Failure 401 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/token [delete]
|
// @Router /api/v1/token [delete]
|
||||||
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
||||||
authHeader := c.Request().Header.Get("Authorization")
|
if c.Request().Header.Get("Authorization") == "" {
|
||||||
if authHeader == "" {
|
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||||
}
|
}
|
||||||
|
token, ok := middleware.BearerToken(c)
|
||||||
parts := strings.Split(authHeader, " ")
|
if !ok {
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
|
||||||
}
|
}
|
||||||
|
|
||||||
token := parts[1]
|
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
|
||||||
// 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()))
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupExpiredSessionsHandler removes all expired sessions
|
// CleanupExpiredSessionsHandler removes all expired sessions
|
||||||
// POST /api/v1/token/cleanup
|
|
||||||
//
|
//
|
||||||
// @Summary Cleanup expired sessions
|
// @Summary Cleanup expired sessions
|
||||||
// @Description Removes all expired session tokens from the database
|
// @Description Removes all expired session tokens from the database
|
||||||
// @Tags auth
|
// @Tags auth
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param Authorization header string true "Bearer token"
|
// @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 401 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/token/cleanup [post]
|
// @Router /api/v1/token/cleanup [post]
|
||||||
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
|
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
|
||||||
// Verify token is valid first (using existing middleware)
|
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
|
||||||
// The middleware will have already validated the token
|
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
|
||||||
|
|
||||||
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()))
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
|
||||||
}
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
|
||||||
// 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",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,12 @@ package server
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type VersionHandler struct {
|
type VersionHandler struct{}
|
||||||
}
|
|
||||||
|
|
||||||
func NewVersionHandler() *VersionHandler {
|
func NewVersionHandler() *VersionHandler {
|
||||||
return &VersionHandler{}
|
return &VersionHandler{}
|
||||||
@@ -19,17 +19,11 @@ func NewVersionHandler() *VersionHandler {
|
|||||||
// @Summary Getting the version history of the backend
|
// @Summary Getting the version history of the backend
|
||||||
// @Description get version history
|
// @Description get version history
|
||||||
// @Tags version
|
// @Tags version
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} backend.VersionData
|
// @Success 200 {array} backend.VersionData
|
||||||
// @Failure 404 {object} string
|
|
||||||
// @Router /version/history [get]
|
// @Router /version/history [get]
|
||||||
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
||||||
versionHistory := backend.GetVersionHistory()
|
return ctx.JSON(http.StatusOK, backend.GetVersionHistory())
|
||||||
if len(versionHistory) == 0 {
|
|
||||||
return ctx.JSON(http.StatusNotFound, "version not found")
|
|
||||||
}
|
|
||||||
return ctx.JSON(http.StatusOK, versionHistory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLatestVersion godoc
|
// GetLatestVersion godoc
|
||||||
@@ -37,15 +31,9 @@ func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
|||||||
// @Summary Getting the latest version of the backend
|
// @Summary Getting the latest version of the backend
|
||||||
// @Description get latest version info
|
// @Description get latest version info
|
||||||
// @Tags version
|
// @Tags version
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} backend.VersionData
|
// @Success 200 {object} backend.VersionData
|
||||||
// @Failure 404 {object} string
|
|
||||||
// @Router /version [get]
|
// @Router /version [get]
|
||||||
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
|
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
|
||||||
latestVersion := backend.GetLatestVersion()
|
return ctx.JSON(http.StatusOK, backend.GetLatestVersion())
|
||||||
if latestVersion.Version == "" {
|
|
||||||
return ctx.JSON(http.StatusNotFound, "version not found")
|
|
||||||
}
|
|
||||||
return ctx.JSON(http.StatusOK, latestVersion)
|
|
||||||
}
|
}
|
||||||
@@ -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