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:
Sansan committed 2026-10-06 15:20:14 +02:00
1 parent 75b6f0c1cf
commit 090e794460
78 files changed
+2825 -6209

No files matched your search

+201 -217
View File
@@ -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,18 +738,18 @@ var doc = `{
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "object",
"additionalProperties": {
"type": "string" "type": "string"
} }
} }
} }
} }
}
}, },
"/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)
} }
+192 -160
View File
@@ -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,18 +731,18 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "object",
"additionalProperties": {
"type": "string" "type": "string"
} }
} }
} }
} }
}
}, },
"/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"
} }
} }
+146 -125
View File
@@ -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:
additionalProperties:
type: string 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
+19 -62
View File
@@ -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()
serveErr := make(chan error, 1)
go func() { serveErr <- s.ListenAndServe() }()
select {
case err := <-serveErr:
if err != nil {
logging.GetLogger().Fatal("HTTP server error", zap.Error(err))
}
case <-ctx.Done():
stop()
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force") logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
// Close database connection
if appServer != nil && appServer.DB() != nil {
appServer.DB().Close()
} }
// The context is used to inform the server it has 5 seconds to finish 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
} }
-3
View File
@@ -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
+29 -49
View File
@@ -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 {
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm() if err := r.ParseForm(); err != nil {
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest) http.Error(w, "Bad Request", http.StatusBadRequest)
return return
} }
search_term := r.FormValue("search_term") soundtracks, err := cache.Get(r.Context())
search(search_term)
results := soundtracks_added
component := FoundSoundtracks(results)
err = component.Render(r.Context(), w)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) logging.GetLogger().Error("Failed to load search data", zap.Error(err))
log.Fatalf("Error rendering in FindSoundtrackWebHandler: %e", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
} return
} }
func search(searchText string) { results := search(r.FormValue("search_term"), soundtracks)
soundtracks_added = nil if err := FoundSoundtracks(results).Render(r.Context(), w); err != nil {
soundtrackData := backend.GetSearchCache() logging.GetLogger().Error("Failed to render search results", zap.Error(err))
results := searchWithData(searchText, soundtrackData) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
soundtracks_added = results }
})
} }
// searchWithData is a testable version of search that takes soundtrack data as a parameter // search returns the soundtracks matching searchText: exact matches first, then matches
func searchWithData(searchText string, soundtrackData []backend.SoundtrackData) []string { // ignoring spaces and special characters, then matches with the letters in order.
func search(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
cleanedSearchTerm := strings.ToLower(searchText)
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, " ", "")
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "é", "e")
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "+", "plus")
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "&", "and")
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "'n", "and")
lowercaseSearchText := strings.ToLower(searchText)
// Pass 1: Exact match (case-insensitive contains)
for _, data := range soundtrackData { for _, data := range soundtrackData {
if !seen[data.Original] && (searchText == "" || strings.Contains(data.Lowercase, lowercaseSearchText)) { if !seen[data.Original] && match(data) {
results = append(results, data.Original) results = append(results, data.Original)
seen[data.Original] = true seen[data.Original] = true
} }
} }
// Pass 2: Contains match with cleaned terms
for _, data := range soundtrackData {
if !seen[data.Original] && (cleanedSearchTerm == "" || strings.Contains(data.Cleaned, cleanedSearchTerm)) {
results = append(results, data.Original)
seen[data.Original] = true
}
} }
// Pass 3: Regex match with cleaned terms lowercase := strings.ToLower(searchText)
for _, data := range soundtrackData { cleaned := backend.CleanSearchTerm(searchText)
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
} }
-193
View File
@@ -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

-60
View File
@@ -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>
-75
View File
@@ -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>
-3
View File
@@ -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
View File
@@ -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
-3
View File
@@ -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
-4
View File
@@ -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
View File
@@ -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
-3
View File
@@ -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
View File
@@ -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
-346
View File
@@ -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."
+25 -5
View File
@@ -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
)
+33 -6
View File
@@ -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=
-87
View File
@@ -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)
}
-40
View File
@@ -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
}
+45 -25
View File
@@ -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 += "/" // NewCharacterService opens the characters directory. Files outside it can never be served.
files, err := os.ReadDir(charactersPath) func NewCharacterService(path string) (*CharacterService, error) {
root, err := os.OpenRoot(path)
if err != nil { if err != nil {
logging.GetLogger().Fatal("Failed to read characters directory", zap.String("path", charactersPath), zap.String("error", err.Error())) return nil, err
}
return &CharacterService{root: root}, nil
} }
// List returns the file names of all character images.
func (c *CharacterService) List() ([]string, error) {
entries, err := fs.ReadDir(c.root.FS(), ".")
if err != nil {
return nil, err
}
var characters []string 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
} }
-90
View File
@@ -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)
}
}
-103
View File
@@ -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 ""
}
-61
View File
@@ -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])
}
}
+16
View File
@@ -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")
)
+283 -245
View File
@@ -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()
defer m.cacheMu.Unlock()
if len(m.soundtracks) == 0 {
soundtracks, err := m.q.FindAllSoundtracks(ctx)
if err != nil { if err != nil {
logging.GetLogger().Fatal("Failed to read songs directory", zap.String("error", err.Error())) return nil, err
} }
fileInfo := files[rand.Intn(len(files))] m.soundtracks = soundtracks
return "songs/" + fileInfo.Name() }
return m.soundtracks, nil
} }
func Reset() { // Reset clears the play queue and reloads the soundtrack list.
songQueNew = nil func (m *MusicService) Reset(ctx context.Context) error {
currentSong = -1 m.queue = nil
initRepo() m.currentSong = -1
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx()) m.cacheMu.Lock()
m.soundtracks = nil
m.cacheMu.Unlock()
_, err := m.allSoundtracks(ctx)
return err
} }
func AddLatestToQue() { // SoundCheckSong returns the path of a random sound check song.
if lastFetchedNew.Path != "" { func (m *MusicService) SoundCheckSong() (string, error) {
currentSong = len(songQueNew) files, err := os.ReadDir(m.soundCheckPath)
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 "", err
logging.GetLogger().Warn("Song not found, removed from database", }
zap.String("song", song.SongName), files = slices.DeleteFunc(files, func(f os.DirEntry) bool { return f.IsDir() })
zap.String("soundtrack", soundtrackData.SoundtrackName), if len(files) == 0 {
zap.String("filename", *song.FileName)) 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() return "", ErrNoSongs
if err != nil {
logging.GetLogger().Error("Failed to close file", zap.String("error", err.Error()))
}
}
lastFetchedNew = song
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,
SongPlayed: currentSongData.TimesPlayed,
CurrentlyPlaying: true,
SongNo: currentSong,
}
}
func GetPlayedSongs() []SongInfo {
var songList []SongInfo
for i, song := range songQueNew {
soundtrackData := getCurrentSoundtrack(song)
songList = append(songList, SongInfo{
Soundtrack: soundtrackData.SoundtrackName,
SoundtrackPlayed: soundtrackData.TimesPlayed,
Song: song.SongName, Song: song.SongName,
SongPlayed: song.TimesPlayed, SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == currentSong, CurrentlyPlaying: true,
SongNo: m.currentSong,
}
}
// PlayedSongs returns all songs in the queue.
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo {
var songList []SongInfo
for i, song := range m.queue {
soundtrack := m.soundtrackFor(ctx, song)
songList = append(songList, SongInfo{
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
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) {
if err := m.removeBrokenSong(ctx, song); err != nil {
return repository.Song{}, err
}
continue
}
return song, nil
}
return repository.Song{}, ErrNoSongs
}
//Check if file exists and open func (m *MusicService) removeBrokenSong(ctx context.Context, song repository.Song) error {
openFile, err := os.Open(song.Path) logging.GetLogger().Warn("Song not found, removing from database",
if err != nil || (song.FileName != nil && soundtrack.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
//File not found
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("soundtrack", soundtrack.SoundtrackName), zap.String("path", song.Path))
zap.Any("filename", song.FileName)) return m.q.RemoveBrokenSong(ctx, repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
} else {
songFound = true
} }
err = openFile.Close() // 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 { if err != nil {
logging.GetLogger().Error("Failed to close file", zap.String("path", song.Path), zap.String("error", err.Error())) logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err))
return repository.Soundtrack{}
} }
} for _, soundtrack := range soundtracks {
return song if soundtrack.ID == song.SoundtrackID {
}
func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
for _, soundtrack := range soundtracksNew {
if soundtrack.ID == currentSongData.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))]
}
-201
View File
@@ -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)
}
}
}
+114
View File
@@ -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
}
+52 -69
View File
@@ -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 {
if err := s.reload(ctx); err != nil {
return nil, err
}
}
return s.cache, nil
}
// Reload reloads the search data. Call it after a sync.
func (s *SearchCache) Reload(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.reload(ctx)
}
func (s *SearchCache) reload(ctx context.Context) error {
soundtracks, err := s.load(ctx)
if err != nil {
return err
}
cache := make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks { for i, soundtrack := range soundtracks {
cleaned := cleanSearchTerm(soundtrack) cache[i] = SoundtrackData{
lowercase := strings.ToLower(soundtrack)
searchCache[i] = SoundtrackData{
Original: soundtrack, Original: soundtrack,
Lowercase: lowercase, Lowercase: strings.ToLower(soundtrack),
Cleaned: cleaned, Cleaned: CleanSearchTerm(soundtrack),
} }
} }
s.cache = cache
return nil
} }
// CleanSearchTermForMatch cleans a search term for better matching var searchReplacer = strings.NewReplacer(" ", "", "é", "e", "+", "plus", "&", "and", "'n", "and")
func CleanSearchTermForMatch(term string) string {
term = strings.ReplaceAll(term, " ", "") // CleanSearchTerm normalises a search term or soundtrack name for matching.
term = strings.ReplaceAll(term, "é", "e") func CleanSearchTerm(term string) string {
term = strings.ReplaceAll(term, "+", "plus") return searchReplacer.Replace(strings.ToLower(term))
term = strings.ReplaceAll(term, "&", "and")
term = strings.ReplaceAll(term, "'n", "and")
return strings.ToLower(term)
} }
// cleanSearchTerm cleans a search term for better matching (internal) // MatchesSubsequence reports whether all characters of term appear in text in the same order.
func cleanSearchTerm(term string) string { func MatchesSubsequence(term, text string) bool {
return CleanSearchTermForMatch(term) rest := text
for _, r := range term {
i := strings.IndexRune(rest, r)
if i < 0 {
return false
}
rest = rest[i+len(string(r)):]
} }
// MatchRegexCached checks if a search term matches using cached compiled regex
func MatchRegexCached(searchTerm string, soundtrackName string) bool {
if searchTerm == "" {
return true return true
} }
// Check cache first
searchRegexCacheMutex.Lock()
re, exists := searchRegexCache[searchTerm]
if !exists {
re = compileSearchRegex(searchTerm)
searchRegexCache[searchTerm] = re
}
searchRegexCacheMutex.Unlock()
return re.MatchString(soundtrackName)
}
// compileSearchRegex compiles a search term into a regex pattern
func compileSearchRegex(searchTerm string) *regexp.Regexp {
regText := ".*"
for _, letter := range searchTerm {
regText += string(letter) + ".*"
}
re, _ := regexp.Compile(regText)
return re
}
+72 -192
View File
@@ -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) func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
if err != nil { if err != nil {
return nil, err 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 (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
if err != nil {
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{}
}
}
return songs
}
rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit) // MostPlayedSoundtracks returns the top N most played soundtracks with their songs
func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit))
}
// 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 []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
func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetNeverPlayedSoundtracks(ctx)
if err != nil {
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) // LeastPlayedSongs returns the top N least played songs with their soundtrack info
func (s *StatisticsService) LeastPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
return toSongs(s.q.GetLeastPlayedSongsWithSoundtrack(ctx, limit))
}
// Summary returns overall statistics
func (s *StatisticsService) Summary(ctx context.Context) (StatisticsSummary, error) {
row, err := s.q.GetStatisticsSummary(ctx)
if err != nil { if err != nil {
return nil, err return StatisticsSummary{}, 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
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetOldestPlayedSoundtracks(ctx, limit)
if err != nil {
return nil, err
}
var result []SoundtrackWithSongs
for _, row := range rows {
var songs []SongInfoForStats
if row.Songs != nil {
if err := json.Unmarshal(row.Songs, &songs); err != nil {
songs = make([]SongInfoForStats, 0)
}
}
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
})
}
return result, nil
}
// GetStatisticsSummary returns overall statistics
func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
queries := BackendRepo()
ctx := BackendCtx()
row, err := queries.GetStatisticsSummary(ctx)
if err != nil {
return nil, err
}
return &StatisticsSummary{
TotalSoundtracks: int64(row.TotalSoundtracks),
PlayedSoundtracks: int64(row.PlayedSoundtracks),
NeverPlayedSoundtracks: int64(row.NeverPlayedSoundtracks),
TotalSoundtrackPlays: int64(row.TotalSoundtrackPlays),
AvgSoundtrackPlays: float64(row.AvgSoundtrackPlays),
MaxSoundtrackPlays: int64(row.MaxSoundtrackPlays),
MinSoundtrackPlays: int64(row.MinSoundtrackPlays),
}, nil
}
// Log helper for statistics operations
func logStatisticsError(err error, operation string) {
if err != nil {
logging.GetLogger().Error("Statistics error",
zap.String("operation", operation),
zap.String("error", err.Error()))
} }
return StatisticsSummary(row), nil
} }
+549 -477
View File
File diff suppressed because it is too large. Load diff
-204
View File
@@ -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 }
+118
View File
@@ -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
View File
@@ -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
} }
-207
View File
@@ -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
}
-251
View File
@@ -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 '';
+13 -1
View File
@@ -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;
+6
View File
@@ -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;
+1
View File
@@ -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 {
+76
View File
@@ -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)
+58 -3
View File
@@ -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
}
+31 -4
View File
@@ -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
` `
-150
View File
@@ -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)
} }
-47
View 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)
}
-71
View File
@@ -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)
}
+81
View File
@@ -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)
}
+61
View File
@@ -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")
}
}
-29
View File
@@ -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())
}
+33
View File
@@ -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()))
}
-24
View File
@@ -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"])
}
+12 -80
View File
@@ -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
firstSeen time.Time
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
visits: make(map[string]*Visit),
limit: limit,
window: window,
}
// Start cleanup goroutine
go rl.cleanup()
return rl
}
// cleanup periodically removes old entries
func (rl *RateLimiter) cleanup() {
for {
time.Sleep(rl.window)
rl.mu.Lock()
now := time.Now()
for ip, visit := range rl.visits {
if now.Sub(visit.firstSeen) > rl.window {
delete(rl.visits, ip)
}
}
rl.mu.Unlock()
}
}
// RateLimitMiddleware returns an Echo middleware for rate limiting
func RateLimitMiddleware(rl *RateLimiter) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
ip := c.RealIP()
rl.mu.Lock()
defer rl.mu.Unlock()
visit, exists := rl.visits[ip]
now := time.Now()
if !exists {
// First visit from this IP
rl.visits[ip] = &Visit{
count: 1,
firstSeen: now,
}
return next(c)
}
// Check if window has expired
if now.Sub(visit.firstSeen) > rl.window {
// Reset for new window
visit.count = 1
visit.firstSeen = now
return next(c)
}
// Check if limit exceeded
if visit.count >= rl.limit {
logging.GetLogger().Warn("Rate limit exceeded", logging.GetLogger().Warn("Rate limit exceeded",
zap.String("ip", ip), zap.String("ip", identifier),
zap.String("path", c.Request().URL.Path)) zap.String("path", c.Request().URL.Path))
return c.JSON(http.StatusTooManyRequests, map[string]string{ return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "Rate limit exceeded. Please try again later.", "error": "Rate limit exceeded. Please try again later.",
}) })
} }
return echoMiddleware.RateLimiterWithConfig(config)
// Increment count
visit.count++
return next(c)
}
}
} }
+7 -7
View File
@@ -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 backend.Syncing { if isSyncing() {
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)
} }
} }
}
+21 -46
View File
@@ -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)
} }
+119 -235
View File
@@ -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")
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)) 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))
}
+101 -93
View File
@@ -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"
) )
// Database is the database connection used by the server.
type Database interface {
HealthChecker
Close()
}
type Server struct { type Server struct {
port int cfg config.Config
db *db.Database db Database
tokenHandler *TokenHandler q repository.Querier
statisticsHandler *StatisticsHandler music *backend.MusicService
sync *backend.SyncService
characters *backend.CharacterService
releases *backend.ReleaseService
stats *backend.StatisticsService
search *backend.SearchCache
httpServer *http.Server httpServer *http.Server
} }
var ( // New creates a Server with all services wired up.
musicPath = os.Getenv("MUSIC_PATH") func New(cfg config.Config, q repository.Querier, database Database) (*Server, error) {
charactersPath = os.Getenv("CHARACTERS_PATH") characters, err := backend.NewCharacterService(cfg.CharactersPath)
logLevel = os.Getenv("LOG_LEVEL")
logJSON = os.Getenv("LOG_JSON") == "true"
)
// NewServerInstance creates a new Server instance with all dependencies initialized.
// Use this for dependency injection and proper lifecycle management.
func NewServerInstance() *Server {
// Re-read environment variables at server creation time (not at package import time)
// This allows tests to override environment variables
host := os.Getenv("DB_HOST")
dbPort := os.Getenv("DB_PORT")
dbName := os.Getenv("DB_NAME")
username := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
// Initialize logger
currentLogLevel := logLevel
if currentLogLevel == "" {
currentLogLevel = "info"
}
logging.Init(currentLogLevel, logJSON)
logger := logging.GetLogger()
port, _ := strconv.Atoi(os.Getenv("PORT"))
// Validate required environment variables
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
}
// Create database instance
database, err := db.NewDatabase(host, dbPort, username, password, dbName)
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()))
}
// Initialize backend package with database pool
backend.InitBackend(database.Pool)
// Initialize token handler with database pool
tokenHandler := NewTokenHandler(database.Pool)
// Initialize statistics handler
statisticsHandler := NewStatisticsHandler()
// Create the server instance
appServer := &Server{
port: port,
db: database, db: database,
tokenHandler: tokenHandler, q: q,
statisticsHandler: statisticsHandler, music: backend.NewMusicService(q, cfg.SoundCheckPath),
characters: characters,
releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q),
} }
s.search = backend.NewSearchCache(s.music.SoundtrackNames)
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
// Create the HTTP server s.httpServer = &http.Server{
appServer.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port),
Addr: fmt.Sprintf(":%d", port), Handler: s.RegisterRoutes(),
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()
}
-411
View File
@@ -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!")
}
+83 -170
View File
@@ -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)) // GetMostPlayedSongs returns top N most played songs
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
// 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)) // GetLeastPlayedSongs returns top N least played songs
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
// 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()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, songs)
}
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
// GET /api/v1/statistics/soundtracks/never-played
//
// @Summary Get never played soundtracks
// @Description Returns all soundtracks that have never been played (times_played = 0)
// @Tags statistics
// @Accept json
// @Produce json
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
if err != nil {
logging.GetLogger().Error("Failed to get never played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetLastPlayedSoundtracks returns most recently played soundtracks
// GET /api/v1/statistics/soundtracks/last-played
// //
// @Summary Get last played soundtracks // @Summary Get last played soundtracks
// @Description Returns the most recently played soundtracks // @Description Returns the most recently played soundtracks with their songs
// @Tags statistics // @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/last-played [get] // @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error { func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 return withLimit(ctx, "last played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
limitStr := ctx.QueryParam("limit") return h.stats.LastPlayedSoundtracks(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.GetLastPlayedSoundtracks(int32(limit)) // GetOldestPlayedSoundtracks returns the least recently played soundtracks
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 // @Summary Get oldest played soundtracks
// @Description Returns the least recently played soundtracks (that have been played at least once) // @Description Returns the least recently played soundtracks with their songs
// @Tags statistics // @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/oldest-played [get] // @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error { func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
limitStr := ctx.QueryParam("limit") return h.stats.OldestPlayedSoundtracks(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.GetOldestPlayedSoundtracks(int32(limit)) // GetNeverPlayedSoundtracks returns soundtracks that have never been played
if err != nil { //
logging.GetLogger().Error("Failed to get oldest played soundtracks", zap.String("error", err.Error())) // @Summary Get never played soundtracks
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) // @Description Returns all soundtracks that have never been played
} // @Tags statistics
return ctx.JSON(http.StatusOK, soundtracks) // @Produce json
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context())
return statsResult(ctx, "never played soundtracks", result, err)
} }
// GetStatisticsSummary returns overall statistics // 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)
} }
-184
View File
@@ -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")
} }
-289
View File
@@ -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))
}
-118
View File
@@ -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
}
+45 -79
View File
@@ -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,11 +51,17 @@ 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
@@ -63,6 +69,7 @@ func (h *TokenHandler) generateToken() (string, error) {
// @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 {
@@ -71,72 +78,51 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
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"
apiPassword := os.Getenv("API_PASSWORD")
// Password validation logic:
// - If session auth is disabled, skip validation
// - If API_PASSWORD is empty, allow any password (open access mode)
// - If API_PASSWORD is set, require exact password match
if !disableSessionAuth {
if apiPassword != "" {
// API password is set - require exact match
if req.Password != apiPassword {
logging.GetLogger().Warn("Invalid password attempt for token creation", logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()), zap.String("ip", c.RealIP()),
zap.String("user_agent", c.Request().UserAgent())) zap.String("user_agent", c.Request().UserAgent()))
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"}) 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
@@ -144,56 +130,36 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
// @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)
} }
-40
View File
@@ -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)
}
-312
View File
@@ -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)
}
+1
View File
@@ -6,6 +6,7 @@ sql:
gen: gen:
go: go:
emit_json_tags: true emit_json_tags: true
emit_interface: true
package: "repository" package: "repository"
out: "internal/db/repository" out: "internal/db/repository"
sql_package: "pgx/v5" sql_package: "pgx/v5"