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