From 973ebf224b040b7b3643865ebb3e3efd263c46c8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 6 Oct 2026 16:31:06 +0200 Subject: [PATCH] Generate the OpenAPI spec with Huma instead of swag The spec is now built from the same calls that register the routes, so it can't drift from the router like the swag comments did (all /api/v1 routes were missing). - /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs. /openapi and /swagger/* redirect there - Bearer token security is declared per operation; token creation and the new GET /api/v1/health are public - Legacy routes use the same handlers, are hidden from the spec and keep the Deprecation header - /sync/reset is hidden (debug only); the /sync/new, /sync/quick and /sync/new/full aliases are removed (use /sync and /sync/full) - Invalid parameters return 422 (validation); every error is {"error": "..."}; empty lists are [] instead of null - /sync/progress is documented as progress or result (oneOf) - Tests validate the spec and check real responses of every documented operation against it (kin-openapi) - Removed swag annotations, cmd/docs, echo-swagger and the swag tool --- .air.toml | 2 +- cmd/docs/docs.go | 1780 --------------------- cmd/docs/swagger.json | 1755 -------------------- cmd/docs/swagger.yaml | 1156 ------------- go.mod | 64 +- go.sum | 142 +- internal/backend/characters.go | 2 +- internal/backend/music.go | 2 +- internal/backend/statistics.go | 6 +- internal/backend/sync.go | 16 +- internal/backend/sync_test.go | 9 +- internal/server/api.go | 165 ++ internal/server/character_handler.go | 51 - internal/server/download_handler.go | 81 - internal/server/errors.go | 9 +- internal/server/files.go | 79 +- internal/server/handlers_test.go | 18 +- internal/server/health_handler.go | 33 - internal/server/library_handler.go | 134 +- internal/server/library_handler_test.go | 2 +- internal/server/middleware/deprecation.go | 18 +- internal/server/middleware/init.go | 15 +- internal/server/middleware/rate_limit.go | 21 +- internal/server/middleware/sync_check.go | 22 - internal/server/middleware/token_auth.go | 109 +- internal/server/misc_handler.go | 99 ++ internal/server/music_handler.go | 352 ++-- internal/server/openapi_test.go | 260 +++ internal/server/routes.go | 147 +- internal/server/routes_test.go | 36 +- internal/server/server.go | 2 +- internal/server/statistics_handler.go | 219 +-- internal/server/sync_handler.go | 134 +- internal/server/token_handler.go | 192 +-- internal/server/version_handler.go | 39 - internal/testutil/fakedb/fakedb.go | 34 + justfile | 7 +- 37 files changed, 1245 insertions(+), 5967 deletions(-) delete mode 100644 cmd/docs/docs.go delete mode 100644 cmd/docs/swagger.json delete mode 100644 cmd/docs/swagger.yaml create mode 100644 internal/server/api.go delete mode 100644 internal/server/character_handler.go delete mode 100644 internal/server/download_handler.go delete mode 100644 internal/server/health_handler.go delete mode 100644 internal/server/middleware/sync_check.go create mode 100644 internal/server/misc_handler.go create mode 100644 internal/server/openapi_test.go delete mode 100644 internal/server/version_handler.go diff --git a/.air.toml b/.air.toml index bfab70e..4120261 100644 --- a/.air.toml +++ b/.air.toml @@ -6,7 +6,7 @@ tmp_dir = "tmp" bin = "./tmp/main" cmd = "go build -o ./tmp/main ./cmd" delay = 1000 - exclude_dir = ["assets", "tmp", "vendor", "testdata", "swagger", "cmd/swagger"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go deleted file mode 100644 index 55a4a6b..0000000 --- a/cmd/docs/docs.go +++ /dev/null @@ -1,1780 +0,0 @@ -// Package docs Code generated by swaggo/swag. DO NOT EDIT -package docs - -import "github.com/swaggo/swag" - -const docTemplate = `{ - "schemes": {{ marshal .Schemes }}, - "swagger": "2.0", - "info": { - "description": "{{escape .Description}}", - "title": "{{.Title}}", - "termsOfService": "http://sanplex.xyz/terms/", - "contact": { - "name": "Sebastian Olsson", - "email": "zarnor91@gmail.com" - }, - "license": { - "name": "MIT", - "url": "http://opensource.org/licenses/MIT" - }, - "version": "{{.Version}}" - }, - "host": "{{.Host}}", - "basePath": "{{.BasePath}}", - "paths": { - "/api/v1/songs/{uuid}": { - "get": { - "description": "Returns information about a song", - "produces": [ - "application/json" - ], - "tags": [ - "library" - ], - "summary": "Get song", - "parameters": [ - { - "type": "string", - "description": "Song UUID", - "name": "uuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.Song" - } - }, - "400": { - "description": "invalid uuid", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/v1/songs/{uuid}/file": { - "get": { - "description": "Returns the audio file of a song. Supports Range requests.", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "library" - ], - "summary": "Stream song", - "parameters": [ - { - "type": "string", - "description": "Song UUID", - "name": "uuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "206": { - "description": "Partial Content", - "schema": { - "type": "file" - } - }, - "400": { - "description": "invalid uuid", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/v1/soundtracks": { - "get": { - "description": "Returns all soundtracks with their UUIDs, sorted by name", - "produces": [ - "application/json" - ], - "tags": [ - "library" - ], - "summary": "List soundtracks", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.Soundtrack" - } - } - } - } - } - }, - "/api/v1/soundtracks/{uuid}": { - "get": { - "description": "Returns a soundtrack with its songs", - "produces": [ - "application/json" - ], - "tags": [ - "library" - ], - "summary": "Get soundtrack", - "parameters": [ - { - "type": "string", - "description": "Soundtrack UUID", - "name": "uuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.SoundtrackWithSongList" - } - }, - "400": { - "description": "invalid uuid", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/v1/statistics/songs/least-played": { - "get": { - "description": "Returns the top N least played songs with their soundtrack info", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get least played songs", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/songs/most-played": { - "get": { - "description": "Returns the top N most played songs with their soundtrack info", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get most played songs", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/last-played": { - "get": { - "description": "Returns the most recently played soundtracks with their songs", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get last played soundtracks", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/least-played": { - "get": { - "description": "Returns the top N least played soundtracks with their songs", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get least played soundtracks", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/most-played": { - "get": { - "description": "Returns the top N most played soundtracks with their songs", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get most played soundtracks", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/never-played": { - "get": { - "description": "Returns all soundtracks that have never been played", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get never played soundtracks", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/oldest-played": { - "get": { - "description": "Returns the least recently played soundtracks with their songs", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get oldest played soundtracks", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/summary": { - "get": { - "description": "Returns overall play statistics", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get statistics summary", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.StatisticsSummary" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/token": { - "post": { - "description": "Returns a new session token for API access. Requires the API password when one is configured.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Create session token", - "parameters": [ - { - "description": "Password and client type", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/server.TokenRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.TokenResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - }, - "delete": { - "description": "Deletes the current session token", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Invalidate session token", - "parameters": [ - { - "type": "string", - "description": "Bearer token", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/token/cleanup": { - "post": { - "description": "Removes all expired session tokens from the database", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Cleanup expired sessions", - "parameters": [ - { - "type": "string", - "description": "Bearer token", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/character": { - "get": { - "description": "Returns the image for a specific character", - "produces": [ - "image/png", - "image/jpeg" - ], - "tags": [ - "characters" - ], - "summary": "Get character image", - "parameters": [ - { - "type": "string", - "description": "Character file name", - "name": "name", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "400": { - "description": "invalid name", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - } - } - } - }, - "/characters": { - "get": { - "description": "Returns a list of all available characters", - "produces": [ - "application/json" - ], - "tags": [ - "characters" - ], - "summary": "Get list of characters", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "/download": { - "get": { - "description": "Returns the latest version of the client application", - "produces": [ - "application/json" - ], - "tags": [ - "download" - ], - "summary": "Check for latest version", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "string" - } - }, - "502": { - "description": "upstream service unavailable", - "schema": { - "type": "string" - } - } - } - } - }, - "/download/linux": { - "get": { - "description": "Redirects to download the latest Linux version", - "tags": [ - "download" - ], - "summary": "Download latest Linux version", - "responses": { - "302": { - "description": "Found", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - }, - "502": { - "description": "upstream service unavailable", - "schema": { - "type": "string" - } - } - } - } - }, - "/download/list": { - "get": { - "description": "Lists all assets available for the latest version", - "produces": [ - "application/json" - ], - "tags": [ - "download" - ], - "summary": "List assets of latest version", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "502": { - "description": "upstream service unavailable", - "schema": { - "type": "string" - } - } - } - } - }, - "/download/windows": { - "get": { - "description": "Redirects to download the latest Windows version", - "tags": [ - "download" - ], - "summary": "Download latest Windows version", - "responses": { - "302": { - "description": "Found", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - }, - "502": { - "description": "upstream service unavailable", - "schema": { - "type": "string" - } - } - } - } - }, - "/health": { - "get": { - "description": "Returns the health status of the server", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "Check server health", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/music": { - "get": { - "description": "Returns the song at the given position in the queue", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get a specific song", - "parameters": [ - { - "type": "integer", - "description": "Song number in the queue", - "name": "song", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "206": { - "description": "Partial Content", - "schema": { - "type": "file" - } - }, - "400": { - "description": "song must be a number", - "schema": { - "type": "string" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/addPlayed": { - "get": { - "description": "Marks the current song as played", - "tags": [ - "music" - ], - "summary": "Add latest to played", - "responses": { - "200": { - "description": "OK" - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/addQue": { - "get": { - "description": "Adds the latest song to the queue", - "tags": [ - "music" - ], - "summary": "Add latest to queue", - "responses": { - "200": { - "description": "OK" - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/all/order": { - "get": { - "description": "Returns a list of all soundtracks in order", - "produces": [ - "application/json" - ], - "tags": [ - "music" - ], - "summary": "Get all soundtracks", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/all/random": { - "get": { - "description": "Returns a list of all soundtracks in random order", - "produces": [ - "application/json" - ], - "tags": [ - "music" - ], - "summary": "Get all soundtracks random", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/info": { - "get": { - "description": "Returns information about the current song", - "produces": [ - "application/json" - ], - "tags": [ - "music" - ], - "summary": "Get current song info", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.SongInfo" - } - } - } - } - }, - "/music/list": { - "get": { - "description": "Returns a list of played songs", - "produces": [ - "application/json" - ], - "tags": [ - "music" - ], - "summary": "Get played songs list", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfo" - } - } - } - } - } - }, - "/music/next": { - "get": { - "description": "Returns the next song in the queue", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get next song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/played": { - "put": { - "description": "Marks the song at the given queue position as played", - "tags": [ - "music" - ], - "summary": "Mark song as played", - "parameters": [ - { - "type": "integer", - "description": "Song number in the queue", - "name": "song", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/previous": { - "get": { - "description": "Returns the previous song in the queue", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get previous song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/rand": { - "get": { - "description": "Returns a random song", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get random song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/rand/classic": { - "get": { - "description": "Returns a random song where every song has the same chance", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get random classic song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/rand/low": { - "get": { - "description": "Returns a random song, favouring soundtracks that have been played less", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get random song with low chance", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/reset": { - "get": { - "description": "Resets the music state", - "tags": [ - "music" - ], - "summary": "Reset music state", - "responses": { - "200": { - "description": "OK" - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/soundTest": { - "get": { - "description": "Returns the sound check song", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get sound check song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/sync": { - "post": { - "description": "Starts syncing soundtracks that have changed", - "produces": [ - "application/json" - ], - "tags": [ - "sync" - ], - "summary": "Sync soundtracks with only changes", - "responses": { - "200": { - "description": "Start syncing soundtracks", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/sync/full": { - "post": { - "description": "Starts a full sync of all soundtracks", - "produces": [ - "application/json" - ], - "tags": [ - "sync" - ], - "summary": "Sync all soundtracks fully", - "responses": { - "200": { - "description": "Start syncing soundtracks full", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/sync/progress": { - "get": { - "description": "Returns the progress of the running sync, or the result of the last sync", - "produces": [ - "application/json" - ], - "tags": [ - "sync" - ], - "summary": "Get sync progress", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.SyncResponse" - } - } - } - } - }, - "/sync/reset": { - "post": { - "description": "Resets the soundtracks database by deleting all soundtracks and songs", - "produces": [ - "application/json" - ], - "tags": [ - "sync" - ], - "summary": "Reset soundtracks database", - "responses": { - "200": { - "description": "Soundtracks and songs are deleted from the database", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/version": { - "get": { - "description": "get latest version info", - "produces": [ - "application/json" - ], - "tags": [ - "version" - ], - "summary": "Getting the latest version of the backend", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.VersionData" - } - } - } - } - }, - "/version/history": { - "get": { - "description": "get version history", - "produces": [ - "application/json" - ], - "tags": [ - "version" - ], - "summary": "Getting the version history of the backend", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.VersionData" - } - } - } - } - } - } - }, - "definitions": { - "backend.Song": { - "type": "object", - "properties": { - "file_name": { - "type": "string" - }, - "name": { - "type": "string" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_uuid": { - "type": "string" - }, - "times_played": { - "type": "integer" - }, - "uuid": { - "type": "string" - } - } - }, - "backend.SongInfo": { - "type": "object", - "properties": { - "CurrentlyPlaying": { - "type": "boolean" - }, - "Song": { - "type": "string" - }, - "SongNo": { - "type": "integer" - }, - "SongPlayed": { - "type": "integer" - }, - "SongUuid": { - "type": "string" - }, - "Soundtrack": { - "type": "string" - }, - "SoundtrackPlayed": { - "type": "integer" - }, - "SoundtrackUuid": { - "type": "string" - } - } - }, - "backend.SongInfoForStats": { - "type": "object", - "properties": { - "file_name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "song_name": { - "type": "string" - }, - "song_uuid": { - "type": "string" - }, - "soundtrack_id": { - "type": "integer" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_uuid": { - "type": "string" - }, - "times_played": { - "type": "integer" - } - } - }, - "backend.Soundtrack": { - "type": "object", - "properties": { - "added": { - "type": "string" - }, - "last_played": { - "type": "string" - }, - "name": { - "type": "string" - }, - "times_played": { - "type": "integer" - }, - "uuid": { - "type": "string" - } - } - }, - "backend.SoundtrackWithSongList": { - "type": "object", - "properties": { - "added": { - "type": "string" - }, - "last_played": { - "type": "string" - }, - "name": { - "type": "string" - }, - "songs": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.Song" - } - }, - "times_played": { - "type": "integer" - }, - "uuid": { - "type": "string" - } - } - }, - "backend.SoundtrackWithSongs": { - "type": "object", - "properties": { - "songs": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - }, - "soundtrack_id": { - "type": "integer" - }, - "soundtrack_last_played": { - "type": "string" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_played": { - "type": "integer" - }, - "soundtrack_uuid": { - "type": "string" - } - } - }, - "backend.StatisticsSummary": { - "type": "object", - "properties": { - "avg_soundtrack_plays": { - "type": "number" - }, - "max_soundtrack_plays": { - "type": "integer" - }, - "min_soundtrack_plays": { - "type": "integer" - }, - "never_played_soundtracks": { - "type": "integer" - }, - "played_soundtracks": { - "type": "integer" - }, - "total_soundtrack_plays": { - "type": "integer" - }, - "total_soundtracks": { - "type": "integer" - } - } - }, - "backend.SyncResponse": { - "type": "object", - "properties": { - "catched_errors": { - "type": "array", - "items": { - "type": "string" - } - }, - "soundtracks_added": { - "type": "array", - "items": { - "type": "string" - } - }, - "soundtracks_changed_content": { - "type": "array", - "items": { - "type": "string" - } - }, - "soundtracks_changed_title": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "soundtracks_re_added": { - "type": "array", - "items": { - "type": "string" - } - }, - "soundtracks_removed": { - "type": "array", - "items": { - "type": "string" - } - }, - "total_time": { - "type": "string" - } - } - }, - "backend.VersionData": { - "type": "object", - "properties": { - "changelog": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "[\"Initial release\"", - "\"Bug fixes\"]" - ] - }, - "version": { - "type": "string", - "example": "1.0.0" - } - } - }, - "server.TokenRequest": { - "type": "object", - "properties": { - "client_type": { - "description": "Optional: \"web\", \"mobile\", \"api\"", - "type": "string" - }, - "password": { - "description": "API password, required when API_PASSWORD is set", - "type": "string" - } - } - }, - "server.TokenResponse": { - "type": "object", - "properties": { - "client_type": { - "type": "string" - }, - "expires_at": { - "type": "string" - }, - "token": { - "type": "string" - } - } - } - } -}` - -// SwaggerInfo holds exported Swagger Info so clients can modify it -var SwaggerInfo = &swag.Spec{ - Version: "1.0", - Host: "", - BasePath: "/", - Schemes: []string{}, - Title: "MusicServer API", - Description: "API for the MusicServer application", - InfoInstanceName: "swagger", - SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", -} - -func init() { - swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) -} diff --git a/cmd/docs/swagger.json b/cmd/docs/swagger.json deleted file mode 100644 index 7929965..0000000 --- a/cmd/docs/swagger.json +++ /dev/null @@ -1,1755 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "description": "API for the MusicServer application", - "title": "MusicServer API", - "termsOfService": "http://sanplex.xyz/terms/", - "contact": { - "name": "Sebastian Olsson", - "email": "zarnor91@gmail.com" - }, - "license": { - "name": "MIT", - "url": "http://opensource.org/licenses/MIT" - }, - "version": "1.0" - }, - "basePath": "/", - "paths": { - "/api/v1/songs/{uuid}": { - "get": { - "description": "Returns information about a song", - "produces": [ - "application/json" - ], - "tags": [ - "library" - ], - "summary": "Get song", - "parameters": [ - { - "type": "string", - "description": "Song UUID", - "name": "uuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.Song" - } - }, - "400": { - "description": "invalid uuid", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/v1/songs/{uuid}/file": { - "get": { - "description": "Returns the audio file of a song. Supports Range requests.", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "library" - ], - "summary": "Stream song", - "parameters": [ - { - "type": "string", - "description": "Song UUID", - "name": "uuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "206": { - "description": "Partial Content", - "schema": { - "type": "file" - } - }, - "400": { - "description": "invalid uuid", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/v1/soundtracks": { - "get": { - "description": "Returns all soundtracks with their UUIDs, sorted by name", - "produces": [ - "application/json" - ], - "tags": [ - "library" - ], - "summary": "List soundtracks", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.Soundtrack" - } - } - } - } - } - }, - "/api/v1/soundtracks/{uuid}": { - "get": { - "description": "Returns a soundtrack with its songs", - "produces": [ - "application/json" - ], - "tags": [ - "library" - ], - "summary": "Get soundtrack", - "parameters": [ - { - "type": "string", - "description": "Soundtrack UUID", - "name": "uuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.SoundtrackWithSongList" - } - }, - "400": { - "description": "invalid uuid", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/v1/statistics/songs/least-played": { - "get": { - "description": "Returns the top N least played songs with their soundtrack info", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get least played songs", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/songs/most-played": { - "get": { - "description": "Returns the top N most played songs with their soundtrack info", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get most played songs", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/last-played": { - "get": { - "description": "Returns the most recently played soundtracks with their songs", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get last played soundtracks", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/least-played": { - "get": { - "description": "Returns the top N least played soundtracks with their songs", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get least played soundtracks", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/most-played": { - "get": { - "description": "Returns the top N most played soundtracks with their songs", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get most played soundtracks", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/never-played": { - "get": { - "description": "Returns all soundtracks that have never been played", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get never played soundtracks", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/soundtracks/oldest-played": { - "get": { - "description": "Returns the least recently played soundtracks with their songs", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get oldest played soundtracks", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10, max: 100)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SoundtrackWithSongs" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/summary": { - "get": { - "description": "Returns overall play statistics", - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get statistics summary", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.StatisticsSummary" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/token": { - "post": { - "description": "Returns a new session token for API access. Requires the API password when one is configured.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Create session token", - "parameters": [ - { - "description": "Password and client type", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/server.TokenRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.TokenResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - }, - "delete": { - "description": "Deletes the current session token", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Invalidate session token", - "parameters": [ - { - "type": "string", - "description": "Bearer token", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/token/cleanup": { - "post": { - "description": "Removes all expired session tokens from the database", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Cleanup expired sessions", - "parameters": [ - { - "type": "string", - "description": "Bearer token", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/character": { - "get": { - "description": "Returns the image for a specific character", - "produces": [ - "image/png", - "image/jpeg" - ], - "tags": [ - "characters" - ], - "summary": "Get character image", - "parameters": [ - { - "type": "string", - "description": "Character file name", - "name": "name", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "400": { - "description": "invalid name", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - } - } - } - }, - "/characters": { - "get": { - "description": "Returns a list of all available characters", - "produces": [ - "application/json" - ], - "tags": [ - "characters" - ], - "summary": "Get list of characters", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "/download": { - "get": { - "description": "Returns the latest version of the client application", - "produces": [ - "application/json" - ], - "tags": [ - "download" - ], - "summary": "Check for latest version", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "string" - } - }, - "502": { - "description": "upstream service unavailable", - "schema": { - "type": "string" - } - } - } - } - }, - "/download/linux": { - "get": { - "description": "Redirects to download the latest Linux version", - "tags": [ - "download" - ], - "summary": "Download latest Linux version", - "responses": { - "302": { - "description": "Found", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - }, - "502": { - "description": "upstream service unavailable", - "schema": { - "type": "string" - } - } - } - } - }, - "/download/list": { - "get": { - "description": "Lists all assets available for the latest version", - "produces": [ - "application/json" - ], - "tags": [ - "download" - ], - "summary": "List assets of latest version", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "502": { - "description": "upstream service unavailable", - "schema": { - "type": "string" - } - } - } - } - }, - "/download/windows": { - "get": { - "description": "Redirects to download the latest Windows version", - "tags": [ - "download" - ], - "summary": "Download latest Windows version", - "responses": { - "302": { - "description": "Found", - "schema": { - "type": "string" - } - }, - "404": { - "description": "not found", - "schema": { - "type": "string" - } - }, - "502": { - "description": "upstream service unavailable", - "schema": { - "type": "string" - } - } - } - } - }, - "/health": { - "get": { - "description": "Returns the health status of the server", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "Check server health", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/music": { - "get": { - "description": "Returns the song at the given position in the queue", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get a specific song", - "parameters": [ - { - "type": "integer", - "description": "Song number in the queue", - "name": "song", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "206": { - "description": "Partial Content", - "schema": { - "type": "file" - } - }, - "400": { - "description": "song must be a number", - "schema": { - "type": "string" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/addPlayed": { - "get": { - "description": "Marks the current song as played", - "tags": [ - "music" - ], - "summary": "Add latest to played", - "responses": { - "200": { - "description": "OK" - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/addQue": { - "get": { - "description": "Adds the latest song to the queue", - "tags": [ - "music" - ], - "summary": "Add latest to queue", - "responses": { - "200": { - "description": "OK" - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/all/order": { - "get": { - "description": "Returns a list of all soundtracks in order", - "produces": [ - "application/json" - ], - "tags": [ - "music" - ], - "summary": "Get all soundtracks", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/all/random": { - "get": { - "description": "Returns a list of all soundtracks in random order", - "produces": [ - "application/json" - ], - "tags": [ - "music" - ], - "summary": "Get all soundtracks random", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/info": { - "get": { - "description": "Returns information about the current song", - "produces": [ - "application/json" - ], - "tags": [ - "music" - ], - "summary": "Get current song info", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.SongInfo" - } - } - } - } - }, - "/music/list": { - "get": { - "description": "Returns a list of played songs", - "produces": [ - "application/json" - ], - "tags": [ - "music" - ], - "summary": "Get played songs list", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfo" - } - } - } - } - } - }, - "/music/next": { - "get": { - "description": "Returns the next song in the queue", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get next song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/played": { - "put": { - "description": "Marks the song at the given queue position as played", - "tags": [ - "music" - ], - "summary": "Mark song as played", - "parameters": [ - { - "type": "integer", - "description": "Song number in the queue", - "name": "song", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/previous": { - "get": { - "description": "Returns the previous song in the queue", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get previous song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/rand": { - "get": { - "description": "Returns a random song", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get random song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/rand/classic": { - "get": { - "description": "Returns a random song where every song has the same chance", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get random classic song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/rand/low": { - "get": { - "description": "Returns a random song, favouring soundtracks that have been played less", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get random song with low chance", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/reset": { - "get": { - "description": "Resets the music state", - "tags": [ - "music" - ], - "summary": "Reset music state", - "responses": { - "200": { - "description": "OK" - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/music/soundTest": { - "get": { - "description": "Returns the sound check song", - "produces": [ - "audio/mpeg" - ], - "tags": [ - "music" - ], - "summary": "Get sound check song", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/sync": { - "post": { - "description": "Starts syncing soundtracks that have changed", - "produces": [ - "application/json" - ], - "tags": [ - "sync" - ], - "summary": "Sync soundtracks with only changes", - "responses": { - "200": { - "description": "Start syncing soundtracks", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/sync/full": { - "post": { - "description": "Starts a full sync of all soundtracks", - "produces": [ - "application/json" - ], - "tags": [ - "sync" - ], - "summary": "Sync all soundtracks fully", - "responses": { - "200": { - "description": "Start syncing soundtracks full", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/sync/progress": { - "get": { - "description": "Returns the progress of the running sync, or the result of the last sync", - "produces": [ - "application/json" - ], - "tags": [ - "sync" - ], - "summary": "Get sync progress", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.SyncResponse" - } - } - } - } - }, - "/sync/reset": { - "post": { - "description": "Resets the soundtracks database by deleting all soundtracks and songs", - "produces": [ - "application/json" - ], - "tags": [ - "sync" - ], - "summary": "Reset soundtracks database", - "responses": { - "200": { - "description": "Soundtracks and songs are deleted from the database", - "schema": { - "type": "string" - } - }, - "423": { - "description": "Syncing is in progress", - "schema": { - "type": "string" - } - } - } - } - }, - "/version": { - "get": { - "description": "get latest version info", - "produces": [ - "application/json" - ], - "tags": [ - "version" - ], - "summary": "Getting the latest version of the backend", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/backend.VersionData" - } - } - } - } - }, - "/version/history": { - "get": { - "description": "get version history", - "produces": [ - "application/json" - ], - "tags": [ - "version" - ], - "summary": "Getting the version history of the backend", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.VersionData" - } - } - } - } - } - } - }, - "definitions": { - "backend.Song": { - "type": "object", - "properties": { - "file_name": { - "type": "string" - }, - "name": { - "type": "string" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_uuid": { - "type": "string" - }, - "times_played": { - "type": "integer" - }, - "uuid": { - "type": "string" - } - } - }, - "backend.SongInfo": { - "type": "object", - "properties": { - "CurrentlyPlaying": { - "type": "boolean" - }, - "Song": { - "type": "string" - }, - "SongNo": { - "type": "integer" - }, - "SongPlayed": { - "type": "integer" - }, - "SongUuid": { - "type": "string" - }, - "Soundtrack": { - "type": "string" - }, - "SoundtrackPlayed": { - "type": "integer" - }, - "SoundtrackUuid": { - "type": "string" - } - } - }, - "backend.SongInfoForStats": { - "type": "object", - "properties": { - "file_name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "song_name": { - "type": "string" - }, - "song_uuid": { - "type": "string" - }, - "soundtrack_id": { - "type": "integer" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_uuid": { - "type": "string" - }, - "times_played": { - "type": "integer" - } - } - }, - "backend.Soundtrack": { - "type": "object", - "properties": { - "added": { - "type": "string" - }, - "last_played": { - "type": "string" - }, - "name": { - "type": "string" - }, - "times_played": { - "type": "integer" - }, - "uuid": { - "type": "string" - } - } - }, - "backend.SoundtrackWithSongList": { - "type": "object", - "properties": { - "added": { - "type": "string" - }, - "last_played": { - "type": "string" - }, - "name": { - "type": "string" - }, - "songs": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.Song" - } - }, - "times_played": { - "type": "integer" - }, - "uuid": { - "type": "string" - } - } - }, - "backend.SoundtrackWithSongs": { - "type": "object", - "properties": { - "songs": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - }, - "soundtrack_id": { - "type": "integer" - }, - "soundtrack_last_played": { - "type": "string" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_played": { - "type": "integer" - }, - "soundtrack_uuid": { - "type": "string" - } - } - }, - "backend.StatisticsSummary": { - "type": "object", - "properties": { - "avg_soundtrack_plays": { - "type": "number" - }, - "max_soundtrack_plays": { - "type": "integer" - }, - "min_soundtrack_plays": { - "type": "integer" - }, - "never_played_soundtracks": { - "type": "integer" - }, - "played_soundtracks": { - "type": "integer" - }, - "total_soundtrack_plays": { - "type": "integer" - }, - "total_soundtracks": { - "type": "integer" - } - } - }, - "backend.SyncResponse": { - "type": "object", - "properties": { - "catched_errors": { - "type": "array", - "items": { - "type": "string" - } - }, - "soundtracks_added": { - "type": "array", - "items": { - "type": "string" - } - }, - "soundtracks_changed_content": { - "type": "array", - "items": { - "type": "string" - } - }, - "soundtracks_changed_title": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "soundtracks_re_added": { - "type": "array", - "items": { - "type": "string" - } - }, - "soundtracks_removed": { - "type": "array", - "items": { - "type": "string" - } - }, - "total_time": { - "type": "string" - } - } - }, - "backend.VersionData": { - "type": "object", - "properties": { - "changelog": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "[\"Initial release\"", - "\"Bug fixes\"]" - ] - }, - "version": { - "type": "string", - "example": "1.0.0" - } - } - }, - "server.TokenRequest": { - "type": "object", - "properties": { - "client_type": { - "description": "Optional: \"web\", \"mobile\", \"api\"", - "type": "string" - }, - "password": { - "description": "API password, required when API_PASSWORD is set", - "type": "string" - } - } - }, - "server.TokenResponse": { - "type": "object", - "properties": { - "client_type": { - "type": "string" - }, - "expires_at": { - "type": "string" - }, - "token": { - "type": "string" - } - } - } - } -} \ No newline at end of file diff --git a/cmd/docs/swagger.yaml b/cmd/docs/swagger.yaml deleted file mode 100644 index 4429f87..0000000 --- a/cmd/docs/swagger.yaml +++ /dev/null @@ -1,1156 +0,0 @@ -basePath: / -definitions: - backend.Song: - properties: - file_name: - type: string - name: - type: string - soundtrack_name: - type: string - soundtrack_uuid: - type: string - times_played: - type: integer - uuid: - type: string - type: object - backend.SongInfo: - properties: - CurrentlyPlaying: - type: boolean - Song: - type: string - SongNo: - type: integer - SongPlayed: - type: integer - SongUuid: - type: string - Soundtrack: - type: string - SoundtrackPlayed: - type: integer - SoundtrackUuid: - type: string - type: object - backend.SongInfoForStats: - properties: - file_name: - type: string - path: - type: string - song_name: - type: string - song_uuid: - type: string - soundtrack_id: - type: integer - soundtrack_name: - type: string - soundtrack_uuid: - type: string - times_played: - type: integer - type: object - backend.Soundtrack: - properties: - added: - type: string - last_played: - type: string - name: - type: string - times_played: - type: integer - uuid: - type: string - type: object - backend.SoundtrackWithSongList: - properties: - added: - type: string - last_played: - type: string - name: - type: string - songs: - items: - $ref: '#/definitions/backend.Song' - type: array - times_played: - type: integer - uuid: - type: string - type: object - backend.SoundtrackWithSongs: - properties: - songs: - items: - $ref: '#/definitions/backend.SongInfoForStats' - type: array - soundtrack_id: - type: integer - soundtrack_last_played: - type: string - soundtrack_name: - type: string - soundtrack_played: - type: integer - soundtrack_uuid: - type: string - type: object - backend.StatisticsSummary: - properties: - avg_soundtrack_plays: - type: number - max_soundtrack_plays: - type: integer - min_soundtrack_plays: - type: integer - never_played_soundtracks: - type: integer - played_soundtracks: - type: integer - total_soundtrack_plays: - type: integer - total_soundtracks: - type: integer - type: object - backend.SyncResponse: - properties: - catched_errors: - items: - type: string - type: array - soundtracks_added: - items: - type: string - type: array - soundtracks_changed_content: - items: - type: string - type: array - soundtracks_changed_title: - additionalProperties: - type: string - type: object - soundtracks_re_added: - items: - type: string - type: array - soundtracks_removed: - items: - type: string - type: array - total_time: - type: string - type: object - backend.VersionData: - properties: - changelog: - example: - - '["Initial release"' - - '"Bug fixes"]' - items: - type: string - type: array - version: - example: 1.0.0 - type: string - type: object - server.TokenRequest: - properties: - client_type: - description: 'Optional: "web", "mobile", "api"' - type: string - password: - description: API password, required when API_PASSWORD is set - type: string - type: object - server.TokenResponse: - properties: - client_type: - type: string - expires_at: - type: string - token: - type: string - type: object -info: - contact: - email: zarnor91@gmail.com - name: Sebastian Olsson - description: API for the MusicServer application - license: - name: MIT - url: http://opensource.org/licenses/MIT - termsOfService: http://sanplex.xyz/terms/ - title: MusicServer API - version: "1.0" -paths: - /api/v1/songs/{uuid}: - get: - description: Returns information about a song - parameters: - - description: Song UUID - in: path - name: uuid - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/backend.Song' - "400": - description: invalid uuid - schema: - type: string - "404": - description: not found - schema: - type: string - summary: Get song - tags: - - library - /api/v1/songs/{uuid}/file: - get: - description: Returns the audio file of a song. Supports Range requests. - parameters: - - description: Song UUID - in: path - name: uuid - required: true - type: string - produces: - - audio/mpeg - responses: - "200": - description: OK - schema: - type: file - "206": - description: Partial Content - schema: - type: file - "400": - description: invalid uuid - schema: - type: string - "404": - description: not found - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Stream song - tags: - - library - /api/v1/soundtracks: - get: - description: Returns all soundtracks with their UUIDs, sorted by name - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.Soundtrack' - type: array - summary: List soundtracks - tags: - - library - /api/v1/soundtracks/{uuid}: - get: - description: Returns a soundtrack with its songs - parameters: - - description: Soundtrack UUID - in: path - name: uuid - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/backend.SoundtrackWithSongList' - "400": - description: invalid uuid - schema: - type: string - "404": - description: not found - schema: - type: string - summary: Get soundtrack - tags: - - library - /api/v1/statistics/songs/least-played: - get: - description: Returns the top N least played songs with their soundtrack info - parameters: - - description: 'Number of results (default: 10, max: 100)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.SongInfoForStats' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get least played songs - tags: - - statistics - /api/v1/statistics/songs/most-played: - get: - description: Returns the top N most played songs with their soundtrack info - parameters: - - description: 'Number of results (default: 10, max: 100)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.SongInfoForStats' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get most played songs - tags: - - statistics - /api/v1/statistics/soundtracks/last-played: - get: - description: Returns the most recently played soundtracks with their songs - parameters: - - description: 'Number of results (default: 10, max: 100)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.SoundtrackWithSongs' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get last played soundtracks - tags: - - statistics - /api/v1/statistics/soundtracks/least-played: - get: - description: Returns the top N least played soundtracks with their songs - parameters: - - description: 'Number of results (default: 10, max: 100)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.SoundtrackWithSongs' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get least played soundtracks - tags: - - statistics - /api/v1/statistics/soundtracks/most-played: - get: - description: Returns the top N most played soundtracks with their songs - parameters: - - description: 'Number of results (default: 10, max: 100)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.SoundtrackWithSongs' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get most played soundtracks - tags: - - statistics - /api/v1/statistics/soundtracks/never-played: - get: - description: Returns all soundtracks that have never been played - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.SoundtrackWithSongs' - type: array - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get never played soundtracks - tags: - - statistics - /api/v1/statistics/soundtracks/oldest-played: - get: - description: Returns the least recently played soundtracks with their songs - parameters: - - description: 'Number of results (default: 10, max: 100)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.SoundtrackWithSongs' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get oldest played soundtracks - tags: - - statistics - /api/v1/statistics/summary: - get: - description: Returns overall play statistics - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/backend.StatisticsSummary' - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get statistics summary - tags: - - statistics - /api/v1/token: - delete: - description: Deletes the current session token - parameters: - - description: Bearer token - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Invalidate session token - tags: - - auth - post: - consumes: - - application/json - description: Returns a new session token for API access. Requires the API password - when one is configured. - parameters: - - description: Password and client type - in: body - name: request - required: true - schema: - $ref: '#/definitions/server.TokenRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/server.TokenResponse' - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "429": - description: Too Many Requests - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Create session token - tags: - - auth - /api/v1/token/cleanup: - post: - description: Removes all expired session tokens from the database - parameters: - - description: Bearer token - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Cleanup expired sessions - tags: - - auth - /character: - get: - description: Returns the image for a specific character - parameters: - - description: Character file name - in: query - name: name - required: true - type: string - produces: - - image/png - - image/jpeg - responses: - "200": - description: OK - schema: - type: file - "400": - description: invalid name - schema: - type: string - "404": - description: not found - schema: - type: string - summary: Get character image - tags: - - characters - /characters: - get: - description: Returns a list of all available characters - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - type: string - type: array - summary: Get list of characters - tags: - - characters - /download: - get: - description: Returns the latest version of the client application - produces: - - application/json - responses: - "200": - description: OK - schema: - type: string - "502": - description: upstream service unavailable - schema: - type: string - summary: Check for latest version - tags: - - download - /download/linux: - get: - description: Redirects to download the latest Linux version - responses: - "302": - description: Found - schema: - type: string - "404": - description: not found - schema: - type: string - "502": - description: upstream service unavailable - schema: - type: string - summary: Download latest Linux version - tags: - - download - /download/list: - get: - description: Lists all assets available for the latest version - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - type: string - type: array - "502": - description: upstream service unavailable - schema: - type: string - summary: List assets of latest version - tags: - - download - /download/windows: - get: - description: Redirects to download the latest Windows version - responses: - "302": - description: Found - schema: - type: string - "404": - description: not found - schema: - type: string - "502": - description: upstream service unavailable - schema: - type: string - summary: Download latest Windows version - tags: - - download - /health: - get: - description: Returns the health status of the server - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: - type: string - type: object - summary: Check server health - tags: - - health - /music: - get: - description: Returns the song at the given position in the queue - parameters: - - description: Song number in the queue - in: query - name: song - required: true - type: integer - produces: - - audio/mpeg - responses: - "200": - description: OK - schema: - type: file - "206": - description: Partial Content - schema: - type: file - "400": - description: song must be a number - schema: - type: string - "404": - description: Not Found - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Get a specific song - tags: - - music - /music/addPlayed: - get: - description: Marks the current song as played - responses: - "200": - description: OK - "423": - description: Syncing is in progress - schema: - type: string - summary: Add latest to played - tags: - - music - /music/addQue: - get: - description: Adds the latest song to the queue - responses: - "200": - description: OK - "423": - description: Syncing is in progress - schema: - type: string - summary: Add latest to queue - tags: - - music - /music/all/order: - get: - description: Returns a list of all soundtracks in order - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - type: string - type: array - "423": - description: Syncing is in progress - schema: - type: string - summary: Get all soundtracks - tags: - - music - /music/all/random: - get: - description: Returns a list of all soundtracks in random order - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - type: string - type: array - "423": - description: Syncing is in progress - schema: - type: string - summary: Get all soundtracks random - tags: - - music - /music/info: - get: - description: Returns information about the current song - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/backend.SongInfo' - summary: Get current song info - tags: - - music - /music/list: - get: - description: Returns a list of played songs - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.SongInfo' - type: array - summary: Get played songs list - tags: - - music - /music/next: - get: - description: Returns the next song in the queue - produces: - - audio/mpeg - responses: - "200": - description: OK - schema: - type: file - "404": - description: Not Found - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Get next song - tags: - - music - /music/played: - put: - description: Marks the song at the given queue position as played - parameters: - - description: Song number in the queue - in: query - name: song - required: true - type: integer - responses: - "200": - description: OK - "400": - description: Bad Request - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Mark song as played - tags: - - music - /music/previous: - get: - description: Returns the previous song in the queue - produces: - - audio/mpeg - responses: - "200": - description: OK - schema: - type: file - "404": - description: Not Found - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Get previous song - tags: - - music - /music/rand: - get: - description: Returns a random song - produces: - - audio/mpeg - responses: - "200": - description: OK - schema: - type: file - "404": - description: Not Found - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Get random song - tags: - - music - /music/rand/classic: - get: - description: Returns a random song where every song has the same chance - produces: - - audio/mpeg - responses: - "200": - description: OK - schema: - type: file - "404": - description: Not Found - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Get random classic song - tags: - - music - /music/rand/low: - get: - description: Returns a random song, favouring soundtracks that have been played - less - produces: - - audio/mpeg - responses: - "200": - description: OK - schema: - type: file - "404": - description: Not Found - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Get random song with low chance - tags: - - music - /music/reset: - get: - description: Resets the music state - responses: - "200": - description: OK - "423": - description: Syncing is in progress - schema: - type: string - summary: Reset music state - tags: - - music - /music/soundTest: - get: - description: Returns the sound check song - produces: - - audio/mpeg - responses: - "200": - description: OK - schema: - type: file - "404": - description: Not Found - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Get sound check song - tags: - - music - /sync: - post: - description: Starts syncing soundtracks that have changed - produces: - - application/json - responses: - "200": - description: Start syncing soundtracks - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Sync soundtracks with only changes - tags: - - sync - /sync/full: - post: - description: Starts a full sync of all soundtracks - produces: - - application/json - responses: - "200": - description: Start syncing soundtracks full - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Sync all soundtracks fully - tags: - - sync - /sync/progress: - get: - description: Returns the progress of the running sync, or the result of the - last sync - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/backend.SyncResponse' - summary: Get sync progress - tags: - - sync - /sync/reset: - post: - description: Resets the soundtracks database by deleting all soundtracks and - songs - produces: - - application/json - responses: - "200": - description: Soundtracks and songs are deleted from the database - schema: - type: string - "423": - description: Syncing is in progress - schema: - type: string - summary: Reset soundtracks database - tags: - - sync - /version: - get: - description: get latest version info - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/backend.VersionData' - summary: Getting the latest version of the backend - tags: - - version - /version/history: - get: - description: get version history - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.VersionData' - type: array - summary: Getting the version history of the backend - tags: - - version -swagger: "2.0" diff --git a/go.mod b/go.mod index a72948d..5a65c9b 100644 --- a/go.mod +++ b/go.mod @@ -8,28 +8,27 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.9.2 - github.com/labstack/echo/v5 v5.1.1 + github.com/labstack/echo/v5 v5.3.0 github.com/lib/pq v1.12.3 // indirect github.com/spf13/afero v1.15.0 github.com/stretchr/testify v1.11.1 - github.com/swaggo/echo-swagger/v2 v2.0.1 - github.com/swaggo/swag v1.16.6 github.com/testcontainers/testcontainers-go v0.42.0 go.uber.org/zap v1.28.0 ) require ( + github.com/danielgtaylor/huma/v2 v2.39.1 + github.com/getkin/kin-openapi v0.149.0 github.com/google/uuid v1.6.0 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.22.0 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cli/browser v1.3.0 // indirect @@ -38,7 +37,6 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -50,25 +48,20 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/spec v0.22.4 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/loading 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/yamlutils v0.26.0 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/compress v1.19.0 // indirect + github.com/labstack/echo/v4 v4.15.4 // indirect + github.com/labstack/gommon v0.5.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect @@ -79,20 +72,19 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/natefinch/atomic v1.0.1 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/sv-tools/openapi v0.4.0 // indirect - github.com/swaggo/files/v2 v2.0.2 // indirect - github.com/swaggo/swag/v2 v2.0.0-rc5 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect - github.com/urfave/cli/v2 v2.27.5 // indirect - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -100,20 +92,14 @@ require ( go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/multierr v1.10.0 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/tools v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) -tool ( - github.com/a-h/templ/cmd/templ - github.com/swaggo/swag/cmd/swag -) +tool github.com/a-h/templ/cmd/templ diff --git a/go.sum b/go.sum index ae149ff..1c6df6e 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= -github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/MShekow/directory-checksum v1.4.18 h1:1nPPVl7uREa6WMTAPKoWW/GylhnASs0C9C+GPiwLwXA= github.com/MShekow/directory-checksum v1.4.18/go.mod h1:iUupsPb0X0BumQQymLrpD5Pkqe/CbV13OSgosw1oFc4= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -14,8 +12,8 @@ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ6 github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -32,10 +30,10 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g= +github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -43,6 +41,8 @@ github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -57,6 +57,12 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 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/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA= +github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -66,33 +72,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= -github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= -github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= -github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= -github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= -github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -102,6 +87,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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= @@ -112,25 +99,28 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I= -github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= +github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs= +github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g= +github.com/labstack/echo/v5 v5.3.0 h1:KT74Mprk053PQEHwSZdeCDIz1BigTZOZhavMD0c9Fjs= +github.com/labstack/echo/v5 v5.3.0/go.mod h1:Q3j2+clBRgJr0O3DDONQeXNsM7RHgSwUhcuo47unqm8= +github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= +github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -153,6 +143,10 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -166,8 +160,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -181,26 +175,20 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4= -github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE= -github.com/swaggo/echo-swagger/v2 v2.0.1 h1:jKR3QiK+ciGjxE0+7qZ/azjtlx/pTVls7pJFJqdJoJI= -github.com/swaggo/echo-swagger/v2 v2.0.1/go.mod h1:BbgiO9XKX6yYU5Rq4ejqVlQI0mVRv6ziFKd0XgdztnQ= -github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= -github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= -github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= -github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk= -github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= -github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= -github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik= +github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -223,33 +211,29 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -261,5 +245,3 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/backend/characters.go b/internal/backend/characters.go index 094c75d..c23f26a 100644 --- a/internal/backend/characters.go +++ b/internal/backend/characters.go @@ -28,7 +28,7 @@ func (c *CharacterService) List() ([]string, error) { if err != nil { return nil, err } - var characters []string + characters := []string{} for _, entry := range entries { if !entry.IsDir() && isImage(entry.Name()) { characters = append(characters, entry.Name()) diff --git a/internal/backend/music.go b/internal/backend/music.go index 6b5485b..69b9a8e 100644 --- a/internal/backend/music.go +++ b/internal/backend/music.go @@ -198,7 +198,7 @@ func (m *MusicService) SongInfo(ctx context.Context) SongInfo { // PlayedSongs returns all songs in the queue. func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo { - var songList []SongInfo + songList := []SongInfo{} for i, song := range m.queue { soundtrack := m.soundtrackFor(ctx, song) songList = append(songList, SongInfo{ diff --git a/internal/backend/statistics.go b/internal/backend/statistics.go index 3eb0ec6..d3b2dbd 100644 --- a/internal/backend/statistics.go +++ b/internal/backend/statistics.go @@ -66,7 +66,7 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, if err != nil { return nil, err } - var result []SoundtrackWithSongs + result := []SoundtrackWithSongs{} for _, r := range rows { row := repository.GetMostPlayedSoundtracksWithSongsRow(r) result = append(result, SoundtrackWithSongs{ @@ -89,7 +89,7 @@ func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) { if err != nil { return nil, err } - var result []SongInfoForStats + result := []SongInfoForStats{} for _, r := range rows { result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r))) } @@ -132,7 +132,7 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound if err != nil { return nil, err } - var result []SoundtrackWithSongs + result := []SoundtrackWithSongs{} for _, row := range rows { result = append(result, SoundtrackWithSongs{ SoundtrackID: row.SoundtrackID, diff --git a/internal/backend/sync.go b/internal/backend/sync.go index 651a1cb..f4ac6c7 100644 --- a/internal/backend/sync.go +++ b/internal/backend/sync.go @@ -136,11 +136,23 @@ func (s *SyncService) Progress() ProgressResponse { } } -// Result returns the result of the last finished sync. +// Result returns the result of the last finished sync. Lists are empty, not nil, before the first sync. func (s *SyncService) Result() SyncResponse { s.mu.Lock() defer s.mu.Unlock() - return s.result + return s.result.withEmptyLists() +} + +func (r SyncResponse) withEmptyLists() SyncResponse { + for _, list := range []*[]string{&r.SoundtracksAdded, &r.SoundtracksReAdded, &r.SoundtracksChangedContent, &r.SoundtracksRemoved, &r.CatchedErrors} { + if *list == nil { + *list = []string{} + } + } + if r.SoundtracksChangedTitle == nil { + r.SoundtracksChangedTitle = map[string]string{} + } + return r } // ResetDB deletes all songs and soundtracks. diff --git a/internal/backend/sync_test.go b/internal/backend/sync_test.go index 8f6e727..c94b622 100644 --- a/internal/backend/sync_test.go +++ b/internal/backend/sync_test.go @@ -371,7 +371,14 @@ func TestSyncStartOnlyOnce(t *testing.T) { func TestSyncProgressBeforeFirstSync(t *testing.T) { s := NewSyncService(fakedb.New(), t.TempDir(), nil) assert.Equal(t, "0", s.Progress().Progress) - assert.Equal(t, SyncResponse{}, s.Result()) + assert.Equal(t, SyncResponse{ + SoundtracksAdded: []string{}, + SoundtracksReAdded: []string{}, + SoundtracksChangedTitle: map[string]string{}, + SoundtracksChangedContent: []string{}, + SoundtracksRemoved: []string{}, + CatchedErrors: []string{}, + }, s.Result(), "lists are empty, not null, so the JSON matches the API schema") } func TestSyncCallsOnCompleteBeforeFinishing(t *testing.T) { diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..9a7d612 --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,165 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + + "music-server/internal/backend" + "music-server/internal/logging" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/labstack/echo/v5" + "go.uber.org/zap" +) + +const ( + apiPrefix = "/api/v1" + bearerAuthName = "bearer" +) + +var bearerAuth = []map[string][]string{{bearerAuthName: {}}} + +// APIError is the body of every error response. +type APIError struct { + status int + Message string `json:"error" example:"Not Found" doc:"What went wrong"` +} + +func (e *APIError) Error() string { return e.Message } +func (e *APIError) GetStatus() int { return e.status } + +func init() { + // The API always returns [] for empty lists, never null. + huma.DefaultArrayNullable = false + + // Huma creates all its errors (validation, 404, ...) through NewError. + huma.NewError = func(status int, msg string, errs ...error) huma.StatusError { + var details []string + for _, err := range errs { + if err != nil { + details = append(details, err.Error()) + } + } + if len(details) > 0 { + msg += ": " + strings.Join(details, "; ") + } + return &APIError{status: status, Message: msg} + } +} + +// apis holds the documented /api/v1 API and the hidden, deprecated legacy routes. +type apis struct { + v1 huma.API + legacy huma.API +} + +func newAPIs(e *echo.Echo) apis { + config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version) + // No $schema fields or Link headers in responses. + config.CreateHooks = nil + config.DocsRenderer = huma.DocsRendererScalar + config.Info.Description = "API for the MusicServer application.\n\n" + + "Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer `.\n\n" + + "The same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token " + + "and are not documented here. They will be removed." + config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"}, + } + + legacyConfig := huma.DefaultConfig("MusicServer legacy API", backend.GetLatestVersion().Version) + legacyConfig.CreateHooks = nil + legacyConfig.OpenAPIPath = "" + legacyConfig.DocsPath = "" + legacyConfig.SchemasPath = "" + + return apis{v1: humaecho.New(e, config), legacy: humaecho.New(e, legacyConfig)} +} + +// route describes an endpoint registered on /api/v1 and, optionally, as a legacy route. +type route struct { + op huma.Operation + // public endpoints need no token. + public bool + // legacyPath is the deprecated path without /api/v1 (empty: no legacy route). + legacyPath string + // locked endpoints answer 423 while a sync is running. + locked bool +} + +// register adds the route's operation to the v1 API and, if it has one, its hidden legacy route. +func register[I, O any](s *Server, a apis, r route, handler func(context.Context, *I) (*O, error)) { + op := r.op + op.Path = apiPrefix + r.op.Path + if !r.public { + op.Security = bearerAuth + op.Errors = append(op.Errors, http.StatusUnauthorized) + } + if r.locked { + op.Middlewares = append(op.Middlewares, s.syncLock(a.v1)) + op.Errors = append(op.Errors, http.StatusLocked) + } + huma.Register(a.v1, op, handler) + + if r.legacyPath == "" { + return + } + legacy := r.op + legacy.OperationID = "legacy-" + r.op.OperationID + legacy.Path = r.legacyPath + legacy.Hidden = true + legacy.Deprecated = true + if r.locked { + legacy.Middlewares = append(legacy.Middlewares, s.syncLock(a.legacy)) + } + huma.Register(a.legacy, legacy, handler) +} + +// syncLock answers 423 Locked while a sync is running. +func (s *Server) syncLock(api huma.API) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + if s.sync.IsSyncing() { + logging.GetLogger().Info("Syncing is in progress - request blocked") + _ = huma.WriteErr(api, ctx, http.StatusLocked, "Syncing is in progress") + return + } + next(ctx) + } +} + +// apiError maps backend errors to API errors. +func apiError(err error) error { + switch { + case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound): + return huma.Error404NotFound(err.Error()) + case errors.Is(err, backend.ErrInvalidName): + return huma.Error400BadRequest(err.Error()) + case errors.Is(err, backend.ErrUpstream): + return huma.Error502BadGateway(err.Error()) + default: + logging.GetLogger().Error("Request failed", zap.Error(err)) + return huma.Error500InternalServerError("Internal Server Error") + } +} + +// echoContext returns the Echo context behind a Huma context. +func echoContext(ctx huma.Context) *echo.Context { + return humaecho.Unwrap(ctx) +} + +// noBody is the output of operations that only return a status. +type noBody struct{} + +// jsonBody wraps a JSON response body. +type jsonBody[T any] struct { + Body T +} + +func ok[T any](body T, err error) (*jsonBody[T], error) { + if err != nil { + return nil, apiError(err) + } + return &jsonBody[T]{Body: body}, nil +} diff --git a/internal/server/character_handler.go b/internal/server/character_handler.go deleted file mode 100644 index 53293f7..0000000 --- a/internal/server/character_handler.go +++ /dev/null @@ -1,51 +0,0 @@ -package server - -import ( - "net/http" - - "music-server/internal/backend" - - "github.com/labstack/echo/v5" -) - -type CharacterHandler struct { - characters *backend.CharacterService -} - -func NewCharacterHandler(characters *backend.CharacterService) *CharacterHandler { - return &CharacterHandler{characters: characters} -} - -// GetCharacterList godoc -// @Summary Get list of characters -// @Description Returns a list of all available characters -// @Tags characters -// @Produce json -// @Success 200 {array} string -// @Router /characters [get] -func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error { - characters, err := c.characters.List() - if err != nil { - return songError(err) - } - return ctx.JSON(http.StatusOK, characters) -} - -// GetCharacter godoc -// @Summary Get character image -// @Description Returns the image for a specific character -// @Tags characters -// @Produce image/png -// @Produce image/jpeg -// @Param name query string true "Character file name" -// @Success 200 {file} file -// @Failure 400 {string} string "invalid name" -// @Failure 404 {string} string "not found" -// @Router /character [get] -func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error { - f, err := c.characters.Open(ctx.QueryParam("name")) - if err != nil { - return songError(err) - } - return serveFile(ctx, f, "") -} diff --git a/internal/server/download_handler.go b/internal/server/download_handler.go deleted file mode 100644 index bc4726e..0000000 --- a/internal/server/download_handler.go +++ /dev/null @@ -1,81 +0,0 @@ -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) -} diff --git a/internal/server/errors.go b/internal/server/errors.go index 47059c6..278b07e 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -10,12 +10,7 @@ import ( "go.uber.org/zap" ) -// ErrorResponse is the body of every error response. -type ErrorResponse struct { - Error string `json:"error" example:"Not Found"` -} - -// httpErrorHandler writes every error as an ErrorResponse. +// httpErrorHandler writes errors from Echo (unknown routes, panics, ...) as an APIError, like Huma does. func httpErrorHandler(c *echo.Context, err error) { if r, _ := echo.UnwrapResponse(c.Response()); r != nil && r.Committed { return @@ -43,5 +38,5 @@ func httpErrorHandler(c *echo.Context, err error) { _ = c.NoContent(code) return } - _ = c.JSON(code, ErrorResponse{Error: message}) + _ = c.JSON(code, &APIError{Message: message}) } diff --git a/internal/server/files.go b/internal/server/files.go index 8edb66e..44c3432 100644 --- a/internal/server/files.go +++ b/internal/server/files.go @@ -1,61 +1,60 @@ 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" + "github.com/danielgtaylor/huma/v2" ) -// 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 +// fileResponses documents an endpoint that returns a file with Range support. +func fileResponses(description string, contentTypes ...string) map[string]*huma.Response { + content := map[string]*huma.MediaType{} + for _, contentType := range contentTypes { + content[contentType] = &huma.MediaType{Schema: &huma.Schema{Type: "string", Format: "binary"}} } - if info.IsDir() { - return echo.NewHTTPError(http.StatusNotFound, "Not Found") + return map[string]*huma.Response{ + "200": {Description: description, Content: content}, + "206": {Description: "Part of the file, for a Range request", Content: content}, } - 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) - } +// streamFile opens path and returns a response that sends it with Range support. +// contentType may be empty to detect it from the file extension. Audio has no write +// deadline because songs can take long to send to slow clients. +func streamFile(path, contentType string, audio bool) (*huma.StreamResponse, error) { f, err := os.Open(path) if err != nil { - return echo.NewHTTPError(http.StatusNotFound, "Not Found") + return nil, huma.Error404NotFound("Not Found") } - _ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{}) - return serveFile(c, f, "audio/mpeg") + return streamOpenFile(f, contentType, audio) } -// 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") +func streamOpenFile(f *os.File, contentType string, audio bool) (*huma.StreamResponse, error) { + info, err := f.Stat() + if err != nil || info.IsDir() { + f.Close() + return nil, huma.Error404NotFound("Not Found") } + return &huma.StreamResponse{Body: func(ctx huma.Context) { + defer f.Close() + c := echoContext(ctx) + if contentType != "" { + c.Response().Header().Set("Content-Type", contentType) + } + if audio { + _ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{}) + } + http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f) + }}, nil +} + +// streamSong streams the song at path, or returns err mapped to an API error. +func streamSong(path string, err error) (*huma.StreamResponse, error) { + if err != nil { + return nil, apiError(err) + } + return streamFile(path, "audio/mpeg", true) } diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index e6f0b0d..c2a0b67 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -40,7 +40,7 @@ func TestMusicRandomAndQueue(t *testing.T) { assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path) } assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code) - assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPut, "/music/played?song=x").Code) + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/music/played?song=x").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code) } @@ -60,12 +60,13 @@ func TestMusicEmptyStates(t *testing.T) { for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} { assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) } - assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music?song=abc").Code) - assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music").Code) + // Invalid or missing parameters fail validation. + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music?song=abc").Code) + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music").Code) rec := e.do(http.MethodGet, "/music/list") assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "null\n", rec.Body.String()) + assert.JSONEq(t, "[]", rec.Body.String()) } func TestMusicSoundCheck(t *testing.T) { @@ -118,7 +119,8 @@ func TestCharacterEndpoints(t *testing.T) { func TestCharacterPathTraversal(t *testing.T) { e := newTestEnv(t) - for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd", ""} { + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/character?name=").Code, "name is required") + for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} { rec := e.do(http.MethodGet, "/character?name="+name) assert.Equal(t, http.StatusBadRequest, rec.Code, name) assert.NotContains(t, rec.Body.String(), "secret", name) @@ -262,6 +264,10 @@ func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatist return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err } +func statsService() *backend.StatisticsService { + return backend.NewStatisticsService(statsStore{}) +} + func TestStatisticsEndpoints(t *testing.T) { e := newTestEnv(t) e.server.stats = backend.NewStatisticsService(statsStore{}) @@ -279,7 +285,7 @@ func TestStatisticsEndpoints(t *testing.T) { assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped") for _, limit := range []string{"0", "-1", "abc"} { - assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit) + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit) } var summary backend.StatisticsSummary diff --git a/internal/server/health_handler.go b/internal/server/health_handler.go deleted file mode 100644 index fc2187e..0000000 --- a/internal/server/health_handler.go +++ /dev/null @@ -1,33 +0,0 @@ -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())) -} diff --git a/internal/server/library_handler.go b/internal/server/library_handler.go index 43572f8..a33a8ff 100644 --- a/internal/server/library_handler.go +++ b/internal/server/library_handler.go @@ -1,111 +1,59 @@ package server import ( + "context" "net/http" "music-server/internal/backend" + "github.com/danielgtaylor/huma/v2" "github.com/google/uuid" - "github.com/labstack/echo/v5" ) -type LibraryHandler struct { - library *backend.LibraryService +type uuidInput struct { + UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack or song"` } -func NewLibraryHandler(library *backend.LibraryService) *LibraryHandler { - return &LibraryHandler{library: library} +func (in *uuidInput) id() uuid.UUID { + // The format is validated by Huma before the handler runs. + return uuid.MustParse(in.UUID) } -// uuidParam parses the :uuid path parameter. -func uuidParam(ctx *echo.Context) (uuid.UUID, error) { - id, err := uuid.Parse(ctx.Param("uuid")) - if err != nil { - return uuid.Nil, echo.NewHTTPError(http.StatusBadRequest, "invalid uuid") +func (s *Server) registerLibrary(a apis) { + l := s.library + op := func(id, path, summary, description string, errs ...int) huma.Operation { + return huma.Operation{ + OperationID: id, + Method: http.MethodGet, + Path: path, + Summary: summary, + Description: description, + Tags: []string{"library"}, + Errors: errs, + } } - return id, nil -} -// GetSoundtracks godoc -// -// @Summary List soundtracks -// @Description Returns all soundtracks with their UUIDs, sorted by name -// @Tags library -// @Produce json -// @Success 200 {array} backend.Soundtrack -// @Router /api/v1/soundtracks [get] -func (l *LibraryHandler) GetSoundtracks(ctx *echo.Context) error { - soundtracks, err := l.library.Soundtracks(ctx.Request().Context()) - if err != nil { - return songError(err) - } - return ctx.JSON(http.StatusOK, soundtracks) -} + register(s, a, route{op: op("list-soundtracks", "/soundtracks", "List soundtracks", + "Returns all soundtracks, sorted by name.")}, + func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.Soundtrack], error) { + return ok(l.Soundtracks(ctx)) + }) + register(s, a, route{op: op("get-soundtrack", "/soundtracks/{uuid}", "Get soundtrack", + "Returns a soundtrack with its songs, sorted by name.", http.StatusNotFound)}, + func(ctx context.Context, in *uuidInput) (*jsonBody[backend.SoundtrackWithSongList], error) { + return ok(l.Soundtrack(ctx, in.id())) + }) + register(s, a, route{op: op("get-song", "/songs/{uuid}", "Get song", + "Returns information about a song.", http.StatusNotFound)}, + func(ctx context.Context, in *uuidInput) (*jsonBody[backend.Song], error) { + return ok(l.Song(ctx, in.id())) + }) -// GetSoundtrack godoc -// -// @Summary Get soundtrack -// @Description Returns a soundtrack with its songs -// @Tags library -// @Produce json -// @Param uuid path string true "Soundtrack UUID" -// @Success 200 {object} backend.SoundtrackWithSongList -// @Failure 400 {string} string "invalid uuid" -// @Failure 404 {string} string "not found" -// @Router /api/v1/soundtracks/{uuid} [get] -func (l *LibraryHandler) GetSoundtrack(ctx *echo.Context) error { - id, err := uuidParam(ctx) - if err != nil { - return err - } - soundtrack, err := l.library.Soundtrack(ctx.Request().Context(), id) - if err != nil { - return songError(err) - } - return ctx.JSON(http.StatusOK, soundtrack) -} - -// GetSong godoc -// -// @Summary Get song -// @Description Returns information about a song -// @Tags library -// @Produce json -// @Param uuid path string true "Song UUID" -// @Success 200 {object} backend.Song -// @Failure 400 {string} string "invalid uuid" -// @Failure 404 {string} string "not found" -// @Router /api/v1/songs/{uuid} [get] -func (l *LibraryHandler) GetSong(ctx *echo.Context) error { - id, err := uuidParam(ctx) - if err != nil { - return err - } - song, err := l.library.Song(ctx.Request().Context(), id) - if err != nil { - return songError(err) - } - return ctx.JSON(http.StatusOK, song) -} - -// GetSongFile godoc -// -// @Summary Stream song -// @Description Returns the audio file of a song. Supports Range requests. -// @Tags library -// @Produce audio/mpeg -// @Param uuid path string true "Song UUID" -// @Success 200 {file} file -// @Success 206 {file} file -// @Failure 400 {string} string "invalid uuid" -// @Failure 404 {string} string "not found" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /api/v1/songs/{uuid}/file [get] -func (l *LibraryHandler) GetSongFile(ctx *echo.Context) error { - id, err := uuidParam(ctx) - if err != nil { - return err - } - path, err := l.library.SongPath(ctx.Request().Context(), id) - return serveSong(ctx, path, err) + file := op("get-song-file", "/songs/{uuid}/file", "Stream song", + "Returns the audio file of a song.", http.StatusNotFound) + file.Responses = fileResponses("The song (MP3)", audioMPEG) + register(s, a, route{op: file, locked: true}, + func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) { + return streamSong(l.SongPath(ctx, in.id())) + }) } diff --git a/internal/server/library_handler_test.go b/internal/server/library_handler_test.go index b22e57a..fb644e9 100644 --- a/internal/server/library_handler_test.go +++ b/internal/server/library_handler_test.go @@ -48,7 +48,7 @@ func TestLibraryEndpointErrors(t *testing.T) { assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) } for _, path := range []string{"/api/v1/soundtracks/not-a-uuid", "/api/v1/songs/123", "/api/v1/songs/123/file"} { - assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, path).Code, path) + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, path).Code, path) } } diff --git a/internal/server/middleware/deprecation.go b/internal/server/middleware/deprecation.go index cd30eaa..919f83c 100644 --- a/internal/server/middleware/deprecation.go +++ b/internal/server/middleware/deprecation.go @@ -1,16 +1,10 @@ package middleware -import ( - "github.com/labstack/echo/v5" -) +import "github.com/danielgtaylor/huma/v2" -// DeprecationMiddleware adds deprecation warning to responses -// for old endpoints that are being phased out in favor of /api/v1/* -func DeprecationMiddleware(next echo.HandlerFunc) echo.HandlerFunc { - return func(c *echo.Context) error { - // Add deprecation warning header - c.Response().Header().Add("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`) - c.Response().Header().Add("Deprecation", "true") - return next(c) - } +// Deprecation marks responses from legacy routes as deprecated in favour of /api/v1. +func Deprecation(ctx huma.Context, next func(huma.Context)) { + ctx.AppendHeader("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`) + ctx.AppendHeader("Deprecation", "true") + next(ctx) } diff --git a/internal/server/middleware/init.go b/internal/server/middleware/init.go index bb94d76..6925332 100644 --- a/internal/server/middleware/init.go +++ b/internal/server/middleware/init.go @@ -1,2 +1,15 @@ -// Package middleware provides Echo middleware for the MusicServer application. +// Package middleware provides Huma middleware for the MusicServer API. package middleware + +import ( + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humaecho" +) + +// Middleware is a Huma middleware function. +type Middleware = func(ctx huma.Context, next func(huma.Context)) + +// clientIP returns the client IP as resolved by Echo (respecting trusted proxies). +func clientIP(ctx huma.Context) string { + return humaecho.Unwrap(ctx).RealIP() +} diff --git a/internal/server/middleware/rate_limit.go b/internal/server/middleware/rate_limit.go index 8ecf302..774ec14 100644 --- a/internal/server/middleware/rate_limit.go +++ b/internal/server/middleware/rate_limit.go @@ -6,24 +6,25 @@ import ( "music-server/internal/logging" - "github.com/labstack/echo/v5" + "github.com/danielgtaylor/huma/v2" echoMiddleware "github.com/labstack/echo/v5/middleware" "go.uber.org/zap" ) // RateLimit allows each client IP limit requests per window, with bursts up to limit. -func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc { - config := echoMiddleware.DefaultRateLimiterConfig - config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{ +func RateLimit(api huma.API, limit int, window time.Duration) Middleware { + store := echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{ Rate: float64(limit) / window.Seconds(), Burst: limit, ExpiresIn: window, }) - config.DenyHandler = func(c *echo.Context, identifier string, err error) error { - logging.GetLogger().Warn("Rate limit exceeded", - zap.String("ip", identifier), - zap.String("path", c.Request().URL.Path)) - return echo.NewHTTPError(http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.") + return func(ctx huma.Context, next func(huma.Context)) { + ip := clientIP(ctx) + if allowed, _ := store.Allow(ip); !allowed { + logging.GetLogger().Warn("Rate limit exceeded", zap.String("ip", ip), zap.String("path", ctx.URL().Path)) + _ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.") + return + } + next(ctx) } - return echoMiddleware.RateLimiterWithConfig(config) } diff --git a/internal/server/middleware/sync_check.go b/internal/server/middleware/sync_check.go deleted file mode 100644 index a9f2235..0000000 --- a/internal/server/middleware/sync_check.go +++ /dev/null @@ -1,22 +0,0 @@ -package middleware - -import ( - "net/http" - - "music-server/internal/logging" - - "github.com/labstack/echo/v5" -) - -// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress. -func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c *echo.Context) error { - if isSyncing() { - logging.GetLogger().Info("Syncing is in progress - request blocked") - return echo.NewHTTPError(http.StatusLocked, "Syncing is in progress") - } - return next(c) - } - } -} diff --git a/internal/server/middleware/token_auth.go b/internal/server/middleware/token_auth.go index 47715d4..5b62cef 100644 --- a/internal/server/middleware/token_auth.go +++ b/internal/server/middleware/token_auth.go @@ -9,54 +9,81 @@ import ( "music-server/internal/db/repository" "music-server/internal/logging" - "github.com/labstack/echo/v5" + "github.com/danielgtaylor/huma/v2" "go.uber.org/zap" ) +type tokenKey struct{} + +// Token returns the session token of an authenticated request. +func Token(ctx context.Context) string { + token, _ := ctx.Value(tokenKey{}).(string) + return token +} + // BearerToken extracts the token from an "Authorization: Bearer " header. -func BearerToken(c *echo.Context) (string, bool) { - token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ") +func BearerToken(header string) (string, bool) { + token, ok := strings.CutPrefix(header, "Bearer ") return token, ok && token != "" && !strings.Contains(token, " ") } -// TokenAuth validates session tokens. When disabled is true all requests are let through. -func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c *echo.Context) error { - if disabled { - return next(c) - } - - if c.Request().Header.Get("Authorization") == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "Authorization header required") - } - token, ok := BearerToken(c) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Invalid authorization format. Use: Bearer ") - } - - ctx := c.Request().Context() - session, err := q.GetSession(ctx, token) - if err != nil { - logging.GetLogger().Warn("Invalid token attempt", - zap.String("ip", c.RealIP()), - zap.String("error", err.Error()), - ) - return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired token") - } - - if time.Now().After(session.ExpiresAt.Time) { - // Clean up in the background with a context that outlives the request. - go func(ctx context.Context) { - if err := q.DeleteSession(ctx, token); err != nil { - logging.GetLogger().Error("Failed to delete expired session", zap.Error(err)) - } - }(context.WithoutCancel(ctx)) - return echo.NewHTTPError(http.StatusUnauthorized, "Token expired") - } - - c.Set("session", session) - return next(c) +// TokenAuth validates the session token for operations that declare a security requirement. +// When disabled is true all requests are let through. +func TokenAuth(api huma.API, q repository.Querier, disabled bool) Middleware { + return func(ctx huma.Context, next func(huma.Context)) { + if disabled || len(ctx.Operation().Security) == 0 { + next(ctx) + return } + + header := ctx.Header("Authorization") + if header == "" { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Authorization header required") + return + } + token, ok := BearerToken(header) + if !ok { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid authorization format. Use: Bearer ") + return + } + + session, err := q.GetSession(ctx.Context(), token) + if err != nil { + logging.GetLogger().Warn("Invalid token attempt", zap.String("ip", clientIP(ctx)), zap.Error(err)) + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid or expired token") + return + } + + if time.Now().After(session.ExpiresAt.Time) { + // Clean up in the background with a context that outlives the request. + go func(ctx context.Context) { + if err := q.DeleteSession(ctx, token); err != nil { + logging.GetLogger().Error("Failed to delete expired session", zap.Error(err)) + } + }(context.WithoutCancel(ctx.Context())) + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Token expired") + return + } + + next(huma.WithValue(ctx, tokenKey{}, token)) } } + +type clientKey struct{} + +// Client holds request details that handlers need but that are not part of the API input. +type Client struct { + IP string + UserAgent string +} + +// ClientInfo stores the client's IP and user agent in the request context. +func ClientInfo(ctx huma.Context, next func(huma.Context)) { + next(huma.WithValue(ctx, clientKey{}, Client{IP: clientIP(ctx), UserAgent: ctx.Header("User-Agent")})) +} + +// ClientFrom returns the client details stored by ClientInfo. +func ClientFrom(ctx context.Context) Client { + client, _ := ctx.Value(clientKey{}).(Client) + return client +} diff --git a/internal/server/misc_handler.go b/internal/server/misc_handler.go new file mode 100644 index 0000000..13bc3ff --- /dev/null +++ b/internal/server/misc_handler.go @@ -0,0 +1,99 @@ +package server + +import ( + "context" + "net/http" + + "music-server/internal/backend" + + "github.com/danielgtaylor/huma/v2" +) + +type characterInput struct { + Name string `query:"name" required:"true" doc:"File name of the character image, from the character list"` +} + +type redirectOutput struct { + Status int + Location string `header:"Location" doc:"Download URL of the asset"` +} + +type healthOutput struct { + Body map[string]string +} + +func (s *Server) registerMisc(a apis) { + get := func(id, path, tag, summary, description string, errs ...int) huma.Operation { + return huma.Operation{ + OperationID: id, + Method: http.MethodGet, + Path: path, + Summary: summary, + Description: description, + Tags: []string{tag}, + Errors: errs, + } + } + + register(s, a, route{op: get("get-health", "/health", "server", "Health check", + "Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error."), + public: true, legacyPath: "/health"}, + func(ctx context.Context, _ *struct{}) (*healthOutput, error) { + return &healthOutput{Body: s.db.Health(ctx)}, nil + }) + register(s, a, route{op: get("get-version", "/version", "server", "Latest server version", + "Returns the server version and its changelog."), legacyPath: "/version"}, + func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) { + return ok(backend.GetLatestVersion(), nil) + }) + register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history", + "Returns all server versions with their changelogs, newest first."), legacyPath: "/version/history"}, + func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) { + return ok(backend.GetVersionHistory(), nil) + }) + + register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters", + "Returns the file names of all character images."), legacyPath: "/characters"}, + func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { + return ok(s.characters.List()) + }) + image := get("get-character", "/character", "characters", "Character image", + "Returns a character image.", http.StatusBadRequest, http.StatusNotFound) + image.Responses = fileResponses("The image", "image/png", "image/jpeg") + register(s, a, route{op: image, legacyPath: "/character"}, + func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) { + f, err := s.characters.Open(in.Name) + if err != nil { + return nil, apiError(err) + } + return streamOpenFile(f, "", false) + }) + + r := s.releases + register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version", + "Returns the name of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download"}, + func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { + return ok(r.LatestVersion(ctx)) + }) + register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets", + "Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download/list"}, + func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { + return ok(r.AssetNames(ctx)) + }) + download := func(id, path, platform, suffix string) { + op := get(id, path, "download", "Download the latest client for "+platform, + "Redirects to the download URL of the latest MusicPlayer release for "+platform+".", + http.StatusNotFound, http.StatusBadGateway) + op.DefaultStatus = http.StatusFound + register(s, a, route{op: op, legacyPath: path}, + func(ctx context.Context, _ *struct{}) (*redirectOutput, error) { + url, err := r.AssetURL(ctx, suffix) + if err != nil { + return nil, apiError(err) + } + return &redirectOutput{Status: http.StatusFound, Location: url}, nil + }) + } + download("download-client-windows", "/download/windows", "Windows", ".exe") + download("download-client-linux", "/download/linux", "Linux", ".x86_64") +} diff --git a/internal/server/music_handler.go b/internal/server/music_handler.go index b05e703..5de98c6 100644 --- a/internal/server/music_handler.go +++ b/internal/server/music_handler.go @@ -1,240 +1,156 @@ package server import ( + "context" "net/http" - "strconv" "music-server/internal/backend" "music-server/internal/logging" - "github.com/labstack/echo/v5" + "github.com/danielgtaylor/huma/v2" "go.uber.org/zap" ) -type MusicHandler struct { - music *backend.MusicService +type songNumberInput struct { + Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"` } -func NewMusicHandler(music *backend.MusicService) *MusicHandler { - return &MusicHandler{music: music} -} +const audioMPEG = "audio/mpeg" -// GetSong godoc -// @Summary Get a specific song -// @Description Returns the song at the given position in the queue -// @Tags music -// @Produce audio/mpeg -// @Param song query int true "Song number in the queue" -// @Success 200 {file} file -// @Success 206 {file} file -// @Failure 400 {string} string "song must be a number" -// @Failure 404 {string} string "Not Found" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music [get] -func (m *MusicHandler) GetSong(ctx *echo.Context) error { - song, err := strconv.Atoi(ctx.QueryParam("song")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "song must be a number") +func (s *Server) registerMusic(a apis) { + m := s.music + audio := func(id, path, summary, description string, errs ...int) route { + return route{ + op: huma.Operation{ + OperationID: id, + Method: http.MethodGet, + Path: path, + Summary: summary, + Description: description, + Tags: []string{"music"}, + Responses: fileResponses("The song (MP3)", audioMPEG), + Errors: errs, + }, + legacyPath: path, + locked: true, + } } - path, err := m.music.Song(song) - return serveSong(ctx, path, err) -} - -// GetSoundCheckSong godoc -// @Summary Get sound check song -// @Description Returns the sound check song -// @Tags music -// @Produce audio/mpeg -// @Success 200 {file} file -// @Failure 404 {string} string "Not Found" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/soundTest [get] -func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error { - path, err := m.music.SoundCheckSong() - return serveSong(ctx, path, err) -} - -// ResetMusic godoc -// @Summary Reset music state -// @Description Resets the music state -// @Tags music -// @Success 200 -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/reset [get] -func (m *MusicHandler) ResetMusic(ctx *echo.Context) error { - if err := m.music.Reset(ctx.Request().Context()); err != nil { - return songError(err) + status := func(id, method, path, summary, description string) route { + return route{ + op: huma.Operation{ + OperationID: id, + Method: method, + Path: path, + Summary: summary, + Description: description, + Tags: []string{"music"}, + DefaultStatus: http.StatusOK, + }, + legacyPath: path, + locked: true, + } } - return ctx.NoContent(http.StatusOK) -} - -// GetRandomSong godoc -// @Summary Get random song -// @Description Returns a random song -// @Tags music -// @Produce audio/mpeg -// @Success 200 {file} file -// @Failure 404 {string} string "Not Found" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/rand [get] -func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error { - path, err := m.music.RandomSong(ctx.Request().Context()) - return serveSong(ctx, path, err) -} - -// GetRandomSongLowChance godoc -// @Summary Get random song with low chance -// @Description Returns a random song, favouring soundtracks that have been played less -// @Tags music -// @Produce audio/mpeg -// @Success 200 {file} file -// @Failure 404 {string} string "Not Found" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/rand/low [get] -func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error { - path, err := m.music.RandomSongLowChance(ctx.Request().Context()) - return serveSong(ctx, path, err) -} - -// GetRandomSongClassic godoc -// @Summary Get random classic song -// @Description Returns a random song where every song has the same chance -// @Tags music -// @Produce audio/mpeg -// @Success 200 {file} file -// @Failure 404 {string} string "Not Found" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/rand/classic [get] -func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error { - path, err := m.music.RandomSongClassic(ctx.Request().Context()) - return serveSong(ctx, path, err) -} - -// GetSongInfo godoc -// @Summary Get current song info -// @Description Returns information about the current song -// @Tags music -// @Produce json -// @Success 200 {object} backend.SongInfo -// @Router /music/info [get] -func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error { - return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context())) -} - -// GetPlayedSongs godoc -// @Summary Get played songs list -// @Description Returns a list of played songs -// @Tags music -// @Produce json -// @Success 200 {array} backend.SongInfo -// @Router /music/list [get] -func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error { - return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context())) -} - -// GetNextSong godoc -// @Summary Get next song -// @Description Returns the next song in the queue -// @Tags music -// @Produce audio/mpeg -// @Success 200 {file} file -// @Failure 404 {string} string "Not Found" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/next [get] -func (m *MusicHandler) GetNextSong(ctx *echo.Context) error { - path, err := m.music.NextSong() - return serveSong(ctx, path, err) -} - -// GetPreviousSong godoc -// @Summary Get previous song -// @Description Returns the previous song in the queue -// @Tags music -// @Produce audio/mpeg -// @Success 200 {file} file -// @Failure 404 {string} string "Not Found" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/previous [get] -func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error { - path, err := m.music.PreviousSong() - return serveSong(ctx, path, err) -} - -// GetAllSoundtracks godoc -// @Summary Get all soundtracks -// @Description Returns a list of all soundtracks in order -// @Tags music -// @Produce json -// @Success 200 {array} string -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/all/order [get] -func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error { - names, err := m.music.SoundtrackNames(ctx.Request().Context()) - if err != nil { - return songError(err) + list := func(id, path, summary, description string, locked bool) route { + return route{ + op: huma.Operation{ + OperationID: id, + Method: http.MethodGet, + Path: path, + Summary: summary, + Description: description, + Tags: []string{"music"}, + }, + legacyPath: path, + locked: locked, + } } - return ctx.JSON(http.StatusOK, names) -} -// GetAllSoundtracksRandom godoc -// @Summary Get all soundtracks random -// @Description Returns a list of all soundtracks in random order -// @Tags music -// @Produce json -// @Success 200 {array} string -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/all/random [get] -func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error { - names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context()) - if err != nil { - return songError(err) - } - return ctx.JSON(http.StatusOK, names) -} + register(s, a, audio("get-queue-song", "/music", "Play a song from the queue", + "Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound), + func(ctx context.Context, in *songNumberInput) (*huma.StreamResponse, error) { + return streamSong(m.Song(in.Song)) + }) + register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song", + "Returns a random song for checking the volume.", http.StatusNotFound), + func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + return streamSong(m.SoundCheckSong()) + }) + register(s, a, audio("get-random-song", "/music/rand", "Random song", + "Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound), + func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + return streamSong(m.RandomSong(ctx)) + }) + register(s, a, audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played", + "Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound), + func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + return streamSong(m.RandomSongLowChance(ctx)) + }) + register(s, a, audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal", + "Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound), + func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + return streamSong(m.RandomSongClassic(ctx)) + }) + register(s, a, audio("get-next-song", "/music/next", "Next song in the queue", + "Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound), + func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + return streamSong(m.NextSong()) + }) + register(s, a, audio("get-previous-song", "/music/previous", "Previous song in the queue", + "Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound), + func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + return streamSong(m.PreviousSong()) + }) -// PutPlayed godoc -// @Summary Mark song as played -// @Description Marks the song at the given queue position as played -// @Tags music -// @Param song query int true "Song number in the queue" -// @Success 200 -// @Failure 400 {string} string "Bad Request" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/played [put] -func (m *MusicHandler) PutPlayed(ctx *echo.Context) error { - song, err := strconv.Atoi(ctx.QueryParam("song")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "song must be a number") + register(s, a, list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false), + func(ctx context.Context, _ *struct{}) (*jsonBody[backend.SongInfo], error) { + return ok(m.SongInfo(ctx), nil) + }) + register(s, a, list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false), + func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SongInfo], error) { + return ok(m.PlayedSongs(ctx), nil) + }) + randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { + return ok(m.SoundtrackNamesRandom(ctx)) } - logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song)) - if err := m.music.SetPlayed(ctx.Request().Context(), song); err != nil { - return songError(err) - } - return ctx.NoContent(http.StatusOK) -} + register(s, a, list("get-soundtrack-names-random", "/music/all", "Soundtrack names, random order", + "Returns the names of all soundtracks in random order.", true), randomNames) + register(s, a, list("get-soundtrack-names-random-alias", "/music/all/random", "Soundtrack names, random order", + "Same as /music/all.", true), randomNames) + register(s, a, list("get-soundtrack-names", "/music/all/order", "Soundtrack names, sorted", + "Returns the names of all soundtracks in alphabetical order.", true), + func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { + return ok(m.SoundtrackNames(ctx)) + }) -// AddLatestToQue godoc -// @Summary Add latest to queue -// @Description Adds the latest song to the queue -// @Tags music -// @Success 200 -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/addQue [get] -func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error { - m.music.AddLatestToQueue() - return ctx.NoContent(http.StatusOK) -} - -// AddLatestPlayed godoc -// @Summary Add latest to played -// @Description Marks the current song as played -// @Tags music -// @Success 200 -// @Failure 423 {string} string "Syncing is in progress" -// @Router /music/addPlayed [get] -func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error { - if err := m.music.AddLatestPlayed(ctx.Request().Context()); err != nil { - return songError(err) - } - return ctx.NoContent(http.StatusOK) + register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue", + "Clears the queue and reloads the soundtrack list."), + func(ctx context.Context, _ *struct{}) (*noBody, error) { + if err := m.Reset(ctx); err != nil { + return nil, apiError(err) + } + return &noBody{}, nil + }) + register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue", + "Adds the song returned by the last random song request to the end of the queue."), + func(ctx context.Context, _ *struct{}) (*noBody, error) { + m.AddLatestToQueue() + return &noBody{}, nil + }) + register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played", + "Increments the play counters of the current song and its soundtrack."), + func(ctx context.Context, _ *struct{}) (*noBody, error) { + if err := m.AddLatestPlayed(ctx); err != nil { + return nil, apiError(err) + } + return &noBody{}, nil + }) + register(s, a, status("mark-song-played", http.MethodPut, "/music/played", "Mark a queued song as played", + "Increments the play counters of the song at the given queue position. Positions outside the queue are ignored."), + func(ctx context.Context, in *songNumberInput) (*noBody, error) { + logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song)) + if err := m.SetPlayed(ctx, in.Song); err != nil { + return nil, apiError(err) + } + return &noBody{}, nil + }) } diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go new file mode 100644 index 0000000..6f63875 --- /dev/null +++ b/internal/server/openapi_test.go @@ -0,0 +1,260 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "music-server/internal/config" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// loadSpec loads the OpenAPI 3.0 version of the spec the server publishes. +func loadSpec(t *testing.T, e *testEnv) *openapi3.T { + t.Helper() + rec := e.do(http.MethodGet, "/openapi-3.0.json") + require.Equal(t, http.StatusOK, rec.Code) + spec, err := openapi3.NewLoader().LoadFromData(rec.Body.Bytes()) + require.NoError(t, err) + return spec +} + +func TestOpenAPISpecIsValid(t *testing.T) { + e := newTestEnv(t) + spec := loadSpec(t, e) + require.NoError(t, spec.Validate(context.Background())) + + // The 3.1 spec is valid JSON with the same paths. + var spec31 struct { + OpenAPI string `json:"openapi"` + Paths map[string]any `json:"paths"` + } + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/openapi.json").Body.Bytes(), &spec31)) + assert.Equal(t, "3.1.0", spec31.OpenAPI) + assert.Len(t, spec31.Paths, spec.Paths.Len()) +} + +func TestOpenAPIDocumentsTheV1API(t *testing.T) { + spec := loadSpec(t, newTestEnv(t)) + + var operations []string + for path, item := range spec.Paths.Map() { + assert.True(t, strings.HasPrefix(path, "/api/v1/"), "only /api/v1 is documented: %s", path) + for method, op := range item.Operations() { + operations = append(operations, method+" "+path) + + public := path == "/api/v1/health" || (path == "/api/v1/token" && method == http.MethodPost) + if public { + assert.Nil(t, op.Security, "%s %s needs no token", method, path) + } else { + require.NotNil(t, op.Security, "%s %s needs a token", method, path) + assert.Equal(t, openapi3.SecurityRequirements{{bearerAuthName: {}}}, *op.Security) + assert.Contains(t, op.Responses.Map(), "401", "%s %s documents 401", method, path) + } + } + } + slices.Sort(operations) + assert.Equal(t, []string{ + "DELETE /api/v1/token", + "GET /api/v1/character", + "GET /api/v1/character/list", + "GET /api/v1/download", + "GET /api/v1/download/linux", + "GET /api/v1/download/list", + "GET /api/v1/download/windows", + "GET /api/v1/health", + "GET /api/v1/music", + "GET /api/v1/music/addPlayed", + "GET /api/v1/music/addQue", + "GET /api/v1/music/all", + "GET /api/v1/music/all/order", + "GET /api/v1/music/all/random", + "GET /api/v1/music/info", + "GET /api/v1/music/list", + "GET /api/v1/music/next", + "GET /api/v1/music/previous", + "GET /api/v1/music/rand", + "GET /api/v1/music/rand/classic", + "GET /api/v1/music/rand/low", + "GET /api/v1/music/reset", + "GET /api/v1/music/soundTest", + "GET /api/v1/songs/{uuid}", + "GET /api/v1/songs/{uuid}/file", + "GET /api/v1/soundtracks", + "GET /api/v1/soundtracks/{uuid}", + "GET /api/v1/statistics/songs/least-played", + "GET /api/v1/statistics/songs/most-played", + "GET /api/v1/statistics/soundtracks/last-played", + "GET /api/v1/statistics/soundtracks/least-played", + "GET /api/v1/statistics/soundtracks/most-played", + "GET /api/v1/statistics/soundtracks/never-played", + "GET /api/v1/statistics/soundtracks/oldest-played", + "GET /api/v1/statistics/summary", + "GET /api/v1/sync/progress", + "GET /api/v1/version", + "GET /api/v1/version/history", + "POST /api/v1/sync", + "POST /api/v1/sync/full", + "POST /api/v1/token", + "POST /api/v1/token/cleanup", + "PUT /api/v1/music/played", + }, operations, "/sync/reset is hidden and legacy routes are not documented") + + assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type) + assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme) +} + +// specValidator checks responses against the published spec. +type specValidator struct { + t *testing.T + e *testEnv + router routers.Router + seen map[string]bool +} + +func newSpecValidator(t *testing.T, e *testEnv) *specValidator { + spec := loadSpec(t, e) + spec.Servers = openapi3.Servers{{URL: "http://example.com"}} + router, err := gorillamux.NewRouter(spec) + require.NoError(t, err) + return &specValidator{t: t, e: e, router: router, seen: map[string]bool{}} +} + +// check sends a request and validates the response status, headers and body against the spec. +func (v *specValidator) check(method, path, body string, wantStatus int, headers ...string) *httptest.ResponseRecorder { + v.t.Helper() + rec := v.e.doBody(method, path, body, headers...) + require.Equal(v.t, wantStatus, rec.Code, "%s %s: %s", method, path, rec.Body.String()) + + req := httptest.NewRequest(method, path, strings.NewReader(body)) + route, params, err := v.router.FindRoute(req) + require.NoError(v.t, err, "%s %s is not in the spec", method, path) + v.seen[method+" "+route.Path] = true + + input := &openapi3filter.ResponseValidationInput{ + RequestValidationInput: &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route}, + Status: rec.Code, + Header: rec.Header(), + Options: &openapi3filter.Options{IncludeResponseStatus: true}, + } + input.SetBodyBytes(rec.Body.Bytes()) + if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "image/") { + input.Body = io.NopCloser(bytes.NewReader(nil)) + input.Options.ExcludeResponseBody = true + } + assert.NoError(v.t, openapi3filter.ValidateResponse(context.Background(), input), "%s %s", method, path) + return rec +} + +func TestResponsesMatchOpenAPI(t *testing.T) { + gitea := newGitea(t, http.StatusOK) + e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }) + v := newSpecValidator(t, e) + song := e.addSong("Game A", "Song", "0123456789") + songURL := "/api/v1/songs/" + song.Uuid.String() + + // Empty states and errors. + v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusNotFound) + v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music?song=x", "", http.StatusUnprocessableEntity) + v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK) + + // Music. + v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/addQue", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/addPlayed", "", http.StatusOK) + v.check(http.MethodPut, "/api/v1/music/played?song=0", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music?song=0", "", http.StatusPartialContent, "Range", "bytes=0-1") + v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/previous", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/rand/low", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/rand/classic", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/soundTest", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/all", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/all/random", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/all/order", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/music/reset", "", http.StatusOK) + + // Library. + v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound) + v.check(http.MethodGet, songURL, "", http.StatusOK) + v.check(http.MethodGet, songURL+"/file", "", http.StatusOK) + + // Characters, downloads, server info. + v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest) + v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound) + v.check(http.MethodGet, "/api/v1/download", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/download/list", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/download/windows", "", http.StatusFound) + v.check(http.MethodGet, "/api/v1/download/linux", "", http.StatusNotFound) + v.check(http.MethodGet, "/api/v1/health", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK) + + // Statistics (empty database). + for _, path := range []string{ + "/soundtracks/most-played", "/soundtracks/least-played", "/soundtracks/last-played", + "/soundtracks/oldest-played", "/songs/most-played", "/songs/least-played", + } { + v.check(http.MethodGet, "/api/v1/statistics"+path+"?limit=5", "", http.StatusOK) + } + v.check(http.MethodGet, "/api/v1/statistics/soundtracks/never-played", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK) + + // Sync, including the locked state and the result after it finishes. + release := e.blockSync() + v.check(http.MethodPost, "/api/v1/sync", "", http.StatusOK) + v.check(http.MethodPost, "/api/v1/sync/full", "", http.StatusLocked) + v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusLocked) + v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK) + release() + v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK) + + // Tokens and authentication. + auth := newTestEnv(t, withAuth("secret")) + va := newSpecValidator(t, auth) + va.check(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`, http.StatusUnauthorized) + rec := va.check(http.MethodPost, "/api/v1/token", `{"password":"secret","client_type":"api"}`, http.StatusOK) + var token TokenResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &token)) + bearer := []string{"Authorization", "Bearer " + token.Token} + va.check(http.MethodGet, "/api/v1/version", "", http.StatusUnauthorized) + va.check(http.MethodPost, "/api/v1/token/cleanup", "", http.StatusOK, bearer...) + va.check(http.MethodDelete, "/api/v1/token", "", http.StatusOK, bearer...) + + // Statistics with data, through the canned statistics store. + stats := newTestEnv(t) + stats.server.stats = statsService() + stats.handler = stats.server.RegisterRoutes() + vs := newSpecValidator(t, stats) + vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK) + vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK) + + // Every documented operation was checked at least once (statistics and tokens use other environments). + spec := loadSpec(t, e) + for path, item := range spec.Paths.Map() { + for method := range item.Operations() { + key := method + " " + path + assert.True(t, v.seen[key] || va.seen[key] || vs.seen[key], "no response check for %s", key) + } + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index fe54543..9d2f998 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -2,9 +2,7 @@ package server import ( "net/http" - "time" - _ "music-server/cmd/docs" // registers the OpenAPI spec "music-server/cmd/web" "music-server/internal/logging" "music-server/internal/server/middleware" @@ -12,51 +10,9 @@ import ( "github.com/a-h/templ" "github.com/labstack/echo/v5" echoMiddleware "github.com/labstack/echo/v5/middleware" - echoSwagger "github.com/swaggo/echo-swagger/v2" - "github.com/swaggo/swag" "go.uber.org/zap" ) -// @title MusicServer API -// @version 1.0 -// @description API for the MusicServer application -// @termsOfService http://sanplex.xyz/terms/ - -// @contact.name Sebastian Olsson -// @contact.email zarnor91@gmail.com - -// @license.name MIT -// @license.url http://opensource.org/licenses/MIT - -// @BasePath / - -// handlers holds all HTTP handlers. -type handlers struct { - health *HealthHandler - version *VersionHandler - character *CharacterHandler - download *DownloadHandler - sync *SyncHandler - music *MusicHandler - library *LibraryHandler - 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), - library: NewLibraryHandler(s.library), - statistics: NewStatisticsHandler(s.stats), - token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth), - } -} - func (s *Server) RegisterRoutes() http.Handler { e := echo.New() e.HTTPErrorHandler = httpErrorHandler @@ -78,102 +34,35 @@ func (s *Server) RegisterRoutes() http.Handler { MaxAge: 300, })) - e.GET("/openapi", serveOpenAPI) - e.GET("/swagger/*", echoSwagger.WrapHandler) + // The API: /api/v1 is documented at /docs and /openapi.json; legacy routes are hidden. + a := newAPIs(e) + a.v1.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a.v1, s.q, s.cfg.DisableSessionAuth)) + a.legacy.UseMiddleware(middleware.ClientInfo, middleware.Deprecation) + s.registerToken(a) + s.registerMisc(a) + s.registerSync(a) + s.registerMusic(a) + s.registerLibrary(a) + s.registerStatistics(a) + // Older links to the spec and docs. + e.GET("/openapi", redirect("/openapi.json")) + e.GET("/swagger/*", redirect("/docs")) + + // Search page and frontend. e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets)))) e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm()))) e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search))) e.Static("/", s.cfg.FrontendPath) - h := s.handlers() - syncLock := middleware.SyncCheck(s.sync.IsSyncing) - - // Legacy endpoints (deprecated - use /api/v1/ instead) - legacy := e.Group("", middleware.DeprecationMiddleware) - registerCommon(legacy, h, syncLock) - legacy.GET("/health", h.health.HealthCheck) - legacy.GET("/characters", h.character.GetCharacterList) - - // API v1 - apiV1 := e.Group("/api/v1") - tokenGroup := apiV1.Group("/token") - tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute)) - tokenGroup.DELETE("", h.token.DeleteTokenHandler) - - protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth)) - protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler) - registerCommon(protected, h, syncLock) - protected.GET("/character/list", h.character.GetCharacterList) - - protected.GET("/soundtracks", h.library.GetSoundtracks) - protected.GET("/soundtracks/:uuid", h.library.GetSoundtrack) - protected.GET("/songs/:uuid", h.library.GetSong) - protected.GET("/songs/:uuid/file", h.library.GetSongFile, syncLock) - - stats := protected.Group("/statistics") - stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks) - stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks) - stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks) - stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks) - stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks) - stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs) - stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs) - stats.GET("/summary", h.statistics.GetStatisticsSummary) - for _, r := range e.Router().Routes() { logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path)) } return e } -// registerCommon registers the routes shared by the legacy and the v1 API. -// syncLock is applied to routes that must not run during a sync. -func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) { - g.GET("/version", h.version.GetLatestVersion) - g.GET("/version/history", h.version.GetVersionHistory) - - g.GET("/character", h.character.GetCharacter) - - g.GET("/download", h.download.CheckLatest) - g.GET("/download/list", h.download.ListAssetsOfLatest) - g.GET("/download/windows", h.download.DownloadLatestWindows) - g.GET("/download/linux", h.download.DownloadLatestLinux) - - g.GET("/sync/progress", h.sync.SyncProgress) - for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} { - g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock) +func redirect(to string) echo.HandlerFunc { + return func(c *echo.Context) error { + return c.Redirect(http.StatusMovedPermanently, to) } - 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)) } diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 3811d15..f7afad1 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -28,7 +28,7 @@ func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "a") for _, prefix := range []string{"", "/api/v1"} { - for _, path := range []string{"/sync", "/sync/new", "/sync/quick", "/sync/full", "/sync/new/full", "/sync/reset"} { + for _, path := range []string{"/sync", "/sync/full", "/sync/reset"} { // GET falls through to the frontend's static files, which answer 404. assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path) } @@ -99,21 +99,33 @@ func TestHealthEndpoint(t *testing.T) { assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) } -func TestOpenAPIAndSwagger(t *testing.T) { +func TestSyncAliasesAreRemoved(t *testing.T) { e := newTestEnv(t) - - for _, path := range []string{"/openapi", "/swagger/doc.json"} { - rec := e.do(http.MethodGet, path) - require.Equal(t, http.StatusOK, rec.Code, path) - var spec struct { - Paths map[string]map[string]any `json:"paths"` + for _, prefix := range []string{"", "/api/v1"} { + for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} { + assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path) } - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec), path) - assert.Contains(t, spec.Paths, "/api/v1/token", path) - assert.Contains(t, spec.Paths["/sync/reset"], "post", path) } + assert.False(t, e.server.sync.IsSyncing()) +} - assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/swagger/index.html").Code) +func TestV1HealthEndpoint(t *testing.T) { + e := newTestEnv(t) + rec := e.do(http.MethodGet, "/api/v1/health") + assert.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) +} + +func TestDocsAndSpecLinks(t *testing.T) { + e := newTestEnv(t) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code) + for _, path := range []string{"/openapi.json", "/openapi.yaml", "/openapi-3.0.json"} { + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path) + } + rec := e.do(http.MethodGet, "/openapi") + assert.Equal(t, http.StatusMovedPermanently, rec.Code) + assert.Equal(t, "/openapi.json", rec.Header().Get("Location")) + assert.Equal(t, "/docs", e.do(http.MethodGet, "/swagger/index.html").Header().Get("Location")) } func TestSearchPageAndFrontend(t *testing.T) { diff --git a/internal/server/server.go b/internal/server/server.go index 3e04a76..9cade88 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,7 +18,7 @@ import ( // Database is the database connection used by the server. type Database interface { - HealthChecker + Health(ctx context.Context) map[string]string Close() } diff --git a/internal/server/statistics_handler.go b/internal/server/statistics_handler.go index a527dbe..a7563cc 100644 --- a/internal/server/statistics_handler.go +++ b/internal/server/statistics_handler.go @@ -1,188 +1,71 @@ package server import ( + "context" "net/http" - "strconv" "music-server/internal/backend" - "music-server/internal/logging" - "github.com/labstack/echo/v5" - "go.uber.org/zap" + "github.com/danielgtaylor/huma/v2" ) -const ( - defaultStatsLimit = 10 - maxStatsLimit = 100 -) +const maxStatsLimit = 100 -// StatisticsHandler handles statistics-related HTTP requests -type StatisticsHandler struct { - stats *backend.StatisticsService +type limitInput struct { + Limit int `query:"limit" default:"10" minimum:"1" doc:"Number of results. Values above 100 are capped to 100."` } -// NewStatisticsHandler creates a new StatisticsHandler -func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler { - return &StatisticsHandler{stats: stats} +func (in *limitInput) capped() int32 { + return int32(min(in.Limit, maxStatsLimit)) } -// 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 +func (s *Server) registerStatistics(a apis) { + st := s.stats + op := func(id, path, summary, description string) route { + return route{op: huma.Operation{ + OperationID: id, + Method: http.MethodGet, + Path: "/statistics" + path, + Summary: summary, + Description: description, + Tags: []string{"statistics"}, + }} } - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - return 0, false + soundtracks := func(id, path, summary, description string, + query func(context.Context, int32) ([]backend.SoundtrackWithSongs, error)) { + register(s, a, op(id, path, summary, description), + func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SoundtrackWithSongs], error) { + return ok(query(ctx, in.capped())) + }) } - 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 echo.NewHTTPError(http.StatusBadRequest, "Invalid limit parameter") + songs := func(id, path, summary, description string, + query func(context.Context, int32) ([]backend.SongInfoForStats, error)) { + register(s, a, op(id, path, summary, description), + func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SongInfoForStats], error) { + return ok(query(ctx, in.capped())) + }) } - 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 echo.NewHTTPError(http.StatusInternalServerError, "Failed to get statistics") - } - return ctx.JSON(http.StatusOK, result) -} + soundtracks("get-most-played-soundtracks", "/soundtracks/most-played", "Most played soundtracks", + "Returns the most played soundtracks with their songs.", st.MostPlayedSoundtracks) + soundtracks("get-least-played-soundtracks", "/soundtracks/least-played", "Least played soundtracks", + "Returns the least played soundtracks with their songs.", st.LeastPlayedSoundtracks) + soundtracks("get-last-played-soundtracks", "/soundtracks/last-played", "Recently played soundtracks", + "Returns the most recently played soundtracks with their songs.", st.LastPlayedSoundtracks) + soundtracks("get-oldest-played-soundtracks", "/soundtracks/oldest-played", "Least recently played soundtracks", + "Returns the soundtracks played longest ago (played at least once) with their songs.", st.OldestPlayedSoundtracks) + songs("get-most-played-songs", "/songs/most-played", "Most played songs", + "Returns the most played songs with their soundtrack.", st.MostPlayedSongs) + songs("get-least-played-songs", "/songs/least-played", "Least played songs", + "Returns the least played songs with their soundtrack.", st.LeastPlayedSongs) -// GetMostPlayedSoundtracks returns top N most played soundtracks with songs -// -// @Summary Get most played soundtracks -// @Description Returns the top N most played soundtracks with their songs -// @Tags statistics -// @Produce json -// @Param limit query int false "Number of results (default: 10, max: 100)" -// @Success 200 {array} backend.SoundtrackWithSongs -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/statistics/soundtracks/most-played [get] -func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error { - return withLimit(ctx, "most played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) { - return h.stats.MostPlayedSoundtracks(c.Request().Context(), limit) - }) -} - -// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs -// -// @Summary Get least played soundtracks -// @Description Returns the top N least played soundtracks with their songs -// @Tags statistics -// @Produce json -// @Param limit query int false "Number of results (default: 10, max: 100)" -// @Success 200 {array} backend.SoundtrackWithSongs -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/statistics/soundtracks/least-played [get] -func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error { - return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) { - return h.stats.LeastPlayedSoundtracks(c.Request().Context(), limit) - }) -} - -// GetMostPlayedSongs returns top N most played songs -// -// @Summary Get most played songs -// @Description Returns the top N most played songs with their soundtrack info -// @Tags statistics -// @Produce json -// @Param limit query int false "Number of results (default: 10, max: 100)" -// @Success 200 {array} backend.SongInfoForStats -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/statistics/songs/most-played [get] -func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error { - return withLimit(ctx, "most played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) { - return h.stats.MostPlayedSongs(c.Request().Context(), limit) - }) -} - -// GetLeastPlayedSongs returns top N least played songs -// -// @Summary Get least played songs -// @Description Returns the top N least played songs with their soundtrack info -// @Tags statistics -// @Produce json -// @Param limit query int false "Number of results (default: 10, max: 100)" -// @Success 200 {array} backend.SongInfoForStats -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/statistics/songs/least-played [get] -func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error { - return withLimit(ctx, "least played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) { - return h.stats.LeastPlayedSongs(c.Request().Context(), limit) - }) -} - -// GetLastPlayedSoundtracks returns the most recently played soundtracks -// -// @Summary Get last played soundtracks -// @Description Returns the most recently played soundtracks with their songs -// @Tags statistics -// @Produce json -// @Param limit query int false "Number of results (default: 10, max: 100)" -// @Success 200 {array} backend.SoundtrackWithSongs -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/statistics/soundtracks/last-played [get] -func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error { - return withLimit(ctx, "last played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) { - return h.stats.LastPlayedSoundtracks(c.Request().Context(), limit) - }) -} - -// GetOldestPlayedSoundtracks returns the least recently played soundtracks -// -// @Summary Get oldest played soundtracks -// @Description Returns the least recently played soundtracks with their songs -// @Tags statistics -// @Produce json -// @Param limit query int false "Number of results (default: 10, max: 100)" -// @Success 200 {array} backend.SoundtrackWithSongs -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/statistics/soundtracks/oldest-played [get] -func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error { - return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) { - return h.stats.OldestPlayedSoundtracks(c.Request().Context(), limit) - }) -} - -// GetNeverPlayedSoundtracks returns soundtracks that have never been played -// -// @Summary Get never played soundtracks -// @Description Returns all soundtracks that have never been played -// @Tags statistics -// @Produce json -// @Success 200 {array} backend.SoundtrackWithSongs -// @Failure 500 {object} map[string]string -// @Router /api/v1/statistics/soundtracks/never-played [get] -func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error { - result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context()) - return statsResult(ctx, "never played soundtracks", result, err) -} - -// GetStatisticsSummary returns overall statistics -// -// @Summary Get statistics summary -// @Description Returns overall play statistics -// @Tags statistics -// @Produce json -// @Success 200 {object} backend.StatisticsSummary -// @Failure 500 {object} map[string]string -// @Router /api/v1/statistics/summary [get] -func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error { - result, err := h.stats.Summary(ctx.Request().Context()) - return statsResult(ctx, "summary", result, err) + register(s, a, op("get-never-played-soundtracks", "/soundtracks/never-played", "Never played soundtracks", + "Returns all soundtracks that have never been played, with their songs."), + func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SoundtrackWithSongs], error) { + return ok(st.NeverPlayedSoundtracks(ctx)) + }) + register(s, a, op("get-statistics-summary", "/summary", "Statistics summary", "Returns overall play statistics."), + func(ctx context.Context, _ *struct{}) (*jsonBody[backend.StatisticsSummary], error) { + return ok(st.Summary(ctx)) + }) } diff --git a/internal/server/sync_handler.go b/internal/server/sync_handler.go index c1e340a..9e6959f 100644 --- a/internal/server/sync_handler.go +++ b/internal/server/sync_handler.go @@ -1,80 +1,86 @@ package server import ( + "context" "net/http" + "reflect" "music-server/internal/backend" "music-server/internal/logging" - "github.com/labstack/echo/v5" + "github.com/danielgtaylor/huma/v2" ) -type SyncHandler struct { - sync *backend.SyncService -} - -func NewSyncHandler(sync *backend.SyncService) *SyncHandler { - return &SyncHandler{sync: sync} -} - -// SyncProgress godoc -// @Summary Get sync progress -// @Description Returns the progress of the running sync, or the result of the last sync -// @Tags sync -// @Produce json -// @Success 200 {object} backend.SyncResponse -// @Router /sync/progress [get] -func (s *SyncHandler) SyncProgress(ctx *echo.Context) error { - if s.sync.IsSyncing() { - return ctx.JSON(http.StatusOK, s.sync.Progress()) +func (s *Server) registerSync(a apis) { + registry := a.v1.OpenAPI().Components.Schemas + progress := huma.Operation{ + OperationID: "get-sync-progress", + Method: http.MethodGet, + Path: "/sync/progress", + Summary: "Sync progress or result", + Description: "While a sync is running, returns its progress. Otherwise returns the result of the last sync.", + Tags: []string{"sync"}, + Responses: map[string]*huma.Response{ + "200": { + Description: "Progress of the running sync, or the result of the last sync", + Content: map[string]*huma.MediaType{"application/json": {Schema: &huma.Schema{OneOf: []*huma.Schema{ + registry.Schema(reflect.TypeFor[backend.ProgressResponse](), true, ""), + registry.Schema(reflect.TypeFor[backend.SyncResponse](), true, ""), + }}}}, + }, + }, } - return ctx.JSON(http.StatusOK, s.sync.Result()) -} + register(s, a, route{op: progress, legacyPath: "/sync/progress"}, + func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) { + if s.sync.IsSyncing() { + return ok[any](s.sync.Progress(), nil) + } + return ok[any](s.sync.Result(), nil) + }) -// SyncSoundtracksNewOnlyChanges godoc -// @Summary Sync soundtracks with only changes -// @Description Starts syncing soundtracks that have changed -// @Tags sync -// @Produce json -// @Success 200 {string} string "Start syncing soundtracks" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /sync [post] -func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error { - return s.start(ctx, false, "Start syncing soundtracks") -} - -// SyncSoundtracksNewFull godoc -// @Summary Sync all soundtracks fully -// @Description Starts a full sync of all soundtracks -// @Tags sync -// @Produce json -// @Success 200 {string} string "Start syncing soundtracks full" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /sync/full [post] -func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error { - return s.start(ctx, true, "Start syncing soundtracks full") -} - -func (s *SyncHandler) start(ctx *echo.Context, full bool, message string) error { - if !s.sync.Start(full) { - return echo.NewHTTPError(http.StatusLocked, "Syncing is in progress") + start := func(id, path, summary, description string, full bool) { + register(s, a, route{ + op: huma.Operation{ + OperationID: id, + Method: http.MethodPost, + Path: path, + Summary: summary, + Description: description, + Tags: []string{"sync"}, + Errors: []int{http.StatusLocked}, + }, + legacyPath: path, + }, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { + message := "Start syncing soundtracks" + if full { + message += " full" + } + if !s.sync.Start(full) { + return nil, huma.Error423Locked("Syncing is in progress") + } + logging.GetLogger().Info(message) + return ok(message, nil) + }) } - logging.GetLogger().Info(message) - return ctx.JSON(http.StatusOK, message) -} + start("start-sync", "/sync", "Sync changed soundtracks", + "Starts syncing the music folder in the background, skipping soundtracks that have not changed. "+ + "Poll /sync/progress for the result.", false) + start("start-full-sync", "/sync/full", "Sync all soundtracks", + "Starts a full sync in the background that re-reads every soundtrack and removes songs whose files are gone. "+ + "Poll /sync/progress for the result.", true) -// ResetDB godoc -// @Summary Reset soundtracks database -// @Description Resets the soundtracks database by deleting all soundtracks and songs -// @Tags sync -// @Produce json -// @Success 200 {string} string "Soundtracks and songs are deleted from the database" -// @Failure 423 {string} string "Syncing is in progress" -// @Router /sync/reset [post] -func (s *SyncHandler) ResetDB(ctx *echo.Context) error { - logging.GetLogger().Info("Resetting soundtracks database") - if err := s.sync.ResetDB(ctx.Request().Context()); err != nil { - return songError(err) + reset := huma.Operation{ + OperationID: "reset-library", + Method: http.MethodPost, + Path: "/sync/reset", + Summary: "Delete all soundtracks and songs", + Description: "For debugging only.", + Tags: []string{"sync"}, + Hidden: true, } - return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database") + register(s, a, route{op: reset, legacyPath: "/sync/reset", locked: true}, + func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { + logging.GetLogger().Info("Resetting soundtracks database") + return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx)) + }) } diff --git a/internal/server/token_handler.go b/internal/server/token_handler.go index ae477e9..7e96f5f 100644 --- a/internal/server/token_handler.go +++ b/internal/server/token_handler.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/rand" "crypto/subtle" "encoding/base64" @@ -11,8 +12,8 @@ import ( "music-server/internal/logging" "music-server/internal/server/middleware" + "github.com/danielgtaylor/huma/v2" "github.com/jackc/pgx/v5/pgtype" - "github.com/labstack/echo/v5" "go.uber.org/zap" ) @@ -20,27 +21,23 @@ const tokenLifetime = 24 * time.Hour // TokenRequest represents a request to generate a new token type TokenRequest struct { - Password string `json:"password"` // API password, required when API_PASSWORD is set - ClientType string `json:"client_type"` // Optional: "web", "mobile", "api" + Password string `json:"password,omitempty" doc:"API password, required when the server has one configured"` + ClientType string `json:"client_type,omitempty" doc:"Kind of client, for example web, godot or api" default:"web"` } // TokenResponse represents the response with a new token type TokenResponse struct { - Token string `json:"token"` + Token string `json:"token" doc:"Send as Authorization: Bearer "` ExpiresAt time.Time `json:"expires_at"` ClientType string `json:"client_type"` } -// TokenHandler creates and removes session tokens. -type TokenHandler struct { - q repository.Querier - apiPassword string - disableSessionAuth bool +type tokenInput struct { + Body TokenRequest `required:"false"` } -// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password. -func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler { - return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth} +type statusMessage struct { + Status string `json:"status"` } func generateToken() (string, error) { @@ -51,115 +48,84 @@ func generateToken() (string, error) { return base64.URLEncoding.EncodeToString(b), nil } -func (h *TokenHandler) passwordValid(password string) bool { - if h.disableSessionAuth || h.apiPassword == "" { +func (s *Server) passwordValid(password string) bool { + if s.cfg.DisableSessionAuth || s.cfg.APIPassword == "" { return true } - return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1 + return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1 } -// CreateTokenHandler creates a new session token -// -// @Summary Create session token -// @Description Returns a new session token for API access. Requires the API password when one is configured. -// @Tags auth -// @Accept json -// @Produce json -// @Param request body TokenRequest true "Password and client type" -// @Success 200 {object} TokenResponse -// @Failure 400 {object} map[string]string -// @Failure 401 {object} map[string]string -// @Failure 429 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/token [post] -func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error { - var req TokenRequest - if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") +func (s *Server) registerToken(a apis) { + create := huma.Operation{ + OperationID: "create-token", + Method: http.MethodPost, + Path: "/token", + Summary: "Create session token", + Description: "Returns a token for the other /api/v1 endpoints, valid for 24 hours. " + + "Requires the API password when the server has one. Limited to 10 requests per minute per client.", + Tags: []string{"auth"}, + Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests}, + Middlewares: huma.Middlewares{middleware.RateLimit(a.v1, 10, time.Minute)}, } + register(s, a, route{op: create, public: true}, + func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) { + client := middleware.ClientFrom(ctx) + if !s.passwordValid(in.Body.Password) { + logging.GetLogger().Warn("Invalid password attempt for token creation", + zap.String("ip", client.IP), zap.String("user_agent", client.UserAgent)) + return nil, huma.Error401Unauthorized("Invalid password") + } - if !h.passwordValid(req.Password) { - logging.GetLogger().Warn("Invalid password attempt for token creation", - zap.String("ip", c.RealIP()), - zap.String("user_agent", c.Request().UserAgent())) - return echo.NewHTTPError(http.StatusUnauthorized, "Invalid password") - } + clientType := in.Body.ClientType + if clientType == "" { + clientType = "web" + } + token, err := generateToken() + if err != nil { + logging.GetLogger().Error("Failed to generate token", zap.Error(err)) + return nil, huma.Error500InternalServerError("Failed to generate token") + } + session, err := s.q.CreateSession(ctx, repository.CreateSessionParams{ + Token: token, + IpAddress: client.IP, + UserAgent: client.UserAgent, + ClientType: &clientType, + ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true}, + }) + if err != nil { + logging.GetLogger().Error("Failed to create session", zap.Error(err)) + return nil, huma.Error500InternalServerError("Failed to create session") + } + return ok(TokenResponse{Token: session.Token, ExpiresAt: session.ExpiresAt.Time, ClientType: clientType}, nil) + }) - if req.ClientType == "" { - req.ClientType = "web" - } - - token, err := generateToken() - if err != nil { - logging.GetLogger().Error("Failed to generate token", zap.Error(err)) - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token") - } - - session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{ - Token: token, - IpAddress: c.RealIP(), - UserAgent: c.Request().UserAgent(), - ClientType: &req.ClientType, - ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true}, + register(s, a, route{op: huma.Operation{ + OperationID: "delete-token", + Method: http.MethodDelete, + Path: "/token", + Summary: "Invalidate session token", + Description: "Deletes the token used for this request.", + Tags: []string{"auth"}, + }}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) { + if err := s.q.DeleteSession(ctx, middleware.Token(ctx)); err != nil { + logging.GetLogger().Error("Failed to delete session", zap.Error(err)) + return nil, huma.Error500InternalServerError("Failed to invalidate token") + } + return ok(statusMessage{Status: "token invalidated"}, nil) }) - if err != nil { - logging.GetLogger().Error("Failed to create session", zap.Error(err)) - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create session") - } - clientType := req.ClientType - if session.ClientType != nil { - clientType = *session.ClientType - } - return c.JSON(http.StatusOK, TokenResponse{ - Token: session.Token, - ExpiresAt: session.ExpiresAt.Time, - ClientType: clientType, + register(s, a, route{op: huma.Operation{ + OperationID: "cleanup-tokens", + Method: http.MethodPost, + Path: "/token/cleanup", + Summary: "Remove expired tokens", + Description: "Deletes all expired session tokens.", + Tags: []string{"auth"}, + }}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) { + if err := s.q.DeleteExpiredSessions(ctx); err != nil { + logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err)) + return nil, huma.Error500InternalServerError("Failed to cleanup sessions") + } + return ok(statusMessage{Status: "cleanup complete"}, nil) }) } - -// DeleteTokenHandler invalidates a session token -// -// @Summary Invalidate session token -// @Description Deletes the current session token -// @Tags auth -// @Produce json -// @Param Authorization header string true "Bearer token" -// @Success 200 {object} map[string]string -// @Failure 401 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/token [delete] -func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error { - if c.Request().Header.Get("Authorization") == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "Authorization header required") - } - token, ok := middleware.BearerToken(c) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Invalid authorization format") - } - - if err := h.q.DeleteSession(c.Request().Context(), token); err != nil { - logging.GetLogger().Error("Failed to delete session", zap.Error(err)) - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to invalidate token") - } - return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"}) -} - -// CleanupExpiredSessionsHandler removes all expired sessions -// -// @Summary Cleanup expired sessions -// @Description Removes all expired session tokens from the database -// @Tags auth -// @Produce json -// @Param Authorization header string true "Bearer token" -// @Success 200 {object} map[string]string -// @Failure 401 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/token/cleanup [post] -func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error { - if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil { - logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err)) - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to cleanup sessions") - } - return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"}) -} diff --git a/internal/server/version_handler.go b/internal/server/version_handler.go deleted file mode 100644 index 4214bc7..0000000 --- a/internal/server/version_handler.go +++ /dev/null @@ -1,39 +0,0 @@ -package server - -import ( - "net/http" - - "music-server/internal/backend" - - "github.com/labstack/echo/v5" -) - -type VersionHandler struct{} - -func NewVersionHandler() *VersionHandler { - return &VersionHandler{} -} - -// GetVersionHistory godoc -// -// @Summary Getting the version history of the backend -// @Description get version history -// @Tags version -// @Produce json -// @Success 200 {array} backend.VersionData -// @Router /version/history [get] -func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error { - return ctx.JSON(http.StatusOK, backend.GetVersionHistory()) -} - -// GetLatestVersion godoc -// -// @Summary Getting the latest version of the backend -// @Description get latest version info -// @Tags version -// @Produce json -// @Success 200 {object} backend.VersionData -// @Router /version [get] -func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error { - return ctx.JSON(http.StatusOK, backend.GetLatestVersion()) -} diff --git a/internal/testutil/fakedb/fakedb.go b/internal/testutil/fakedb/fakedb.go index cc76b4f..10c8cc0 100644 --- a/internal/testutil/fakedb/fakedb.go +++ b/internal/testutil/fakedb/fakedb.go @@ -372,3 +372,37 @@ func (s *Store) HasSession(token string) bool { _, ok := s.Sessions[token] return ok } + +// Statistics queries return empty results; tests that need data use their own fakes. + +func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) { + return nil, s.Err +} + +func (s *Store) GetLeastPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetLeastPlayedSoundtracksWithSongsRow, error) { + return nil, s.Err +} + +func (s *Store) GetLastPlayedSoundtracks(context.Context, int32) ([]repository.GetLastPlayedSoundtracksRow, error) { + return nil, s.Err +} + +func (s *Store) GetOldestPlayedSoundtracks(context.Context, int32) ([]repository.GetOldestPlayedSoundtracksRow, error) { + return nil, s.Err +} + +func (s *Store) GetNeverPlayedSoundtracks(context.Context) ([]repository.GetNeverPlayedSoundtracksRow, error) { + return nil, s.Err +} + +func (s *Store) GetMostPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetMostPlayedSongsWithSoundtrackRow, error) { + return nil, s.Err +} + +func (s *Store) GetLeastPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetLeastPlayedSongsWithSoundtrackRow, error) { + return nil, s.Err +} + +func (s *Store) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) { + return repository.GetStatisticsSummaryRow{}, s.Err +} diff --git a/justfile b/justfile index 4cc0a4d..289a3bc 100644 --- a/justfile +++ b/justfile @@ -15,12 +15,7 @@ sqlc-generate: migrate-create name: @migrate create -ext sql -dir internal/db/migrations -seq {{name}} -# Generate OpenAPI docs (version pinned in go.mod) -swag-generate: - @echo "Generating OpenAPI docs..." - @go tool swag init -g internal/server/routes.go -o cmd/docs --parseInternal - -build: sqlc-generate templ-build swag-generate +build: sqlc-generate templ-build @echo "Building..." @go build -o main cmd/main.go