From 4e5bdc4ee2419acb21b5b458b6e553efe963b4e5 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 14 Jun 2026 11:30:58 +0200 Subject: [PATCH] Fixed some small bugs after merge --- cmd/docs/docs.go | 465 +++++++++++++++++- cmd/docs/swagger.json | 465 +++++++++++++++++- cmd/docs/swagger.yaml | 310 +++++++++++- cmd/web/assets/css/styles.css | 75 ++- cmd/web/hello.templ | 24 +- internal/backend/music_test.go | 72 ++- internal/db/database.go | 21 + internal/db/migration_test.go | 29 +- .../000005_rename_game_to_soundtrack.up.sql | 1 - internal/db/queries/statistics.sql | 4 +- internal/db/repository/models.go | 39 +- internal/db/repository/song.sql.go | 12 +- internal/db/repository/soundtrack.sql.go | 9 +- internal/db/repository/statistics.sql.go | 4 +- internal/db/test_helpers.go | 34 +- internal/server/healthHandler.go | 7 +- internal/server/health_handler_test.go | 7 +- internal/server/routes.go | 33 +- internal/server/statistics_handler_test.go | 15 +- internal/server/test_helpers.go | 7 +- justfile | 13 +- 21 files changed, 1460 insertions(+), 186 deletions(-) diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 848b952..c59c548 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -23,6 +23,385 @@ var doc = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/statistics/games/last-played": { + "get": { + "description": "Returns the most recently played games", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get last played games", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/games/least-played": { + "get": { + "description": "Returns the top N least played games with their songs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get least played games", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/games/most-played": { + "get": { + "description": "Returns the top N most played games with their songs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get most played games", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/games/never-played": { + "get": { + "description": "Returns all games that have never been played (times_played = 0)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get never played games", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/games/oldest-played": { + "get": { + "description": "Returns the least recently played games (that have been played at least once)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get oldest played games", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "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/least-played": { + "get": { + "description": "Returns the top N least played songs with their game info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get least played songs", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "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 game info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get most played songs", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "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/summary": { + "get": { + "description": "Returns overall statistics about the music library", + "consumes": [ + "application/json" + ], + "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", @@ -455,7 +834,7 @@ var doc = `{ "tags": [ "music" ], - "summary": "Get all games", + "summary": "Get all soundtracks", "responses": { "200": { "description": "OK", @@ -488,7 +867,7 @@ var doc = `{ "tags": [ "music" ], - "summary": "Get all games random", + "summary": "Get all soundtracks random", "responses": { "200": { "description": "OK", @@ -828,10 +1207,10 @@ var doc = `{ "tags": [ "sync" ], - "summary": "Sync games with only changes", + "summary": "Sync soundtracks with only changes", "responses": { "200": { - "description": "Start syncing games", + "description": "Start syncing soundtracks", "schema": { "type": "string" } @@ -860,7 +1239,7 @@ var doc = `{ "summary": "Sync all games fully", "responses": { "200": { - "description": "Start syncing games full", + "description": "Start syncing soundtracks full", "schema": { "type": "string" } @@ -910,10 +1289,10 @@ var doc = `{ "tags": [ "sync" ], - "summary": "Reset games database", + "summary": "Reset soundtracks database", "responses": { "200": { - "description": "Games and songs are deleted from the database", + "description": "Soundtracks and songs are deleted from the database", "schema": { "type": "string" } @@ -990,6 +1369,78 @@ var doc = `{ } }, "definitions": { + "backend.GameWithSongs": { + "type": "object", + "properties": { + "game_id": { + "type": "integer" + }, + "game_last_played": { + "type": "string" + }, + "game_name": { + "type": "string" + }, + "game_played": { + "type": "integer" + }, + "songs": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.SongInfoForStats" + } + } + } + }, + "backend.SongInfoForStats": { + "type": "object", + "properties": { + "file_name": { + "type": "string" + }, + "game_id": { + "type": "integer" + }, + "game_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "song_name": { + "type": "string" + }, + "times_played": { + "type": "integer" + } + } + }, + "backend.StatisticsSummary": { + "type": "object", + "properties": { + "avg_game_plays": { + "type": "number" + }, + "max_game_plays": { + "type": "integer" + }, + "min_game_plays": { + "type": "integer" + }, + "never_played_games": { + "type": "integer" + }, + "played_games": { + "type": "integer" + }, + "total_game_plays": { + "type": "integer" + }, + "total_games": { + "type": "integer" + } + } + }, "backend.VersionData": { "type": "object", "properties": { diff --git a/cmd/docs/swagger.json b/cmd/docs/swagger.json index cde977f..e283757 100644 --- a/cmd/docs/swagger.json +++ b/cmd/docs/swagger.json @@ -4,6 +4,385 @@ "contact": {} }, "paths": { + "/api/v1/statistics/games/last-played": { + "get": { + "description": "Returns the most recently played games", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get last played games", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/games/least-played": { + "get": { + "description": "Returns the top N least played games with their songs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get least played games", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/games/most-played": { + "get": { + "description": "Returns the top N most played games with their songs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get most played games", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/games/never-played": { + "get": { + "description": "Returns all games that have never been played (times_played = 0)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get never played games", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/games/oldest-played": { + "get": { + "description": "Returns the least recently played games (that have been played at least once)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get oldest played games", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.GameWithSongs" + } + } + }, + "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/least-played": { + "get": { + "description": "Returns the top N least played songs with their game info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get least played songs", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "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 game info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get most played songs", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "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/summary": { + "get": { + "description": "Returns overall statistics about the music library", + "consumes": [ + "application/json" + ], + "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", @@ -436,7 +815,7 @@ "tags": [ "music" ], - "summary": "Get all games", + "summary": "Get all soundtracks", "responses": { "200": { "description": "OK", @@ -469,7 +848,7 @@ "tags": [ "music" ], - "summary": "Get all games random", + "summary": "Get all soundtracks random", "responses": { "200": { "description": "OK", @@ -809,10 +1188,10 @@ "tags": [ "sync" ], - "summary": "Sync games with only changes", + "summary": "Sync soundtracks with only changes", "responses": { "200": { - "description": "Start syncing games", + "description": "Start syncing soundtracks", "schema": { "type": "string" } @@ -841,7 +1220,7 @@ "summary": "Sync all games fully", "responses": { "200": { - "description": "Start syncing games full", + "description": "Start syncing soundtracks full", "schema": { "type": "string" } @@ -891,10 +1270,10 @@ "tags": [ "sync" ], - "summary": "Reset games database", + "summary": "Reset soundtracks database", "responses": { "200": { - "description": "Games and songs are deleted from the database", + "description": "Soundtracks and songs are deleted from the database", "schema": { "type": "string" } @@ -971,6 +1350,78 @@ } }, "definitions": { + "backend.GameWithSongs": { + "type": "object", + "properties": { + "game_id": { + "type": "integer" + }, + "game_last_played": { + "type": "string" + }, + "game_name": { + "type": "string" + }, + "game_played": { + "type": "integer" + }, + "songs": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.SongInfoForStats" + } + } + } + }, + "backend.SongInfoForStats": { + "type": "object", + "properties": { + "file_name": { + "type": "string" + }, + "game_id": { + "type": "integer" + }, + "game_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "song_name": { + "type": "string" + }, + "times_played": { + "type": "integer" + } + } + }, + "backend.StatisticsSummary": { + "type": "object", + "properties": { + "avg_game_plays": { + "type": "number" + }, + "max_game_plays": { + "type": "integer" + }, + "min_game_plays": { + "type": "integer" + }, + "never_played_games": { + "type": "integer" + }, + "played_games": { + "type": "integer" + }, + "total_game_plays": { + "type": "integer" + }, + "total_games": { + "type": "integer" + } + } + }, "backend.VersionData": { "type": "object", "properties": { diff --git a/cmd/docs/swagger.yaml b/cmd/docs/swagger.yaml index 242592d..27a9895 100644 --- a/cmd/docs/swagger.yaml +++ b/cmd/docs/swagger.yaml @@ -1,4 +1,51 @@ definitions: + backend.GameWithSongs: + properties: + game_id: + type: integer + game_last_played: + type: string + game_name: + type: string + game_played: + type: integer + songs: + items: + $ref: '#/definitions/backend.SongInfoForStats' + type: array + type: object + backend.SongInfoForStats: + properties: + file_name: + type: string + game_id: + type: integer + game_name: + type: string + path: + type: string + song_name: + type: string + times_played: + type: integer + type: object + backend.StatisticsSummary: + properties: + avg_game_plays: + type: number + max_game_plays: + type: integer + min_game_plays: + type: integer + never_played_games: + type: integer + played_games: + type: integer + total_game_plays: + type: integer + total_games: + type: integer + type: object backend.VersionData: properties: changelog: @@ -30,6 +77,255 @@ definitions: info: contact: {} paths: + /api/v1/statistics/games/last-played: + get: + consumes: + - application/json + description: Returns the most recently played games + parameters: + - description: 'Number of results (default: 10)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.GameWithSongs' + 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 games + tags: + - statistics + /api/v1/statistics/games/least-played: + get: + consumes: + - application/json + description: Returns the top N least played games with their songs + parameters: + - description: 'Number of results (default: 10)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.GameWithSongs' + 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 games + tags: + - statistics + /api/v1/statistics/games/most-played: + get: + consumes: + - application/json + description: Returns the top N most played games with their songs + parameters: + - description: 'Number of results (default: 10)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.GameWithSongs' + 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 games + tags: + - statistics + /api/v1/statistics/games/never-played: + get: + consumes: + - application/json + description: Returns all games that have never been played (times_played = 0) + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.GameWithSongs' + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get never played games + tags: + - statistics + /api/v1/statistics/games/oldest-played: + get: + consumes: + - application/json + description: Returns the least recently played games (that have been played + at least once) + parameters: + - description: 'Number of results (default: 10)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.GameWithSongs' + 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 games + tags: + - statistics + /api/v1/statistics/songs/least-played: + get: + consumes: + - application/json + description: Returns the top N least played songs with their game info + parameters: + - description: 'Number of results (default: 10)' + 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: + consumes: + - application/json + description: Returns the top N most played songs with their game info + parameters: + - description: 'Number of results (default: 10)' + 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/summary: + get: + consumes: + - application/json + description: Returns overall statistics about the music library + 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: consumes: @@ -325,7 +621,7 @@ paths: description: Syncing is in progress schema: type: string - summary: Get all games + summary: Get all soundtracks tags: - music /music/all/random: @@ -347,7 +643,7 @@ paths: description: Syncing is in progress schema: type: string - summary: Get all games random + summary: Get all soundtracks random tags: - music /music/info: @@ -561,14 +857,14 @@ paths: - application/json responses: "200": - description: Start syncing games + description: Start syncing soundtracks schema: type: string "423": description: Syncing is in progress schema: type: string - summary: Sync games with only changes + summary: Sync soundtracks with only changes tags: - sync /sync/full: @@ -580,7 +876,7 @@ paths: - application/json responses: "200": - description: Start syncing games full + description: Start syncing soundtracks full schema: type: string "423": @@ -615,14 +911,14 @@ paths: - application/json responses: "200": - description: Games and songs are deleted from the database + description: Soundtracks and songs are deleted from the database schema: type: string "423": description: Syncing is in progress schema: type: string - summary: Reset games database + summary: Reset soundtracks database tags: - sync /version: diff --git a/cmd/web/assets/css/styles.css b/cmd/web/assets/css/styles.css index 0b889da..12027fe 100644 --- a/cmd/web/assets/css/styles.css +++ b/cmd/web/assets/css/styles.css @@ -1,5 +1,33 @@ /* Pure CSS styles for Music Search */ +:root { + /* Light mode colors (default) */ + --bg-primary: #f3f4f6; + --bg-secondary: #e5e7eb; + --bg-tertiary: #dcfce7; + --text-primary: #000; + --text-secondary: #374151; + --border-primary: #9ca3af; + --border-focus: #6b7280; + --accent-primary: #f97316; + --accent-hover: #ea580c; + --shadow-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] { + /* Dark mode colors matching frontend */ + --bg-primary: #555; + --bg-secondary: #333; + --bg-tertiary: #2a2a2a; + --text-primary: #fff; + --text-secondary: #ff9c00; + --border-primary: #666; + --border-focus: #ff9c00; + --accent-primary: #ff9c00; + --accent-hover: #e68a00; + --shadow-color: rgba(0, 0, 0, 0.3); +} + * { box-sizing: border-box; margin: 0; @@ -10,7 +38,9 @@ html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.5; - background-color: #f3f4f6; + background-color: var(--bg-primary); + color: var(--text-primary); + transition: background-color 0.3s ease, color 0.3s ease; } main { @@ -29,15 +59,15 @@ main { max-width: 600px; font-size: 1.5rem; padding: 0.5rem; - border: 1px solid #9ca3af; + border: 1px solid var(--border-primary); border-radius: 0.5rem; - background-color: #e5e7eb; - color: #000; + background-color: var(--bg-secondary); + color: var(--text-primary); } #search_term:focus { outline: none; - border-color: #6b7280; + border-color: var(--border-focus); } #clear { @@ -45,23 +75,48 @@ main { padding: 0.5rem 1rem; border: none; border-radius: 0.5rem; - background-color: #f97316; - color: #fff; + background-color: var(--accent-primary); + color: var(--text-primary); cursor: pointer; margin-left: 1rem; } #clear:hover { - background-color: #ea580c; + background-color: var(--accent-hover); } #games-container { font-size: 1.5rem; } +.game-text { + color: var(--text-primary); + word-break: break-word; +} + +/* Dark mode toggle */ +#dark-mode-toggle { + position: fixed; + top: 1rem; + right: 1rem; + font-size: 1.2rem; + padding: 0.4rem 0.8rem; + border: none; + border-radius: 0.5rem; + background-color: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + z-index: 1000; + transition: all 0.3s ease; +} + +#dark-mode-toggle:hover { + background-color: var(--border-primary); +} + /* Game result cards */ .bg-green-100 { - background-color: #dcfce7; + background-color: var(--bg-tertiary); } .p-4 { @@ -69,7 +124,7 @@ main { } .shadow-md { - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 6px -1px var(--shadow-color), 0 2px 4px -2px var(--shadow-color); } .rounded-lg { diff --git a/cmd/web/hello.templ b/cmd/web/hello.templ index 564fd07..bcbef89 100644 --- a/cmd/web/hello.templ +++ b/cmd/web/hello.templ @@ -2,6 +2,7 @@ package web templ HelloForm() { @Base() { +
@@ -12,8 +13,29 @@ templ HelloForm() { if (document.readyState == 'complete') { htmx.ajax('POST', '/find', '#games-container'); document.getElementById("search_term").focus(); + + // Initialize dark mode from localStorage (default to dark) + const savedTheme = localStorage.getItem('theme') || 'dark'; + if (savedTheme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + document.getElementById('dark-mode-toggle').textContent = '☀️'; + } } }); + + // Dark mode toggle functionality + document.getElementById("dark-mode-toggle").addEventListener("click", function() { + const html = document.documentElement; + const currentTheme = html.getAttribute('data-theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + html.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + + // Update toggle button text + this.textContent = newTheme === 'dark' ? '☀️' : '🌙'; + }); + document.getElementById("clear").addEventListener("click", function (event) { document.getElementById("search_term").value = ""; htmx.ajax('POST', '/find', '#games-container'); @@ -26,7 +48,7 @@ templ HelloForm() { templ FoundGames(games []string) { for _, game := range games {
-

{ game }

+

{ game }

} } diff --git a/internal/backend/music_test.go b/internal/backend/music_test.go index 491f4e1..d670597 100644 --- a/internal/backend/music_test.go +++ b/internal/backend/music_test.go @@ -9,10 +9,10 @@ import ( // Test the average calculation logic directly without database access func TestCalculateAverage(t *testing.T) { - games := []repository.Game{ - {GameName: "Game1", TimesPlayed: 10}, - {GameName: "Game2", TimesPlayed: 20}, - {GameName: "Game3", TimesPlayed: 30}, + games := []repository.Soundtrack{ + {SoundtrackName: "Game1", TimesPlayed: 10}, + {SoundtrackName: "Game2", TimesPlayed: 20}, + {SoundtrackName: "Game3", TimesPlayed: 30}, } var sum int32 @@ -28,7 +28,7 @@ func TestCalculateAverage(t *testing.T) { } func TestCalculateAverageEmpty(t *testing.T) { - games := []repository.Game{} + games := []repository.Soundtrack{} if len(games) == 0 { result := int32(0) @@ -52,8 +52,8 @@ func TestCalculateAverageEmpty(t *testing.T) { } func TestCalculateAverageSingle(t *testing.T) { - games := []repository.Game{ - {GameName: "Game1", TimesPlayed: 42}, + games := []repository.Soundtrack{ + {SoundtrackName: "Game1", TimesPlayed: 42}, } var sum int32 @@ -69,10 +69,10 @@ func TestCalculateAverageSingle(t *testing.T) { } func TestGetRandomGame(t *testing.T) { - games := []repository.Game{ - {GameName: "Game1", TimesPlayed: 10}, - {GameName: "Game2", TimesPlayed: 20}, - {GameName: "Game3", TimesPlayed: 30}, + games := []repository.Soundtrack{ + {SoundtrackName: "Game1", TimesPlayed: 10}, + {SoundtrackName: "Game2", TimesPlayed: 20}, + {SoundtrackName: "Game3", TimesPlayed: 30}, } // Set seed for reproducible tests @@ -80,93 +80,93 @@ func TestGetRandomGame(t *testing.T) { result := games[rand.Intn(len(games))] - if result.GameName == "" { + if result.SoundtrackName == "" { t.Error("random game selection returned empty game") } found := false for _, g := range games { - if g.GameName == result.GameName { + if g.SoundtrackName == result.SoundtrackName { found = true break } } if !found { - t.Errorf("random game selection returned game not in list: %v", result.GameName) + t.Errorf("random game selection returned game not in list: %v", result.SoundtrackName) } } func TestFindGameByID(t *testing.T) { - games := []repository.Game{ - {ID: 1, GameName: "Game1", TimesPlayed: 10}, - {ID: 2, GameName: "Game2", TimesPlayed: 20}, - {ID: 3, GameName: "Game3", TimesPlayed: 30}, + games := []repository.Soundtrack{ + {ID: 1, SoundtrackName: "Game1", TimesPlayed: 10}, + {ID: 2, SoundtrackName: "Game2", TimesPlayed: 20}, + {ID: 3, SoundtrackName: "Game3", TimesPlayed: 30}, } tests := []struct { name string - games []repository.Game + games []repository.Soundtrack gameID int32 - expected repository.Game + expected repository.Soundtrack }{ { name: "existing game", games: games, gameID: 2, - expected: repository.Game{ID: 2, GameName: "Game2", TimesPlayed: 20}, + expected: repository.Soundtrack{ID: 2, SoundtrackName: "Game2", TimesPlayed: 20}, }, { name: "non-existing game", games: games, gameID: 99, - expected: repository.Game{}, + expected: repository.Soundtrack{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var result repository.Game + var result repository.Soundtrack for _, game := range tt.games { if game.ID == tt.gameID { result = game break } } - if result.ID != tt.expected.ID || result.GameName != tt.expected.GameName { + if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName { t.Errorf("findGameByID() = %v, want %v", result, tt.expected) } }) } } -func TestExtractGameNames(t *testing.T) { - games := []repository.Game{ - {GameName: "Game1", TimesPlayed: 10}, - {GameName: "Game2", TimesPlayed: 20}, - {GameName: "Game3", TimesPlayed: 30}, +func TestExtractSoundtrackNames(t *testing.T) { + games := []repository.Soundtrack{ + {SoundtrackName: "Game1", TimesPlayed: 10}, + {SoundtrackName: "Game2", TimesPlayed: 20}, + {SoundtrackName: "Game3", TimesPlayed: 30}, } var result []string for _, game := range games { - result = append(result, game.GameName) + result = append(result, game.SoundtrackName) } expected := []string{"Game1", "Game2", "Game3"} if len(result) != len(expected) { - t.Errorf("extractGameNames() length = %d, want %d", len(result), len(expected)) + t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected)) return } for i, v := range result { if v != expected[i] { - t.Errorf("extractGameNames()[%d] = %v, want %v", i, v, expected[i]) + t.Errorf("extractSoundtrackNames()[%d] = %v, want %v", i, v, expected[i]) } } } -func TestShuffleGameNames(t *testing.T) { +func TestShuffleSoundtrackNames(t *testing.T) { games := []string{"Game1", "Game2", "Game3"} // Test that shuffle doesn't lose any elements @@ -181,7 +181,7 @@ func TestShuffleGameNames(t *testing.T) { } if len(games) != len(original) { - t.Errorf("shuffleGameNames() changed length from %d to %d", len(original), len(games)) + t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(games)) return } @@ -195,9 +195,7 @@ func TestShuffleGameNames(t *testing.T) { } } if !found { - t.Errorf("shuffleGameNames() lost element: %v", orig) + t.Errorf("shuffleSoundtrackNames() lost element: %v", orig) } } } - - diff --git a/internal/db/database.go b/internal/db/database.go index c37301b..7d2b50c 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "time" "music-server/internal/logging" @@ -59,6 +60,26 @@ func (db *Database) Close() { } } +// Health checks the health of the database connection by pinging the database. +// It returns a map with keys indicating various health statistics. +func (db *Database) Health() map[string]string { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + stats := make(map[string]string) + + // Ping the database + err := db.Pool.Ping(ctx) + if err != nil { + stats["status"] = "down" + stats["error"] = err.Error() + return stats + } + + stats["status"] = "up" + return stats +} + // RunMigrations runs all pending database migrations to the latest version. // Uses the existing pool to extract connection details. func (db *Database) RunMigrations() error { diff --git a/internal/db/migration_test.go b/internal/db/migration_test.go index c8f5f3f..7a678c0 100644 --- a/internal/db/migration_test.go +++ b/internal/db/migration_test.go @@ -1,7 +1,6 @@ package db import ( - "context" "database/sql" "fmt" "os" @@ -80,9 +79,9 @@ func TestMigrationsStepByStep(t *testing.T) { } for _, s := range songs { - _, err := db.Exec(`INSERT INTO song (game_id, song_name, path) - VALUES ($1, $2, $3)`, - s.gameID, s.name, s.path) + _, err := db.Exec(`INSERT INTO song (game_id, song_name, path, hash) + VALUES ($1, $2, $3, $4)`, + s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name)) require.NoError(t, err, "Failed to insert song %s", s.name) } @@ -95,9 +94,9 @@ func TestMigrationsStepByStep(t *testing.T) { var songCount int err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount) require.NoError(t, err) - require.Equal(t, 8, songCount, "Expected 8 songs") + require.Equal(t, 9, songCount, "Expected 9 songs") - t.Log("✓ Manually inserted 5 games with 8 songs") + t.Log("✓ Manually inserted 5 games with 9 songs") }) // Step 3: Apply migration 5 (rename game→soundtrack) @@ -126,7 +125,7 @@ func TestMigrationsStepByStep(t *testing.T) { var songCount int err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount) require.NoError(t, err) - require.Equal(t, 8, songCount, "Expected 8 songs after migration") + require.Equal(t, 9, songCount, "Expected 9 songs after migration") // Verify data integrity: soundtrack_name values rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id") @@ -215,13 +214,18 @@ func applyMigrations(t *testing.T, host, port, user, password, dbname string, st require.NoError(t, err) m, err := migrate.NewWithDatabaseInstance( - "file://internal/db/migrations", + "file://migrations", "postgres", driver) require.NoError(t, err) // Get current version version, _, err := m.Version() - require.NoError(t, err) + if err != nil && err != migrate.ErrNilVersion { + require.NoError(t, err) + } + if err == migrate.ErrNilVersion { + version = 0 + } t.Logf("Current migration version: %d", version) // Apply exactly 'steps' migrations @@ -237,6 +241,11 @@ func applyMigrations(t *testing.T, host, port, user, password, dbname string, st // Get new version newVersion, _, err := m.Version() - require.NoError(t, err) + if err != nil && err != migrate.ErrNilVersion { + require.NoError(t, err) + } + if err == migrate.ErrNilVersion { + newVersion = 0 + } t.Logf("Migration version after applying %d steps: %d", steps, newVersion) } diff --git a/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql b/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql index 3bf03aa..17c119b 100644 --- a/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql +++ b/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql @@ -13,7 +13,6 @@ ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id; -- Update song primary key ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey; ALTER TABLE song ADD PRIMARY KEY (soundtrack_id, path); -ALTER TABLE song RENAME CONSTRAINT song_pkey TO song_pkey_soundtrack; -- Update song_list table references ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name; diff --git a/internal/db/queries/statistics.sql b/internal/db/queries/statistics.sql index 5d67f78..f7204c4 100644 --- a/internal/db/queries/statistics.sql +++ b/internal/db/queries/statistics.sql @@ -138,8 +138,8 @@ LIMIT $1; -- name: GetStatisticsSummary :one SELECT COUNT(*) as total_soundtracks, - SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks, - SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_soundtracks, + COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint as played_soundtracks, + COALESCE(SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END), 0)::bigint as never_played_soundtracks, COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays, COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays, COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays, diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index cd231c7..1a672cf 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -10,23 +10,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -type IDMigrationStatus struct { - TableName string `json:"table_name"` - TotalRows int32 `json:"total_rows"` - MigratedRows int32 `json:"migrated_rows"` - Completed bool `json:"completed"` - StartedAt *time.Time `json:"started_at"` -} - -type Session struct { - Token string `json:"token"` - IpAddress string `json:"ip_address"` - UserAgent string `json:"user_agent"` - ClientType *string `json:"client_type"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - type Session struct { Token string `json:"token"` IpAddress string `json:"ip_address"` @@ -44,7 +27,6 @@ type Song struct { Hash string `json:"hash"` FileName *string `json:"file_name"` ID pgtype.Int4 `json:"id"` - Uuid pgtype.UUID `json:"uuid"` } type SongList struct { @@ -56,17 +38,16 @@ type SongList struct { } type Soundtrack struct { - ID int32 `json:"id"` - SoundtrackName string `json:"soundtrack_name"` - Added time.Time `json:"added"` - Deleted *time.Time `json:"deleted"` - LastChanged *time.Time `json:"last_changed"` - Path string `json:"path"` - TimesPlayed int32 `json:"times_played"` - LastPlayed *time.Time `json:"last_played"` - NumberOfSongs int32 `json:"number_of_songs"` - Hash string `json:"hash"` - Uuid pgtype.UUID `json:"uuid"` + ID int32 `json:"id"` + SoundtrackName string `json:"soundtrack_name"` + Added time.Time `json:"added"` + Deleted *time.Time `json:"deleted"` + LastChanged *time.Time `json:"last_changed"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + LastPlayed *time.Time `json:"last_played"` + NumberOfSongs int32 `json:"number_of_songs"` + Hash string `json:"hash"` } type Vgmq struct { diff --git a/internal/db/repository/song.sql.go b/internal/db/repository/song.sql.go index 5b79b4f..048f630 100644 --- a/internal/db/repository/song.sql.go +++ b/internal/db/repository/song.sql.go @@ -110,7 +110,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int } const fetchAllSongs = `-- name: FetchAllSongs :many -SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song ` func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { @@ -130,7 +130,6 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { &i.Hash, &i.FileName, &i.ID, - &i.Uuid, ); err != nil { return nil, err } @@ -143,7 +142,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { } const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many -SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE soundtrack_id = $1 ` @@ -165,7 +164,6 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3 &i.Hash, &i.FileName, &i.ID, - &i.Uuid, ); err != nil { return nil, err } @@ -178,7 +176,7 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3 } const getSongById = `-- name: GetSongById :one -SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE id = $1 +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE id = $1 ` func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) { @@ -192,13 +190,12 @@ func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) &i.Hash, &i.FileName, &i.ID, - &i.Uuid, ) return i, err } const getSongWithHash = `-- name: GetSongWithHash :one -SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE hash = $1 +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE hash = $1 ` func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) { @@ -212,7 +209,6 @@ func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error &i.Hash, &i.FileName, &i.ID, - &i.Uuid, ) return i, err } diff --git a/internal/db/repository/soundtrack.sql.go b/internal/db/repository/soundtrack.sql.go index b80cfd3..bc38704 100644 --- a/internal/db/repository/soundtrack.sql.go +++ b/internal/db/repository/soundtrack.sql.go @@ -28,7 +28,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error { } const findAllSoundtracks = `-- name: FindAllSoundtracks :many -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash FROM soundtrack WHERE deleted IS NULL ORDER BY soundtrack_name @@ -54,7 +54,6 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) &i.LastPlayed, &i.NumberOfSongs, &i.Hash, - &i.Uuid, ); err != nil { return nil, err } @@ -67,7 +66,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) } const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash FROM soundtrack ORDER BY soundtrack_name ` @@ -92,7 +91,6 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun &i.LastPlayed, &i.NumberOfSongs, &i.Hash, - &i.Uuid, ); err != nil { return nil, err } @@ -116,7 +114,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri } const getSoundtrackById = `-- name: GetSoundtrackById :one -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash FROM soundtrack WHERE id = $1 AND deleted IS NULL @@ -136,7 +134,6 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, &i.LastPlayed, &i.NumberOfSongs, &i.Hash, - &i.Uuid, ) return i, err } diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go index c531427..a29da58 100644 --- a/internal/db/repository/statistics.sql.go +++ b/internal/db/repository/statistics.sql.go @@ -398,8 +398,8 @@ func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetO const getStatisticsSummary = `-- name: GetStatisticsSummary :one SELECT COUNT(*) as total_soundtracks, - SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks, - SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_soundtracks, + COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint as played_soundtracks, + COALESCE(SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END), 0)::bigint as never_played_soundtracks, COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays, COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays, COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays, diff --git a/internal/db/test_helpers.go b/internal/db/test_helpers.go index 2cdac70..29924f4 100644 --- a/internal/db/test_helpers.go +++ b/internal/db/test_helpers.go @@ -54,8 +54,19 @@ func TestSetupDB(t *testing.T) { t.Fatalf("Failed to initialize test database: %v", err) } + // Clean up any existing schema to ensure clean state + ctx := context.Background() + _, err = TestDatabase.Pool.Exec(ctx, "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;") + if err != nil { + t.Logf("Warning: Could not clean schema: %v", err) + // Continue anyway, migrations might still work + } + // Run migrations if err := TestDatabase.RunMigrations(); err != nil { + // Clean up on failure to prevent nil pointer issues in other tests + TestDatabase.Close() + TestDatabase = nil t.Fatalf("Failed to run migrations: %v", err) } }) @@ -97,10 +108,11 @@ func createTestDatabase(host, port, dbname, user, password string) { // "closed pool" errors when tests run sequentially func TestTearDownDB(t *testing.T) { // CloseDb() // Disabled to prevent pool closure between sequential tests - if TestDatabase != nil { - TestDatabase.Close() - TestDatabase = nil - } + // Note: We also don't nil TestDatabase to allow reuse across tests + // if TestDatabase != nil { + // TestDatabase.Close() + // TestDatabase = nil + // } } // TestClearDatabase clears all data from the test database @@ -112,10 +124,13 @@ func TestClearDatabase(t *testing.T) { // Clear all tables in reverse order to respect foreign keys // Note: This assumes the tables exist and have the expected structure + // After migration 000005, game table was renamed to soundtrack tables := []string{ "song_list", "song", - "game", + "soundtrack", + "vgmq", + "sessions", } ctx := context.Background() @@ -126,9 +141,10 @@ func TestClearDatabase(t *testing.T) { } } - // Reset sequences - _, err := TestDatabase.Pool.Exec(ctx, "SELECT setval('game_id_seq', 1, false)") - if err != nil { - t.Logf("Failed to reset game_id_seq: %v", err) + // Reset sequences (renamed from game_id_seq to soundtrack_id_seq in migration 000005) + var seqErr error + _, seqErr = TestDatabase.Pool.Exec(ctx, "SELECT setval('soundtrack_id_seq', 1, false)") + if seqErr != nil { + t.Logf("Failed to reset soundtrack_id_seq: %v", seqErr) } } diff --git a/internal/server/healthHandler.go b/internal/server/healthHandler.go index a00ab66..a448158 100644 --- a/internal/server/healthHandler.go +++ b/internal/server/healthHandler.go @@ -8,10 +8,11 @@ import ( ) type HealthHandler struct { + db *db.Database } -func NewHealthHandler() *HealthHandler { - return &HealthHandler{} +func NewHealthHandler(database *db.Database) *HealthHandler { + return &HealthHandler{db: database} } // HealthCheck godoc @@ -24,5 +25,5 @@ func NewHealthHandler() *HealthHandler { // @Success 200 {string} string "OK" // @Router /health [get] func (h *HealthHandler) HealthCheck(ctx *echo.Context) error { - return ctx.JSON(http.StatusOK, db.Health()) + return ctx.JSON(http.StatusOK, h.db.Health()) } diff --git a/internal/server/health_handler_test.go b/internal/server/health_handler_test.go index 265e7de..9084b08 100644 --- a/internal/server/health_handler_test.go +++ b/internal/server/health_handler_test.go @@ -5,18 +5,13 @@ import ( "net/http" "testing" - "music-server/internal/db" - "github.com/stretchr/testify/assert" ) // TestHealthCheck verifies the health endpoint returns database status func TestHealthCheck(t *testing.T) { - // Setup database - db.TestSetupDB(t) - defer db.TestTearDownDB(t) - e := StartTestServer(t) + // No explicit teardown - handled by StartTestServer's sync.Once resp := MakeTestRequest(t, e, "GET", "/health") assert.Equal(t, http.StatusOK, resp.Code) diff --git a/internal/server/routes.go b/internal/server/routes.go index 4309342..17a4f92 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -63,7 +63,7 @@ func (s *Server) RegisterRoutes() http.Handler { // ============================================ deprecatedMiddleware := middleware.DeprecationMiddleware - health := NewHealthHandler() + health := NewHealthHandler(s.db) e.GET("/health", deprecatedMiddleware(health.HealthCheck)) version := NewVersionHandler() @@ -112,10 +112,10 @@ func (s *Server) RegisterRoutes() http.Handler { // ============================================ // API v1 Routes with Token Authentication // ============================================ - + // Create /api/v1 group apiV1 := e.Group("/api/v1") - + // Public endpoints - no token required apiV1.POST("/token", func(c *echo.Context) error { return s.tokenHandler.CreateTokenHandler(c) @@ -164,33 +164,6 @@ func (s *Server) RegisterRoutes() http.Handler { // Future: VGMQ endpoints will be added to protectedV1 group _ = protectedV1 // Use the variable to avoid unused variable error - // ============================================ - // API v1 Routes with Token Authentication - // ============================================ - - // Create /api/v1 group - apiV1 := e.Group("/api/v1") - - // Public endpoints - no token required - apiV1.POST("/token", func(c *echo.Context) error { - return s.tokenHandler.CreateTokenHandler(c) - }) - apiV1.DELETE("/token", func(c *echo.Context) error { - return s.tokenHandler.DeleteTokenHandler(c) - }) - apiV1.POST("/token/cleanup", func(c *echo.Context) error { - return s.tokenHandler.CleanupExpiredSessionsHandler(c) - }) - - // Protected endpoints - require valid token - // Create token auth middleware with pool access - tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool) - - // Protected group with token authentication - will be used by VGMQ and Statistics API - _ = apiV1.Group("", tokenAuthMiddleware) - - // Note: Future protected endpoints (VGMQ, Statistics) will be added here - routes := e.Router().Routes() sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path diff --git a/internal/server/statistics_handler_test.go b/internal/server/statistics_handler_test.go index fa429f0..3a663c0 100644 --- a/internal/server/statistics_handler_test.go +++ b/internal/server/statistics_handler_test.go @@ -73,6 +73,11 @@ func TestPartialMigrationThenSyncThenComplete(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) + // Wait for sync to complete + if !waitForSyncCompletion(t, e, 60) { + t.Error("Sync did not complete within timeout") + } + // Verify data via statistics endpoint req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil) req.Header.Set("Authorization", "Bearer "+token) @@ -85,9 +90,9 @@ func TestPartialMigrationThenSyncThenComplete(t *testing.T) { err := json.Unmarshal(rec.Body.Bytes(), &summary) require.NoError(t, err) - // We inserted 5 soundtracks, so total should be at least 5 - // (there might be existing data) - require.GreaterOrEqual(t, summary.TotalGames, int64(5)) + // After sync with /sync/new, only soundtracks matching filesystem remain + // testMusic has 3 games + require.Equal(t, int64(3), summary.TotalGames) } // insertTestData inserts 5 test soundtracks with songs into the database @@ -115,8 +120,8 @@ func insertTestData(t *testing.T) { for _, st := range soundtracks { _, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{ SoundtrackName: st.name, - Path: st.path, - Hash: "test-hash-" + st.name, + Path: st.path, + Hash: "test-hash-" + st.name, }) require.NoError(t, err, "Failed to insert soundtrack: %s", st.name) } diff --git a/internal/server/test_helpers.go b/internal/server/test_helpers.go index bd30334..153554f 100644 --- a/internal/server/test_helpers.go +++ b/internal/server/test_helpers.go @@ -50,7 +50,7 @@ func StartTestServer(t *testing.T) *echo.Echo { // Initialize database for tests db.TestSetupDB(t) - + // Initialize backend with test database pool // This ensures BackendRepo() and BackendCtx() are available if db.TestDatabase != nil && db.TestDatabase.Pool != nil { @@ -59,8 +59,9 @@ func StartTestServer(t *testing.T) *echo.Echo { // Create a Server instance and get its routes s := &Server{ - db: db.TestDatabase, - tokenHandler: NewTokenHandler(db.TestDatabase.Pool), + db: db.TestDatabase, + tokenHandler: NewTokenHandler(db.TestDatabase.Pool), + statisticsHandler: NewStatisticsHandler(), } handler := s.RegisterRoutes() diff --git a/justfile b/justfile index b7871fa..b4bd9c8 100644 --- a/justfile +++ b/justfile @@ -84,8 +84,13 @@ build-run: build @go run cmd/main.go test: build - @echo "Testing..." - @go test ./... -v + @echo "Starting test database container..." + @podman-compose -f compose.test.yaml up -d + @sleep 10 + @echo "Running integration tests..." + @just test-integration + @echo "Stopping test database container..." + @just test-integration-down # Clean the binary clean: @@ -105,7 +110,9 @@ podman-down: # Run integration tests with podman # Starts a test PostgreSQL container, runs tests, then cleans up test-integration: - @echo "Starting test database container..." + @echo "Cleaning old test database..." + @podman-compose -f compose.test.yaml down -v + @echo "Starting fresh test database container..." @podman-compose -f compose.test.yaml up -d @sleep 10 @echo "Running integration tests..."