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.go b/cmd/web/hello.go index 5445720..87feded 100644 --- a/cmd/web/hello.go +++ b/cmd/web/hello.go @@ -30,7 +30,7 @@ func FindGameWebHandler(w http.ResponseWriter, r *http.Request) { func search(searchText string) { games_added = nil - games := backend.GetAllGames() + games := backend.GetAllSoundtracks() for _, game := range games { if is_match_exact(searchText, game) { add_game(game) 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.go b/internal/backend/music.go index c9717b4..dff0a6a 100644 --- a/internal/backend/music.go +++ b/internal/backend/music.go @@ -22,7 +22,7 @@ type SongInfo struct { var currentSong = -1 -var gamesNew []repository.Game +var gamesNew []repository.Soundtrack var songQueNew []repository.Song @@ -37,10 +37,10 @@ func initRepo() { } } -func getAllGames() []repository.Game { +func getAllGames() []repository.Soundtrack { if len(gamesNew) == 0 { initRepo() - gamesNew, _ = BackendRepo().FindAllGames(BackendCtx()) + gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx()) } return gamesNew @@ -59,7 +59,7 @@ func Reset() { songQueNew = nil currentSong = -1 initRepo() - gamesNew, _ = BackendRepo().FindAllGames(BackendCtx()) + gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx()) } func AddLatestToQue() { @@ -77,8 +77,8 @@ func AddLatestPlayed() { currentSongData := songQueNew[currentSong] initRepo() - BackendRepo().AddGamePlayed(BackendCtx(), currentSongData.GameID) - BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName}) + BackendRepo().AddSoundtrackPlayed(BackendCtx(), currentSongData.SoundtrackID) + BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: currentSongData.SoundtrackID, SongName: currentSongData.SongName}) } func SetPlayed(songNumber int) { @@ -87,8 +87,8 @@ func SetPlayed(songNumber int) { } songData := songQueNew[songNumber] initRepo() - BackendRepo().AddGamePlayed(BackendCtx(), songData.GameID) - BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName}) + BackendRepo().AddSoundtrackPlayed(BackendCtx(), songData.SoundtrackID) + BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: songData.SoundtrackID, SongName: songData.SongName}) } func GetRandomSong() string { @@ -105,7 +105,7 @@ func GetRandomSong() string { func GetRandomSongLowChance() string { getAllGames() - var listOfGames []repository.Game + var listOfGames []repository.Soundtrack var averagePlayed = getAveragePlayed() @@ -131,7 +131,7 @@ func GetRandomSongClassic() string { var listOfAllSongs []repository.Song for _, game := range gamesNew { - songList, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID) + songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID) listOfAllSongs = append(listOfAllSongs, songList...) } @@ -139,13 +139,13 @@ func GetRandomSongClassic() string { var song repository.Song for !songFound { song = listOfAllSongs[rand.Intn(len(listOfAllSongs))] - gameData, err := BackendRepo().GetGameById(BackendCtx(), song.GameID) + gameData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID) if err != nil { - BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), - zap.String("game", gameData.GameName), + zap.String("game", gameData.SoundtrackName), zap.String("filename", *song.FileName)) continue } @@ -154,10 +154,10 @@ func GetRandomSongClassic() string { openFile, err := os.Open(song.Path) if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) { //File not found - BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), - zap.String("game", gameData.GameName), + zap.String("game", gameData.SoundtrackName), zap.String("filename", *song.FileName)) } else { songFound = true @@ -180,7 +180,7 @@ func GetSongInfo() SongInfo { currentGameData := getCurrentGame(currentSongData) return SongInfo{ - Game: currentGameData.GameName, + Game: currentGameData.SoundtrackName, GamePlayed: currentGameData.TimesPlayed, Song: currentSongData.SongName, SongPlayed: currentSongData.TimesPlayed, @@ -195,7 +195,7 @@ func GetPlayedSongs() []SongInfo { for i, song := range songQueNew { gameData := getCurrentGame(song) songList = append(songList, SongInfo{ - Game: gameData.GameName, + Game: gameData.SoundtrackName, GamePlayed: gameData.TimesPlayed, Song: song.SongName, SongPlayed: song.TimesPlayed, @@ -217,22 +217,22 @@ func GetSong(song string) string { return songData.Path } -func GetAllGames() []string { +func GetAllSoundtracks() []string { getAllGames() var jsonArray []string for _, game := range gamesNew { - jsonArray = append(jsonArray, game.GameName) + jsonArray = append(jsonArray, game.SoundtrackName) } return jsonArray } -func GetAllGamesRandom() []string { +func GetAllSoundtracksRandom() []string { getAllGames() var jsonArray []string for _, game := range gamesNew { - jsonArray = append(jsonArray, game.GameName) + jsonArray = append(jsonArray, game.SoundtrackName) } rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] }) return jsonArray @@ -266,12 +266,12 @@ func GetPreviousSong() string { } } -func getSongFromList(games []repository.Game) repository.Song { +func getSongFromList(games []repository.Soundtrack) repository.Song { songFound := false var song repository.Song for !songFound { game := getRandomGame(games) - songs, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID) + songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID) if len(songs) == 0 { continue } @@ -282,10 +282,10 @@ func getSongFromList(games []repository.Game) repository.Song { openFile, err := os.Open(song.Path) if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) { //File not found - BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), - zap.String("game", game.GameName), + zap.String("game", game.SoundtrackName), zap.Any("filename", song.FileName)) } else { songFound = true @@ -299,13 +299,13 @@ func getSongFromList(games []repository.Game) repository.Song { return song } -func getCurrentGame(currentSongData repository.Song) repository.Game { +func getCurrentGame(currentSongData repository.Song) repository.Soundtrack { for _, game := range gamesNew { - if game.ID == currentSongData.GameID { + if game.ID == currentSongData.SoundtrackID { return game } } - return repository.Game{} + return repository.Soundtrack{} } func getAveragePlayed() int32 { @@ -317,6 +317,6 @@ func getAveragePlayed() int32 { return sum / int32(len(gamesNew)) } -func getRandomGame(listOfGames []repository.Game) repository.Game { +func getRandomGame(listOfGames []repository.Soundtrack) repository.Soundtrack { return listOfGames[rand.Intn(len(listOfGames))] } 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/backend/statistics.go b/internal/backend/statistics.go new file mode 100644 index 0000000..e6fccb3 --- /dev/null +++ b/internal/backend/statistics.go @@ -0,0 +1,277 @@ +package backend + +import ( + "encoding/json" + "time" + + "music-server/internal/logging" + + "go.uber.org/zap" +) + +// GameWithSongs represents a game with its songs for statistics +type GameWithSongs struct { + SoundtrackID int32 `json:"game_id"` + SoundtrackName string `json:"game_name"` + SoundtrackPlayed int32 `json:"game_played"` + SoundtrackLastPlayed *time.Time `json:"game_last_played,omitempty"` + Songs []SongInfoForStats `json:"songs"` +} + +// SongInfoForStats represents a song with game info for statistics +type SongInfoForStats struct { + SoundtrackID int32 `json:"game_id"` + SoundtrackName string `json:"game_name"` + SongName string `json:"song_name"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + FileName *string `json:"file_name,omitempty"` +} + +// StatisticsSummary holds overall statistics +type StatisticsSummary struct { + TotalGames int64 `json:"total_games"` + PlayedGames int64 `json:"played_games"` + NeverPlayedGames int64 `json:"never_played_games"` + TotalGamePlays int64 `json:"total_game_plays"` + AvgGamePlays float64 `json:"avg_game_plays"` + MaxGamePlays int64 `json:"max_game_plays"` + MinGamePlays int64 `json:"min_game_plays"` +} + +// StatisticsHandler manages statistics operations +type StatisticsHandler struct { + // Uses the global backend repo initialized via InitBackend +} + +// NewStatisticsHandler creates a new StatisticsHandler +func NewStatisticsHandler() *StatisticsHandler { + return &StatisticsHandler{} +} + +// GetMostPlayedGamesWithSongs returns the top N most played games with their songs +func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + // Get raw results + rows, err := queries.GetMostPlayedGamesWithSongs(ctx, limit) + if err != nil { + return nil, err + } + + // Convert to GameWithSongs + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + // Parse JSON songs array + if err := json.Unmarshal(row.Songs, &songs); err != nil { + // Fallback: if JSON parsing fails, create empty song entries + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + SoundtrackID: row.SoundtrackID, + SoundtrackName: row.SoundtrackName, + SoundtrackPlayed: row.SoundtrackPlayed, + SoundtrackLastPlayed: row.SoundtrackLastPlayed, + Songs: songs, + }) + } + return result, nil +} + +// GetLeastPlayedGamesWithSongs returns the top N least played games with their songs +func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetLeastPlayedGamesWithSongs(ctx, limit) + if err != nil { + return nil, err + } + + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + if err := json.Unmarshal(row.Songs, &songs); err != nil { + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + SoundtrackID: row.SoundtrackID, + SoundtrackName: row.SoundtrackName, + SoundtrackPlayed: row.SoundtrackPlayed, + SoundtrackLastPlayed: row.SoundtrackLastPlayed, + Songs: songs, + }) + } + return result, nil +} + +// GetMostPlayedSongsWithGame returns the top N most played songs with their game info +func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetMostPlayedSongsWithGame(ctx, limit) + if err != nil { + return nil, err + } + + var result []SongInfoForStats + for _, row := range rows { + result = append(result, SongInfoForStats{ + SoundtrackID: row.SoundtrackID, + SoundtrackName: row.SoundtrackName, + SongName: row.SongName, + Path: row.Path, + TimesPlayed: row.TimesPlayed, + FileName: row.FileName, + }) + } + return result, nil +} + +// GetLeastPlayedSongsWithGame returns the top N least played songs with their game info +func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetLeastPlayedSongsWithGame(ctx, limit) + if err != nil { + return nil, err + } + + var result []SongInfoForStats + for _, row := range rows { + result = append(result, SongInfoForStats{ + SoundtrackID: row.SoundtrackID, + SoundtrackName: row.SoundtrackName, + SongName: row.SongName, + Path: row.Path, + TimesPlayed: row.TimesPlayed, + FileName: row.FileName, + }) + } + return result, nil +} + +// GetNeverPlayedGames returns games that have never been played +func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetNeverPlayedGames(ctx) + if err != nil { + return nil, err + } + + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + if err := json.Unmarshal(row.Songs, &songs); err != nil { + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + SoundtrackID: row.SoundtrackID, + SoundtrackName: row.SoundtrackName, + SoundtrackPlayed: row.SoundtrackPlayed, + SoundtrackLastPlayed: nil, + Songs: songs, + }) + } + return result, nil +} + +// GetLastPlayedGames returns the most recently played games +func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetLastPlayedGames(ctx, limit) + if err != nil { + return nil, err + } + + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + if err := json.Unmarshal(row.Songs, &songs); err != nil { + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + SoundtrackID: row.SoundtrackID, + SoundtrackName: row.SoundtrackName, + SoundtrackPlayed: row.SoundtrackPlayed, + SoundtrackLastPlayed: row.SoundtrackLastPlayed, + Songs: songs, + }) + } + return result, nil +} + +// GetOldestPlayedGames returns the least recently played games +func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetOldestPlayedGames(ctx, limit) + if err != nil { + return nil, err + } + + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + if err := json.Unmarshal(row.Songs, &songs); err != nil { + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + SoundtrackID: row.SoundtrackID, + SoundtrackName: row.SoundtrackName, + SoundtrackPlayed: row.SoundtrackPlayed, + SoundtrackLastPlayed: row.SoundtrackLastPlayed, + Songs: songs, + }) + } + return result, nil +} + +// GetStatisticsSummary returns overall statistics +func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) { + queries := BackendRepo() + ctx := BackendCtx() + + row, err := queries.GetStatisticsSummary(ctx) + if err != nil { + return nil, err + } + + return &StatisticsSummary{ + TotalGames: int64(row.TotalSoundtracks), + PlayedGames: int64(row.PlayedSoundtracks), + NeverPlayedGames: int64(row.NeverPlayedSoundtracks), + TotalGamePlays: int64(row.TotalSoundtrackPlays), + AvgGamePlays: float64(row.AvgSoundtrackPlays), + MaxGamePlays: int64(row.MaxSoundtrackPlays), + MinGamePlays: int64(row.MinSoundtrackPlays), + }, nil +} + +// Log helper for statistics operations +func logStatisticsError(err error, operation string) { + if err != nil { + logging.GetLogger().Error("Statistics error", + zap.String("operation", operation), + zap.String("error", err.Error())) + } +} diff --git a/internal/backend/sync.go b/internal/backend/sync.go index 42a77ea..189668a 100644 --- a/internal/backend/sync.go +++ b/internal/backend/sync.go @@ -30,16 +30,22 @@ var start time.Time var totalTime time.Duration var timeSpent time.Duration -var allGames []repository.Game -var gamesBeforeSync []repository.Game -var gamesAfterSync []repository.Game +var allGames []repository.Soundtrack +var gamesBeforeSync []repository.Soundtrack +var gamesAfterSync []repository.Soundtrack var gamesAdded []string var gamesReAdded []string var gamesChangedTitle map[string]string var gamesChangedContent []string var gamesRemoved []string var catchedErrors []string -var brokenSongs []string + +type brokenSong struct { + SoundtrackID int32 + Path string +} + +var brokenSongs []brokenSong var pool *ants.Pool var poolSong *ants.Pool @@ -80,7 +86,7 @@ func (gs GameStatus) String() string { func ResetDB() { repo.ClearSongs(BackendCtx()) - repo.ClearGames(BackendCtx()) + repo.ClearSoundtracks(BackendCtx()) } func SyncProgress() ProgressResponse { @@ -124,13 +130,13 @@ func SyncResult() SyncResponse { for _, beforeGame := range gamesBeforeSync { var found = false for _, afterGame := range gamesAfterSync { - if beforeGame.GameName == afterGame.GameName { + if beforeGame.SoundtrackName == afterGame.SoundtrackName { found = true break } } if !found { - gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.GameName) + gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.SoundtrackName) } } @@ -169,12 +175,12 @@ func SyncResult() SyncResponse { } } -func SyncGamesNewFull() { +func SyncSoundtracksNewFull() { syncGamesNew(true) Reset() } -func SyncGamesNewOnlyChanges() { +func SyncSoundtracksNewOnlyChanges() { syncGamesNew(false) Reset() } @@ -203,14 +209,14 @@ func syncGamesNew(full bool) { catchedErrors = nil brokenSongs = nil - gamesBeforeSync, err = repo.FindAllGames(BackendCtx()) - handleError("FindAllGames Before", err, "") + gamesBeforeSync, err = repo.FindAllSoundtracks(BackendCtx()) + handleError("FindAllSoundtracks Before", err, "") logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync))) - allGames, err = repo.GetAllGamesIncludingDeleted(BackendCtx()) - handleError("GetAllGamesIncludingDeleted", err, "") - err = repo.SetGameDeletionDate(BackendCtx()) - handleError("SetGameDeletionDate", err, "") + allGames, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx()) + handleError("GetAllSoundtracksIncludingDeleted", err, "") + err = repo.SetSoundtrackDeletionDate(BackendCtx()) + handleError("SetSoundtrackDeletionDate", err, "") directories, err := os.ReadDir(musicPath) if err != nil { @@ -233,8 +239,8 @@ func syncGamesNew(full bool) { syncWg.Wait() checkBrokenSongsNew() - gamesAfterSync, err = repo.FindAllGames(BackendCtx()) - handleError("FindAllGames After", err, "") + gamesAfterSync, err = repo.FindAllSoundtracks(BackendCtx()) + handleError("FindAllSoundtracks After", err, "") finished := time.Now() totalTime = finished.Sub(start) @@ -259,8 +265,10 @@ func checkBrokenSongsNew() { }) } brokenWg.Wait() - err = repo.RemoveBrokenSongs(BackendCtx(), brokenSongs) - handleError("RemoveBrokenSongs", err, "") + for _, bs := range brokenSongs { + err = repo.RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: bs.SoundtrackID, Path: bs.Path}) + handleError("RemoveBrokenSong", err, "") + } } func checkBrokenSongNew(song repository.Song) { @@ -268,7 +276,7 @@ func checkBrokenSongNew(song repository.Song) { openFile, err := os.Open(song.Path) if err != nil { //File not found - brokenSongs = append(brokenSongs, song.Path) + brokenSongs = append(brokenSongs, brokenSong{SoundtrackID: song.SoundtrackID, Path: song.Path}) logging.GetLogger().Warn("Broken song found", zap.String("path", song.Path)) } else { err = openFile.Close() @@ -285,28 +293,28 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full dirHash := getHashForDir(gameDir) var status GameStatus = NewGame - var oldGame repository.Game + var oldGame repository.Soundtrack var id int32 = -1 //fmt.Printf("Games before: %d\n", len(gamesBeforeSync)) for _, currentGame := range allGames { oldGame = currentGame - //fmt.Printf("%s | %s\n", oldGame.GameName, oldGame.Hash) - if oldGame.GameName == file.Name() && oldGame.Hash == dirHash { + //fmt.Printf("%s | %s\n", oldGame.SoundtrackName, oldGame.Hash) + if oldGame.SoundtrackName == file.Name() && oldGame.Hash == dirHash { status = NotChanged id = oldGame.ID //fmt.Printf("Game not changed\n") break - } else if oldGame.GameName == file.Name() && oldGame.Hash != dirHash { + } else if oldGame.SoundtrackName == file.Name() && oldGame.Hash != dirHash { status = GameChanged id = oldGame.ID //fmt.Printf("Game changed\n") break - } else if oldGame.GameName != file.Name() && oldGame.Hash == dirHash { + } else if oldGame.SoundtrackName != file.Name() && oldGame.Hash == dirHash { status = TitleChanged id = oldGame.ID - //fmt.Printf("GameName changed\n") + //fmt.Printf("SoundtrackName changed\n") break } } @@ -332,8 +340,8 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full break } } - err = repo.InsertGameWithExistingId(BackendCtx(), repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash}) - handleError("InsertGameWithExistingId", err, "") + err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, SoundtrackName: file.Name(), Path: gameDir, Hash: dirHash}) + handleError("InsertSoundtrackWithExistingId", err, "") if err != nil { logging.GetLogger().Debug("Game already exists, removing old ID file", zap.Int32("id", id), @@ -366,24 +374,24 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full zap.String("game", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) - err = repo.UpdateGameHash(BackendCtx(), repository.UpdateGameHashParams{Hash: dirHash, ID: id}) - handleError("UpdateGameHash", err, "") + err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id}) + handleError("UpdateSoundtrackHash", err, "") gamesChangedContent = append(gamesChangedContent, file.Name()) newCheckSongs(entries, gameDir, id) case TitleChanged: logging.GetLogger().Debug("Game title changed", zap.Int32("id", id), - zap.String("oldName", oldGame.GameName), + zap.String("oldName", oldGame.SoundtrackName), zap.String("newName", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) - err = repo.UpdateGameName(BackendCtx(), repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id}) - handleError("UpdateGameName", err, "") + err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: gameDir, ID: id}) + handleError("UpdateSoundtrackName", err, "") newCheckSongs(entries, gameDir, id) if gamesChangedTitle == nil { gamesChangedTitle = make(map[string]string) } - gamesChangedTitle[oldGame.GameName] = file.Name() + gamesChangedTitle[oldGame.SoundtrackName] = file.Name() case NotChanged: var found bool = false for _, beforeGame := range gamesBeforeSync { @@ -412,8 +420,8 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full zap.String("game", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) - err = repo.RemoveDeletionDate(BackendCtx(), id) - handleError("RemoveDeletionDate", err, "") + err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id) + handleError("RemoveSoundtrackDeletionDate", err, "") } foldersSynced++ logging.GetLogger().Debug("Sync progress", @@ -424,14 +432,14 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full func insertGameNew(name string, path string, hash string) int32 { var duplicateError = errors.New("ERROR: duplicate key value violates unique") - id, err := repo.InsertGame(BackendCtx(), repository.InsertGameParams{GameName: name, Path: path, Hash: hash}) - handleError("InsertGame", err, "") + id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash}) + handleError("InsertSoundtrack", err, "") if err != nil { logging.GetLogger().Warn("ID collision detected, resetting sequence") if strings.HasPrefix(err.Error(), duplicateError.Error()) { logging.GetLogger().Debug("Resetting game ID sequence") - _, err = repo.ResetGameIdSeq(BackendCtx()) - handleError("ResetGameIdSeq", err, "") + _, err = repo.ResetSoundtrackIdSeq(BackendCtx()) + handleError("ResetSoundtrackIdSeq", err, "") id = insertGameNew(name, path, hash) } } @@ -475,7 +483,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool { songName, _ := strings.CutSuffix(fileName, ".mp3") song, err := repo.GetSongWithHash(BackendCtx(), songHash) - handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) + handleError("GetSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) if err == nil { if song.SongName == songName && song.Path == path { return false @@ -488,31 +496,31 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool { zap.String("song_hash", songHash)) count, err := repo.CheckSongWithHash(BackendCtx(), songHash) - handleError("CheckSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash)) + handleError("CheckSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash)) if err != nil { - count2, err := repo.CheckSong(BackendCtx(), path) - handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash)) + count2, err := repo.CheckSong(BackendCtx(), repository.CheckSongParams{SoundtrackID: id, Path: path}) + handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash)) if count2 > 0 { - err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path}) - handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) + err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path}) + handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) count, err = repo.CheckSongWithHash(BackendCtx(), songHash) - handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) + handleError("CheckSongWithHash 2", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) } } //count, _ := repo.CheckSong(ctx, path) if count > 0 { err = repo.UpdateSong(BackendCtx(), repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash}) - handleError("UpdateSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) + handleError("UpdateSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) } else { - count2, err := repo.CheckSong(BackendCtx(), path) - handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) + count2, err := repo.CheckSong(BackendCtx(), repository.CheckSongParams{SoundtrackID: id, Path: path}) + handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) if count2 > 0 { - err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path}) - handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) + err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path}) + handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) } else { - err = repo.AddSong(BackendCtx(), repository.AddSongParams{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash}) - handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) + err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash}) + handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) } } 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/dbHelper.go b/internal/db/dbHelper.go index 9cc754a..e87877f 100644 --- a/internal/db/dbHelper.go +++ b/internal/db/dbHelper.go @@ -20,6 +20,9 @@ import ( "go.uber.org/zap" ) +// TODO: DEPRECATED - Remove these global variables once all code is migrated to use Database struct +// Use database.go's Database struct instead. These globals remain for backward compatibility +// with legacy code paths. New code should use the Database struct from database.go. var Dbpool *pgxpool.Pool var Ctx = context.Background() diff --git a/internal/db/migration_test.go b/internal/db/migration_test.go new file mode 100644 index 0000000..7a678c0 --- /dev/null +++ b/internal/db/migration_test.go @@ -0,0 +1,251 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "testing" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + _ "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +// TestMigrationsStepByStep tests applying migrations incrementally +// Then adding data manually, then completing migrations +func TestMigrationsStepByStep(t *testing.T) { + host := os.Getenv("DB_HOST") + port := os.Getenv("DB_PORT") + user := os.Getenv("DB_USERNAME") + password := os.Getenv("DB_PASSWORD") + // Use a unique database name for this test + dbname := "music_server_migration_test" + + if host == "" || port == "" || user == "" || password == "" { + t.Skip("Test database environment variables not set (DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD)") + } + + // Clean up: drop database if it exists + cleanupDB(t, host, port, user, password, dbname) + defer cleanupDB(t, host, port, user, password, dbname) + + // Create the database + createTestDB(t, host, port, user, password, dbname) + + // Step 1: Apply first 4 migrations (before soundtrack rename) + // This creates: game, song, vgmq, song_list tables + // And sessions table with indexes + t.Run("ApplyFirst4Migrations", func(t *testing.T) { + applyMigrations(t, host, port, user, password, dbname, 4) + }) + + // Step 2: Add data manually to game and song tables + t.Run("AddManualData", func(t *testing.T) { + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname) + db, err := sql.Open("postgres", connStr) + require.NoError(t, err) + defer db.Close() + + // Insert 5 games manually + for i := 1; i <= 5; i++ { + gameName := fmt.Sprintf("Manual Game %d", i) + path := fmt.Sprintf("/manual/path/game%d", i) + hash := fmt.Sprintf("hash-%d", i) + + _, err := db.Exec(`INSERT INTO game (game_name, path, hash, added) + VALUES ($1, $2, $3, NOW())`, + gameName, path, hash) + require.NoError(t, err, "Failed to insert game %d", i) + } + + // Insert songs for each game + songs := []struct { + gameID int + name string + path string + }{ + {1, "Song A", "/path/a.mp3"}, + {1, "Song B", "/path/b.mp3"}, + {2, "Song C", "/path/c.mp3"}, + {2, "Song D", "/path/d.mp3"}, + {3, "Song E", "/path/e.mp3"}, + {4, "Song F", "/path/f.mp3"}, + {4, "Song G", "/path/g.mp3"}, + {4, "Song H", "/path/h.mp3"}, + {5, "Song I", "/path/i.mp3"}, + } + + for _, s := range songs { + _, err := db.Exec(`INSERT INTO song (game_id, song_name, path, hash) + VALUES ($1, $2, $3, $4)`, + s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name)) + require.NoError(t, err, "Failed to insert song %s", s.name) + } + + // Verify data was inserted + var gameCount int + err = db.QueryRow("SELECT COUNT(*) FROM game").Scan(&gameCount) + require.NoError(t, err) + require.Equal(t, 5, gameCount, "Expected 5 games") + + var songCount int + err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount) + require.NoError(t, err) + require.Equal(t, 9, songCount, "Expected 9 songs") + + t.Log("✓ Manually inserted 5 games with 9 songs") + }) + + // Step 3: Apply migration 5 (rename game→soundtrack) + t.Run("ApplyMigration5", func(t *testing.T) { + // Apply the remaining migrations (just migration 5) + applyMigrations(t, host, port, user, password, dbname, 1) + + // Verify tables were renamed + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname) + db, err := sql.Open("postgres", connStr) + require.NoError(t, err) + defer db.Close() + + // Check that soundtrack table exists + var soundtrackCount int + err = db.QueryRow("SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount) + require.NoError(t, err) + require.Equal(t, 5, soundtrackCount, "Expected 5 soundtracks after migration") + + // Check that game table no longer exists + _, err = db.Exec("SELECT 1 FROM game LIMIT 1") + require.Error(t, err, "game table should not exist after migration") + + // Check that song table has soundtrack_id column + var songCount int + err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount) + require.NoError(t, err) + require.Equal(t, 9, songCount, "Expected 9 songs after migration") + + // Verify data integrity: soundtrack_name values + rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id") + require.NoError(t, err) + defer rows.Close() + + expectedNames := []string{"Manual Game 1", "Manual Game 2", "Manual Game 3", "Manual Game 4", "Manual Game 5"} + actualNames := make([]string, 0) + for rows.Next() { + var name string + err := rows.Scan(&name) + require.NoError(t, err) + actualNames = append(actualNames, name) + } + require.Equal(t, expectedNames, actualNames, "Soundtrack names should match original game names") + + t.Log("✓ Migration 5 applied successfully, data preserved") + }) +} + +// cleanupDB drops the test database +func cleanupDB(t *testing.T, host, port, user, password, dbname string) { + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", + host, port, user, password) + db, err := sql.Open("postgres", connStr) + if err != nil { + t.Logf("Warning: could not connect to cleanup DB: %v", err) + return + } + defer db.Close() + + // Check if database exists before dropping + var exists int + err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists) + if err != nil && err != sql.ErrNoRows { + t.Logf("Warning: could not check if DB exists: %v", err) + return + } + + if exists == 1 { + _, err = db.Exec("DROP DATABASE " + dbname + " WITH (FORCE)") + if err != nil { + t.Logf("Warning: could not drop DB: %v", err) + } + } +} + +// createTestDB creates a fresh test database +func createTestDB(t *testing.T, host, port, user, password, dbname string) { + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", + host, port, user, password) + db, err := sql.Open("postgres", connStr) + require.NoError(t, err) + defer db.Close() + + // Drop if exists + cleanupDB(t, host, port, user, password, dbname) + + // Create database + _, err = db.Exec("CREATE DATABASE " + dbname) + require.NoError(t, err) + + // Enable UUID extension if needed + connStrDB := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname) + db2, err := sql.Open("postgres", connStrDB) + require.NoError(t, err) + defer db2.Close() + + _, err = db2.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"") + if err != nil { + t.Logf("Note: uuid-ossp extension may not be available: %v", err) + } +} + +// applyMigrations applies n migrations to the database using Go migrate library +func applyMigrations(t *testing.T, host, port, user, password, dbname string, steps int) { + migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", + user, password, host, port, dbname) + + db, err := sql.Open("postgres", migrationURL) + require.NoError(t, err) + defer db.Close() + + driver, err := postgres.WithInstance(db, &postgres.Config{}) + require.NoError(t, err) + + m, err := migrate.NewWithDatabaseInstance( + "file://migrations", + "postgres", driver) + require.NoError(t, err) + + // Get current version + version, _, err := m.Version() + if err != nil && err != migrate.ErrNilVersion { + require.NoError(t, err) + } + if err == migrate.ErrNilVersion { + version = 0 + } + t.Logf("Current migration version: %d", version) + + // Apply exactly 'steps' migrations + if steps > 0 { + err = m.Steps(steps) + if err != nil && err != migrate.ErrNoChange { + require.NoError(t, err) + } + } else if steps < 0 { + err = m.Steps(steps) + require.NoError(t, err) + } + + // Get new version + newVersion, _, err := m.Version() + if err != nil && err != migrate.ErrNilVersion { + require.NoError(t, err) + } + if err == migrate.ErrNilVersion { + newVersion = 0 + } + t.Logf("Migration version after applying %d steps: %d", steps, newVersion) +} diff --git a/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql b/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql new file mode 100644 index 0000000..f0236de --- /dev/null +++ b/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql @@ -0,0 +1,33 @@ +-- Revert: Rename soundtrack table back to game +ALTER TABLE soundtrack RENAME TO game; + +-- Revert primary key sequence +ALTER SEQUENCE soundtrack_id_seq RENAME TO game_id_seq; + +-- Revert columns in game table +ALTER TABLE game RENAME COLUMN soundtrack_name TO game_name; + +-- Revert song table: rename soundtrack_id back to game_id +ALTER TABLE song RENAME COLUMN soundtrack_id TO game_id; + +-- Revert song primary key +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey; +ALTER TABLE song ADD PRIMARY KEY (game_id, path); +ALTER TABLE song RENAME CONSTRAINT song_pkey_soundtrack TO song_pkey; + +-- Revert song_list table references +ALTER TABLE song_list RENAME COLUMN soundtrack_name TO game_name; + +-- Revert foreign key constraint +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey; +ALTER TABLE song ADD CONSTRAINT song_game_id_fkey + FOREIGN KEY (game_id) REFERENCES game(id); + +-- Revert indexes +ALTER INDEX IF EXISTS idx_soundtrack_deleted RENAME TO idx_game_deleted; +ALTER INDEX IF EXISTS idx_soundtrack_hash RENAME TO idx_game_hash; +ALTER INDEX IF EXISTS idx_soundtrack_path RENAME TO idx_game_path; +ALTER INDEX IF EXISTS idx_soundtrack_name RENAME TO idx_game_name; +ALTER INDEX IF EXISTS idx_song_soundtrack_id RENAME TO idx_song_game_id; +ALTER INDEX IF EXISTS idx_song_soundtrack_id_song_name RENAME TO idx_song_game_id_song_name; +ALTER INDEX IF EXISTS song_list_soundtrack_name_idx RENAME TO song_list_game_name_idx; diff --git a/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql b/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql new file mode 100644 index 0000000..17c119b --- /dev/null +++ b/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql @@ -0,0 +1,32 @@ +-- Rename game table to soundtrack +ALTER TABLE game RENAME TO soundtrack; + +-- Rename primary key sequence +ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq; + +-- Rename columns in soundtrack table +ALTER TABLE soundtrack RENAME COLUMN game_name TO soundtrack_name; + +-- Update song table: rename game_id to soundtrack_id +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); + +-- Update song_list table references +ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name; + +-- Rename foreign key constraint +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey; +ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey + FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id); + +-- Rename indexes +ALTER INDEX IF EXISTS idx_game_deleted RENAME TO idx_soundtrack_deleted; +ALTER INDEX IF EXISTS idx_game_hash RENAME TO idx_soundtrack_hash; +ALTER INDEX IF EXISTS idx_game_path RENAME TO idx_soundtrack_path; +ALTER INDEX IF EXISTS idx_game_name RENAME TO idx_soundtrack_name; +ALTER INDEX IF EXISTS idx_song_game_id RENAME TO idx_song_soundtrack_id; +ALTER INDEX IF EXISTS idx_song_game_id_song_name RENAME TO idx_song_soundtrack_id_song_name; +ALTER INDEX IF EXISTS song_list_game_name_idx RENAME TO song_list_soundtrack_name_idx; diff --git a/internal/db/migrations/000006_add_id_to_song.down.sql b/internal/db/migrations/000006_add_id_to_song.down.sql new file mode 100644 index 0000000..58628bf --- /dev/null +++ b/internal/db/migrations/000006_add_id_to_song.down.sql @@ -0,0 +1,24 @@ +-- Rollback: Remove id column and restore composite PK + +-- Step 1: Drop indexes created in up migration +DROP INDEX IF EXISTS idx_song_soundtrack_id; +DROP INDEX IF EXISTS idx_song_path; + +-- Step 2: Drop foreign key constraint +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey; + +-- Step 3: Drop new primary key +ALTER TABLE song DROP CONSTRAINT song_pkey; + +-- Step 4: Drop unique constraint on id +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_id_unique; + +-- Step 5: Restore composite primary key +ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (soundtrack_id, path); + +-- Step 6: Drop the id column +ALTER TABLE song DROP COLUMN id; + +-- Step 7: Recreate original foreign key (soundtrack_id references soundtrack.id) +ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey + FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id); diff --git a/internal/db/migrations/000006_add_id_to_song.up.sql b/internal/db/migrations/000006_add_id_to_song.up.sql new file mode 100644 index 0000000..ed1494d --- /dev/null +++ b/internal/db/migrations/000006_add_id_to_song.up.sql @@ -0,0 +1,36 @@ +-- Migration: Add id column to song table and change PK from composite to single column +-- This prepares the song table for eventual UUID migration + +-- Step 1: Add new id column (nullable initially) +ALTER TABLE song ADD COLUMN id serial4; + +-- Step 2: Create unique constraint on id (allows backfilling) +ALTER TABLE song ADD CONSTRAINT song_id_unique UNIQUE (id); + +-- Step 3: Backfill existing rows with sequential IDs +-- Use DEFAULT which pulls from the sequence +UPDATE song SET id = DEFAULT WHERE id IS NULL; + +-- Step 4: Verify all rows have an id +-- If this returns 0, backfill worked +-- SELECT COUNT(*) FROM song WHERE id IS NULL; + +-- Step 5: Drop the composite primary key (soundtrack_id, path) +ALTER TABLE song DROP CONSTRAINT song_pkey; + +-- Step 6: Add new primary key on id column +ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (id); + +-- Step 7: Ensure soundtrack_id remains a foreign key to soundtrack +-- First drop existing FK if it exists (from the rename migration) +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey; + +-- Then recreate it +ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey + FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id); + +-- Step 8: Create index on soundtrack_id for query performance +CREATE INDEX IF NOT EXISTS idx_song_soundtrack_id ON song(soundtrack_id); + +-- Step 9: Create index on path for lookups (previously part of PK) +CREATE INDEX IF NOT EXISTS idx_song_path ON song(path); diff --git a/internal/db/queries/game.sql b/internal/db/queries/game.sql deleted file mode 100644 index ba02059..0000000 --- a/internal/db/queries/game.sql +++ /dev/null @@ -1,49 +0,0 @@ --- name: ResetGameIdSeq :one -SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1); - --- name: GetGameNameById :one -SELECT game_name FROM game WHERE id = $1; - --- name: GetGameById :one -SELECT * -FROM game -WHERE id = $1 -AND deleted IS NULL; - --- name: SetGameDeletionDate :exec -UPDATE game SET deleted=now() WHERE deleted IS NULL; - --- name: ClearGames :exec -DELETE FROM game; - --- name: UpdateGameName :exec -UPDATE game SET game_name=sqlc.arg(name), path=sqlc.arg(path), last_changed=now() WHERE id=sqlc.arg(id); - --- name: UpdateGameHash :exec -UPDATE game SET hash=sqlc.arg(hash), last_changed=now() WHERE id=sqlc.arg(id); - --- name: RemoveDeletionDate :exec -UPDATE game SET deleted=NULL WHERE id=$1; - --- name: GetIdByGameName :one -SELECT id FROM game WHERE game_name = $1; - --- name: InsertGame :one -INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id; - --- name: InsertGameWithExistingId :exec -INSERT INTO game (id, game_name, path, hash, added) VALUES ($1, $2, $3, $4, now()); - --- name: FindAllGames :many -SELECT * -FROM game -WHERE deleted IS NULL -ORDER BY game_name; - --- name: GetAllGamesIncludingDeleted :many -SELECT * -FROM game -ORDER BY game_name; - --- name: AddGamePlayed :exec -UPDATE game SET times_played = times_played + 1, last_played = now() WHERE id = $1; diff --git a/internal/db/queries/song.sql b/internal/db/queries/song.sql index 788470f..95064ca 100644 --- a/internal/db/queries/song.sql +++ b/internal/db/queries/song.sql @@ -1,14 +1,14 @@ -- name: ClearSongs :exec DELETE FROM song; --- name: ClearSongsByGameId :exec -DELETE FROM song WHERE game_id = $1; +-- name: ClearSongsBySoundtrackId :exec +DELETE FROM song WHERE soundtrack_id = $1; -- name: AddSong :exec -INSERT INTO song(game_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5); +INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5); -- name: CheckSong :one -SELECT COUNT(*) FROM song WHERE path = $1; +SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2; -- name: CheckSongWithHash :one SELECT COUNT(*) FROM song WHERE hash = $1; @@ -20,22 +20,25 @@ SELECT * FROM song WHERE hash = $1; UPDATE song SET song_name=$1, file_name=$2, path=$3 where hash=$4; -- name: AddHashToSong :exec -UPDATE song SET hash=$1 where path=$2; +UPDATE song SET hash=$1 where soundtrack_id = $2 AND path = $3; --- name: FindSongsFromGame :many +-- name: FindSongsFromSoundtrack :many SELECT * FROM song -WHERE game_id = $1; +WHERE soundtrack_id = $1; -- name: AddSongPlayed :exec UPDATE song SET times_played = times_played + 1 -WHERE game_id = $1 AND song_name = $2; +WHERE soundtrack_id = $1 AND song_name = $2; -- name: FetchAllSongs :many SELECT * FROM song; +-- name: GetSongById :one +SELECT * FROM song WHERE id = $1; + -- name: RemoveBrokenSong :exec -DELETE FROM song WHERE path = $1; +DELETE FROM song WHERE soundtrack_id = $1 AND path = $2; -- name: RemoveBrokenSongs :exec -DELETE FROM song where path = any (sqlc.slice('paths')); +DELETE FROM song WHERE id = ANY($1); diff --git a/internal/db/queries/song_list.sql b/internal/db/queries/song_list.sql index 1ddc294..d4c0193 100644 --- a/internal/db/queries/song_list.sql +++ b/internal/db/queries/song_list.sql @@ -1,5 +1,5 @@ -- name: InsertSongInList :exec -INSERT INTO song_list (match_date, match_id, song_no, game_name, song_name) +INSERT INTO song_list (match_date, match_id, song_no, soundtrack_name, song_name) VALUES ($1, $2, $3, $4, $5); -- name: GetSongList :many diff --git a/internal/db/queries/soundtrack.sql b/internal/db/queries/soundtrack.sql new file mode 100644 index 0000000..1de82d5 --- /dev/null +++ b/internal/db/queries/soundtrack.sql @@ -0,0 +1,49 @@ +-- name: ResetSoundtrackIdSeq :one +SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1); + +-- name: GetSoundtrackNameById :one +SELECT soundtrack_name FROM soundtrack WHERE id = $1; + +-- name: GetSoundtrackById :one +SELECT * +FROM soundtrack +WHERE id = $1 +AND deleted IS NULL; + +-- name: SetSoundtrackDeletionDate :exec +UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL; + +-- name: ClearSoundtracks :exec +DELETE FROM soundtrack; + +-- name: UpdateSoundtrackName :exec +UPDATE soundtrack SET soundtrack_name=sqlc.arg(name), path=sqlc.arg(path), last_changed=now() WHERE id=sqlc.arg(id); + +-- name: UpdateSoundtrackHash :exec +UPDATE soundtrack SET hash=sqlc.arg(hash), last_changed=now() WHERE id=sqlc.arg(id); + +-- name: RemoveSoundtrackDeletionDate :exec +UPDATE soundtrack SET deleted=NULL WHERE id=$1; + +-- name: GetIdBySoundtrackName :one +SELECT id FROM soundtrack WHERE soundtrack_name = $1; + +-- name: InsertSoundtrack :one +INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id; + +-- name: InsertSoundtrackWithExistingId :exec +INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now()); + +-- name: FindAllSoundtracks :many +SELECT * +FROM soundtrack +WHERE deleted IS NULL +ORDER BY soundtrack_name; + +-- name: GetAllSoundtracksIncludingDeleted :many +SELECT * +FROM soundtrack +ORDER BY soundtrack_name; + +-- name: AddSoundtrackPlayed :exec +UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1; diff --git a/internal/db/queries/statistics.sql b/internal/db/queries/statistics.sql new file mode 100644 index 0000000..f7204c4 --- /dev/null +++ b/internal/db/queries/statistics.sql @@ -0,0 +1,148 @@ +-- Most played soundtracks with their songs +-- name: GetMostPlayedGamesWithSongs :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.times_played DESC, g.soundtrack_name +LIMIT $1; + +-- Least played soundtracks with their songs +-- name: GetLeastPlayedGamesWithSongs :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.times_played ASC, g.soundtrack_name +LIMIT $1; + +-- Most played songs with their soundtrack info +-- name: GetMostPlayedSongsWithGame :many +SELECT + s.soundtrack_id as soundtrack_id, + g.soundtrack_name, + s.song_name, + s.path, + s.times_played, + s.file_name +FROM song s +JOIN soundtrack g ON s.soundtrack_id = g.id +WHERE g.deleted IS NULL +ORDER BY s.times_played DESC, s.song_name +LIMIT $1; + +-- Least played songs with their soundtrack info +-- name: GetLeastPlayedSongsWithGame :many +SELECT + s.soundtrack_id as soundtrack_id, + g.soundtrack_name, + s.song_name, + s.path, + s.times_played, + s.file_name +FROM song s +JOIN soundtrack g ON s.soundtrack_id = g.id +WHERE g.deleted IS NULL +ORDER BY s.times_played ASC, s.song_name +LIMIT $1; + +-- Games that have never been played (times_played = 0) +-- name: GetNeverPlayedGames :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.added, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL AND g.times_played = 0 +GROUP BY g.id, g.soundtrack_name, g.times_played, g.added +ORDER BY g.soundtrack_name; + +-- Last played soundtracks (most recently played) +-- name: GetLastPlayedGames :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL AND g.last_played IS NOT NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.last_played DESC +LIMIT $1; + +-- Oldest played soundtracks (least recently played, but has been played at least once) +-- name: GetOldestPlayedGames :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL AND g.last_played IS NOT NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.last_played ASC +LIMIT $1; + +-- Get statistics summary +-- name: GetStatisticsSummary :one +SELECT + COUNT(*) as total_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, + COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays +FROM soundtrack +WHERE deleted IS NULL; diff --git a/internal/db/repository/game.sql.go b/internal/db/repository/game.sql.go deleted file mode 100644 index aaed7f3..0000000 --- a/internal/db/repository/game.sql.go +++ /dev/null @@ -1,246 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: game.sql - -package repository - -import ( - "context" -) - -const addGamePlayed = `-- name: AddGamePlayed :exec -UPDATE game SET times_played = times_played + 1, last_played = now() WHERE id = $1 -` - -func (q *Queries) AddGamePlayed(ctx context.Context, id int32) error { - _, err := q.db.Exec(ctx, addGamePlayed, id) - return err -} - -const clearGames = `-- name: ClearGames :exec -DELETE FROM game -` - -func (q *Queries) ClearGames(ctx context.Context) error { - _, err := q.db.Exec(ctx, clearGames) - return err -} - -const findAllGames = `-- name: FindAllGames :many -SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash -FROM game -WHERE deleted IS NULL -ORDER BY game_name -` - -func (q *Queries) FindAllGames(ctx context.Context) ([]Game, error) { - rows, err := q.db.Query(ctx, findAllGames) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Game - for rows.Next() { - var i Game - if err := rows.Scan( - &i.ID, - &i.GameName, - &i.Added, - &i.Deleted, - &i.LastChanged, - &i.Path, - &i.TimesPlayed, - &i.LastPlayed, - &i.NumberOfSongs, - &i.Hash, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAllGamesIncludingDeleted = `-- name: GetAllGamesIncludingDeleted :many -SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash -FROM game -ORDER BY game_name -` - -func (q *Queries) GetAllGamesIncludingDeleted(ctx context.Context) ([]Game, error) { - rows, err := q.db.Query(ctx, getAllGamesIncludingDeleted) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Game - for rows.Next() { - var i Game - if err := rows.Scan( - &i.ID, - &i.GameName, - &i.Added, - &i.Deleted, - &i.LastChanged, - &i.Path, - &i.TimesPlayed, - &i.LastPlayed, - &i.NumberOfSongs, - &i.Hash, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getGameById = `-- name: GetGameById :one -SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash -FROM game -WHERE id = $1 -AND deleted IS NULL -` - -func (q *Queries) GetGameById(ctx context.Context, id int32) (Game, error) { - row := q.db.QueryRow(ctx, getGameById, id) - var i Game - err := row.Scan( - &i.ID, - &i.GameName, - &i.Added, - &i.Deleted, - &i.LastChanged, - &i.Path, - &i.TimesPlayed, - &i.LastPlayed, - &i.NumberOfSongs, - &i.Hash, - ) - return i, err -} - -const getGameNameById = `-- name: GetGameNameById :one -SELECT game_name FROM game WHERE id = $1 -` - -func (q *Queries) GetGameNameById(ctx context.Context, id int32) (string, error) { - row := q.db.QueryRow(ctx, getGameNameById, id) - var game_name string - err := row.Scan(&game_name) - return game_name, err -} - -const getIdByGameName = `-- name: GetIdByGameName :one -SELECT id FROM game WHERE game_name = $1 -` - -func (q *Queries) GetIdByGameName(ctx context.Context, gameName string) (int32, error) { - row := q.db.QueryRow(ctx, getIdByGameName, gameName) - var id int32 - err := row.Scan(&id) - return id, err -} - -const insertGame = `-- name: InsertGame :one -INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id -` - -type InsertGameParams struct { - GameName string `json:"game_name"` - Path string `json:"path"` - Hash string `json:"hash"` -} - -func (q *Queries) InsertGame(ctx context.Context, arg InsertGameParams) (int32, error) { - row := q.db.QueryRow(ctx, insertGame, arg.GameName, arg.Path, arg.Hash) - var id int32 - err := row.Scan(&id) - return id, err -} - -const insertGameWithExistingId = `-- name: InsertGameWithExistingId :exec -INSERT INTO game (id, game_name, path, hash, added) VALUES ($1, $2, $3, $4, now()) -` - -type InsertGameWithExistingIdParams struct { - ID int32 `json:"id"` - GameName string `json:"game_name"` - Path string `json:"path"` - Hash string `json:"hash"` -} - -func (q *Queries) InsertGameWithExistingId(ctx context.Context, arg InsertGameWithExistingIdParams) error { - _, err := q.db.Exec(ctx, insertGameWithExistingId, - arg.ID, - arg.GameName, - arg.Path, - arg.Hash, - ) - return err -} - -const removeDeletionDate = `-- name: RemoveDeletionDate :exec -UPDATE game SET deleted=NULL WHERE id=$1 -` - -func (q *Queries) RemoveDeletionDate(ctx context.Context, id int32) error { - _, err := q.db.Exec(ctx, removeDeletionDate, id) - return err -} - -const resetGameIdSeq = `-- name: ResetGameIdSeq :one -SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1) -` - -func (q *Queries) ResetGameIdSeq(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, resetGameIdSeq) - var setval int64 - err := row.Scan(&setval) - return setval, err -} - -const setGameDeletionDate = `-- name: SetGameDeletionDate :exec -UPDATE game SET deleted=now() WHERE deleted IS NULL -` - -func (q *Queries) SetGameDeletionDate(ctx context.Context) error { - _, err := q.db.Exec(ctx, setGameDeletionDate) - return err -} - -const updateGameHash = `-- name: UpdateGameHash :exec -UPDATE game SET hash=$1, last_changed=now() WHERE id=$2 -` - -type UpdateGameHashParams struct { - Hash string `json:"hash"` - ID int32 `json:"id"` -} - -func (q *Queries) UpdateGameHash(ctx context.Context, arg UpdateGameHashParams) error { - _, err := q.db.Exec(ctx, updateGameHash, arg.Hash, arg.ID) - return err -} - -const updateGameName = `-- name: UpdateGameName :exec -UPDATE game SET game_name=$1, path=$2, last_changed=now() WHERE id=$3 -` - -type UpdateGameNameParams struct { - Name string `json:"name"` - Path string `json:"path"` - ID int32 `json:"id"` -} - -func (q *Queries) UpdateGameName(ctx context.Context, arg UpdateGameNameParams) error { - _, err := q.db.Exec(ctx, updateGameName, arg.Name, arg.Path, arg.ID) - return err -} diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index aed7c47..1a672cf 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -10,19 +10,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -type Game struct { - ID int32 `json:"id"` - GameName string `json:"game_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 Session struct { Token string `json:"token"` IpAddress string `json:"ip_address"` @@ -33,20 +20,34 @@ type Session struct { } type Song struct { - GameID int32 `json:"game_id"` - SongName string `json:"song_name"` - Path string `json:"path"` - TimesPlayed int32 `json:"times_played"` - Hash string `json:"hash"` - FileName *string `json:"file_name"` + SoundtrackID int32 `json:"soundtrack_id"` + SongName string `json:"song_name"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + Hash string `json:"hash"` + FileName *string `json:"file_name"` + ID pgtype.Int4 `json:"id"` } type SongList struct { - MatchDate time.Time `json:"match_date"` - MatchID int32 `json:"match_id"` - SongNo int32 `json:"song_no"` - GameName *string `json:"game_name"` - SongName *string `json:"song_name"` + MatchDate time.Time `json:"match_date"` + MatchID int32 `json:"match_id"` + SongNo int32 `json:"song_no"` + SoundtrackName *string `json:"soundtrack_name"` + SongName *string `json:"song_name"` +} + +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"` } type Vgmq struct { diff --git a/internal/db/repository/song.sql.go b/internal/db/repository/song.sql.go index 28b60c5..048f630 100644 --- a/internal/db/repository/song.sql.go +++ b/internal/db/repository/song.sql.go @@ -7,37 +7,40 @@ package repository import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const addHashToSong = `-- name: AddHashToSong :exec -UPDATE song SET hash=$1 where path=$2 +UPDATE song SET hash=$1 where soundtrack_id = $2 AND path = $3 ` type AddHashToSongParams struct { - Hash string `json:"hash"` - Path string `json:"path"` + Hash string `json:"hash"` + SoundtrackID int32 `json:"soundtrack_id"` + Path string `json:"path"` } func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) error { - _, err := q.db.Exec(ctx, addHashToSong, arg.Hash, arg.Path) + _, err := q.db.Exec(ctx, addHashToSong, arg.Hash, arg.SoundtrackID, arg.Path) return err } const addSong = `-- name: AddSong :exec -INSERT INTO song(game_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5) +INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5) ` type AddSongParams struct { - GameID int32 `json:"game_id"` - SongName string `json:"song_name"` - Path string `json:"path"` - FileName *string `json:"file_name"` - Hash string `json:"hash"` + SoundtrackID int32 `json:"soundtrack_id"` + SongName string `json:"song_name"` + Path string `json:"path"` + FileName *string `json:"file_name"` + Hash string `json:"hash"` } func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error { _, err := q.db.Exec(ctx, addSong, - arg.GameID, + arg.SoundtrackID, arg.SongName, arg.Path, arg.FileName, @@ -48,25 +51,30 @@ func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error { const addSongPlayed = `-- name: AddSongPlayed :exec UPDATE song SET times_played = times_played + 1 -WHERE game_id = $1 AND song_name = $2 +WHERE soundtrack_id = $1 AND song_name = $2 ` type AddSongPlayedParams struct { - GameID int32 `json:"game_id"` - SongName string `json:"song_name"` + SoundtrackID int32 `json:"soundtrack_id"` + SongName string `json:"song_name"` } func (q *Queries) AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error { - _, err := q.db.Exec(ctx, addSongPlayed, arg.GameID, arg.SongName) + _, err := q.db.Exec(ctx, addSongPlayed, arg.SoundtrackID, arg.SongName) return err } const checkSong = `-- name: CheckSong :one -SELECT COUNT(*) FROM song WHERE path = $1 +SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2 ` -func (q *Queries) CheckSong(ctx context.Context, path string) (int64, error) { - row := q.db.QueryRow(ctx, checkSong, path) +type CheckSongParams struct { + SoundtrackID int32 `json:"soundtrack_id"` + Path string `json:"path"` +} + +func (q *Queries) CheckSong(ctx context.Context, arg CheckSongParams) (int64, error) { + row := q.db.QueryRow(ctx, checkSong, arg.SoundtrackID, arg.Path) var count int64 err := row.Scan(&count) return count, err @@ -92,17 +100,17 @@ func (q *Queries) ClearSongs(ctx context.Context) error { return err } -const clearSongsByGameId = `-- name: ClearSongsByGameId :exec -DELETE FROM song WHERE game_id = $1 +const clearSongsBySoundtrackId = `-- name: ClearSongsBySoundtrackId :exec +DELETE FROM song WHERE soundtrack_id = $1 ` -func (q *Queries) ClearSongsByGameId(ctx context.Context, gameID int32) error { - _, err := q.db.Exec(ctx, clearSongsByGameId, gameID) +func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error { + _, err := q.db.Exec(ctx, clearSongsBySoundtrackId, soundtrackID) return err } const fetchAllSongs = `-- name: FetchAllSongs :many -SELECT game_id, song_name, path, times_played, hash, file_name 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) { @@ -115,12 +123,13 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { for rows.Next() { var i Song if err := rows.Scan( - &i.GameID, + &i.SoundtrackID, &i.SongName, &i.Path, &i.TimesPlayed, &i.Hash, &i.FileName, + &i.ID, ); err != nil { return nil, err } @@ -132,14 +141,14 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { return items, nil } -const findSongsFromGame = `-- name: FindSongsFromGame :many -SELECT game_id, song_name, path, times_played, hash, file_name +const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song -WHERE game_id = $1 +WHERE soundtrack_id = $1 ` -func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song, error) { - rows, err := q.db.Query(ctx, findSongsFromGame, gameID) +func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error) { + rows, err := q.db.Query(ctx, findSongsFromSoundtrack, soundtrackID) if err != nil { return nil, err } @@ -148,12 +157,13 @@ func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song, for rows.Next() { var i Song if err := rows.Scan( - &i.GameID, + &i.SoundtrackID, &i.SongName, &i.Path, &i.TimesPlayed, &i.Hash, &i.FileName, + &i.ID, ); err != nil { return nil, err } @@ -165,39 +175,64 @@ func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song, return items, nil } +const getSongById = `-- name: GetSongById :one +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) { + row := q.db.QueryRow(ctx, getSongById, id) + var i Song + err := row.Scan( + &i.SoundtrackID, + &i.SongName, + &i.Path, + &i.TimesPlayed, + &i.Hash, + &i.FileName, + &i.ID, + ) + return i, err +} + const getSongWithHash = `-- name: GetSongWithHash :one -SELECT game_id, song_name, path, times_played, hash, file_name 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) { row := q.db.QueryRow(ctx, getSongWithHash, hash) var i Song err := row.Scan( - &i.GameID, + &i.SoundtrackID, &i.SongName, &i.Path, &i.TimesPlayed, &i.Hash, &i.FileName, + &i.ID, ) return i, err } const removeBrokenSong = `-- name: RemoveBrokenSong :exec -DELETE FROM song WHERE path = $1 +DELETE FROM song WHERE soundtrack_id = $1 AND path = $2 ` -func (q *Queries) RemoveBrokenSong(ctx context.Context, path string) error { - _, err := q.db.Exec(ctx, removeBrokenSong, path) +type RemoveBrokenSongParams struct { + SoundtrackID int32 `json:"soundtrack_id"` + Path string `json:"path"` +} + +func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error { + _, err := q.db.Exec(ctx, removeBrokenSong, arg.SoundtrackID, arg.Path) return err } const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec -DELETE FROM song where path = any ($1) +DELETE FROM song WHERE id = ANY($1) ` -func (q *Queries) RemoveBrokenSongs(ctx context.Context, paths []string) error { - _, err := q.db.Exec(ctx, removeBrokenSongs, paths) +func (q *Queries) RemoveBrokenSongs(ctx context.Context, id pgtype.Int4) error { + _, err := q.db.Exec(ctx, removeBrokenSongs, id) return err } diff --git a/internal/db/repository/song_list.sql.go b/internal/db/repository/song_list.sql.go index 0deeae4..c1a31e2 100644 --- a/internal/db/repository/song_list.sql.go +++ b/internal/db/repository/song_list.sql.go @@ -11,7 +11,7 @@ import ( ) const getSongList = `-- name: GetSongList :many -SELECT match_date, match_id, song_no, game_name, song_name +SELECT match_date, match_id, song_no, soundtrack_name, song_name FROM song_list WHERE match_date = $1 ORDER BY song_no DESC @@ -30,7 +30,7 @@ func (q *Queries) GetSongList(ctx context.Context, matchDate time.Time) ([]SongL &i.MatchDate, &i.MatchID, &i.SongNo, - &i.GameName, + &i.SoundtrackName, &i.SongName, ); err != nil { return nil, err @@ -44,16 +44,16 @@ func (q *Queries) GetSongList(ctx context.Context, matchDate time.Time) ([]SongL } const insertSongInList = `-- name: InsertSongInList :exec -INSERT INTO song_list (match_date, match_id, song_no, game_name, song_name) +INSERT INTO song_list (match_date, match_id, song_no, soundtrack_name, song_name) VALUES ($1, $2, $3, $4, $5) ` type InsertSongInListParams struct { - MatchDate time.Time `json:"match_date"` - MatchID int32 `json:"match_id"` - SongNo int32 `json:"song_no"` - GameName *string `json:"game_name"` - SongName *string `json:"song_name"` + MatchDate time.Time `json:"match_date"` + MatchID int32 `json:"match_id"` + SongNo int32 `json:"song_no"` + SoundtrackName *string `json:"soundtrack_name"` + SongName *string `json:"song_name"` } func (q *Queries) InsertSongInList(ctx context.Context, arg InsertSongInListParams) error { @@ -61,7 +61,7 @@ func (q *Queries) InsertSongInList(ctx context.Context, arg InsertSongInListPara arg.MatchDate, arg.MatchID, arg.SongNo, - arg.GameName, + arg.SoundtrackName, arg.SongName, ) return err diff --git a/internal/db/repository/soundtrack.sql.go b/internal/db/repository/soundtrack.sql.go new file mode 100644 index 0000000..bc38704 --- /dev/null +++ b/internal/db/repository/soundtrack.sql.go @@ -0,0 +1,246 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: soundtrack.sql + +package repository + +import ( + "context" +) + +const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec +UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1 +` + +func (q *Queries) AddSoundtrackPlayed(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, addSoundtrackPlayed, id) + return err +} + +const clearSoundtracks = `-- name: ClearSoundtracks :exec +DELETE FROM soundtrack +` + +func (q *Queries) ClearSoundtracks(ctx context.Context) error { + _, err := q.db.Exec(ctx, clearSoundtracks) + return err +} + +const findAllSoundtracks = `-- name: FindAllSoundtracks :many +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 +` + +func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) { + rows, err := q.db.Query(ctx, findAllSoundtracks) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Soundtrack + for rows.Next() { + var i Soundtrack + if err := rows.Scan( + &i.ID, + &i.SoundtrackName, + &i.Added, + &i.Deleted, + &i.LastChanged, + &i.Path, + &i.TimesPlayed, + &i.LastPlayed, + &i.NumberOfSongs, + &i.Hash, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash +FROM soundtrack +ORDER BY soundtrack_name +` + +func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error) { + rows, err := q.db.Query(ctx, getAllSoundtracksIncludingDeleted) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Soundtrack + for rows.Next() { + var i Soundtrack + if err := rows.Scan( + &i.ID, + &i.SoundtrackName, + &i.Added, + &i.Deleted, + &i.LastChanged, + &i.Path, + &i.TimesPlayed, + &i.LastPlayed, + &i.NumberOfSongs, + &i.Hash, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getIdBySoundtrackName = `-- name: GetIdBySoundtrackName :one +SELECT id FROM soundtrack WHERE soundtrack_name = $1 +` + +func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error) { + row := q.db.QueryRow(ctx, getIdBySoundtrackName, soundtrackName) + var id int32 + err := row.Scan(&id) + return id, err +} + +const getSoundtrackById = `-- name: GetSoundtrackById :one +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 +` + +func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, error) { + row := q.db.QueryRow(ctx, getSoundtrackById, id) + var i Soundtrack + err := row.Scan( + &i.ID, + &i.SoundtrackName, + &i.Added, + &i.Deleted, + &i.LastChanged, + &i.Path, + &i.TimesPlayed, + &i.LastPlayed, + &i.NumberOfSongs, + &i.Hash, + ) + return i, err +} + +const getSoundtrackNameById = `-- name: GetSoundtrackNameById :one +SELECT soundtrack_name FROM soundtrack WHERE id = $1 +` + +func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string, error) { + row := q.db.QueryRow(ctx, getSoundtrackNameById, id) + var soundtrack_name string + err := row.Scan(&soundtrack_name) + return soundtrack_name, err +} + +const insertSoundtrack = `-- name: InsertSoundtrack :one +INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id +` + +type InsertSoundtrackParams struct { + SoundtrackName string `json:"soundtrack_name"` + Path string `json:"path"` + Hash string `json:"hash"` +} + +func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) { + row := q.db.QueryRow(ctx, insertSoundtrack, arg.SoundtrackName, arg.Path, arg.Hash) + var id int32 + err := row.Scan(&id) + return id, err +} + +const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec +INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now()) +` + +type InsertSoundtrackWithExistingIdParams struct { + ID int32 `json:"id"` + SoundtrackName string `json:"soundtrack_name"` + Path string `json:"path"` + Hash string `json:"hash"` +} + +func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error { + _, err := q.db.Exec(ctx, insertSoundtrackWithExistingId, + arg.ID, + arg.SoundtrackName, + arg.Path, + arg.Hash, + ) + return err +} + +const removeSoundtrackDeletionDate = `-- name: RemoveSoundtrackDeletionDate :exec +UPDATE soundtrack SET deleted=NULL WHERE id=$1 +` + +func (q *Queries) RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, removeSoundtrackDeletionDate, id) + return err +} + +const resetSoundtrackIdSeq = `-- name: ResetSoundtrackIdSeq :one +SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1) +` + +func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, resetSoundtrackIdSeq) + var setval int64 + err := row.Scan(&setval) + return setval, err +} + +const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec +UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL +` + +func (q *Queries) SetSoundtrackDeletionDate(ctx context.Context) error { + _, err := q.db.Exec(ctx, setSoundtrackDeletionDate) + return err +} + +const updateSoundtrackHash = `-- name: UpdateSoundtrackHash :exec +UPDATE soundtrack SET hash=$1, last_changed=now() WHERE id=$2 +` + +type UpdateSoundtrackHashParams struct { + Hash string `json:"hash"` + ID int32 `json:"id"` +} + +func (q *Queries) UpdateSoundtrackHash(ctx context.Context, arg UpdateSoundtrackHashParams) error { + _, err := q.db.Exec(ctx, updateSoundtrackHash, arg.Hash, arg.ID) + return err +} + +const updateSoundtrackName = `-- name: UpdateSoundtrackName :exec +UPDATE soundtrack SET soundtrack_name=$1, path=$2, last_changed=now() WHERE id=$3 +` + +type UpdateSoundtrackNameParams struct { + Name string `json:"name"` + Path string `json:"path"` + ID int32 `json:"id"` +} + +func (q *Queries) UpdateSoundtrackName(ctx context.Context, arg UpdateSoundtrackNameParams) error { + _, err := q.db.Exec(ctx, updateSoundtrackName, arg.Name, arg.Path, arg.ID) + return err +} diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go new file mode 100644 index 0000000..a29da58 --- /dev/null +++ b/internal/db/repository/statistics.sql.go @@ -0,0 +1,435 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: statistics.sql + +package repository + +import ( + "context" + "time" +) + +const getLastPlayedGames = `-- name: GetLastPlayedGames :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL AND g.last_played IS NOT NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.last_played DESC +LIMIT $1 +` + +type GetLastPlayedGamesRow struct { + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` +} + +// Last played soundtracks (most recently played) +func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLastPlayedGamesRow, error) { + rows, err := q.db.Query(ctx, getLastPlayedGames, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetLastPlayedGamesRow + for rows.Next() { + var i GetLastPlayedGamesRow + if err := rows.Scan( + &i.SoundtrackID, + &i.SoundtrackName, + &i.SoundtrackPlayed, + &i.SoundtrackLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLeastPlayedGamesWithSongs = `-- name: GetLeastPlayedGamesWithSongs :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.times_played ASC, g.soundtrack_name +LIMIT $1 +` + +type GetLeastPlayedGamesWithSongsRow struct { + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` +} + +// Least played soundtracks with their songs +func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedGamesWithSongsRow, error) { + rows, err := q.db.Query(ctx, getLeastPlayedGamesWithSongs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetLeastPlayedGamesWithSongsRow + for rows.Next() { + var i GetLeastPlayedGamesWithSongsRow + if err := rows.Scan( + &i.SoundtrackID, + &i.SoundtrackName, + &i.SoundtrackPlayed, + &i.SoundtrackLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLeastPlayedSongsWithGame = `-- name: GetLeastPlayedSongsWithGame :many +SELECT + s.soundtrack_id as soundtrack_id, + g.soundtrack_name, + s.song_name, + s.path, + s.times_played, + s.file_name +FROM song s +JOIN soundtrack g ON s.soundtrack_id = g.id +WHERE g.deleted IS NULL +ORDER BY s.times_played ASC, s.song_name +LIMIT $1 +` + +type GetLeastPlayedSongsWithGameRow struct { + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackName string `json:"soundtrack_name"` + SongName string `json:"song_name"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + FileName *string `json:"file_name"` +} + +// Least played songs with their soundtrack info +func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithGameRow, error) { + rows, err := q.db.Query(ctx, getLeastPlayedSongsWithGame, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetLeastPlayedSongsWithGameRow + for rows.Next() { + var i GetLeastPlayedSongsWithGameRow + if err := rows.Scan( + &i.SoundtrackID, + &i.SoundtrackName, + &i.SongName, + &i.Path, + &i.TimesPlayed, + &i.FileName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMostPlayedGamesWithSongs = `-- name: GetMostPlayedGamesWithSongs :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.times_played DESC, g.soundtrack_name +LIMIT $1 +` + +type GetMostPlayedGamesWithSongsRow struct { + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` +} + +// Most played soundtracks with their songs +func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedGamesWithSongsRow, error) { + rows, err := q.db.Query(ctx, getMostPlayedGamesWithSongs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMostPlayedGamesWithSongsRow + for rows.Next() { + var i GetMostPlayedGamesWithSongsRow + if err := rows.Scan( + &i.SoundtrackID, + &i.SoundtrackName, + &i.SoundtrackPlayed, + &i.SoundtrackLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMostPlayedSongsWithGame = `-- name: GetMostPlayedSongsWithGame :many +SELECT + s.soundtrack_id as soundtrack_id, + g.soundtrack_name, + s.song_name, + s.path, + s.times_played, + s.file_name +FROM song s +JOIN soundtrack g ON s.soundtrack_id = g.id +WHERE g.deleted IS NULL +ORDER BY s.times_played DESC, s.song_name +LIMIT $1 +` + +type GetMostPlayedSongsWithGameRow struct { + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackName string `json:"soundtrack_name"` + SongName string `json:"song_name"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + FileName *string `json:"file_name"` +} + +// Most played songs with their soundtrack info +func (q *Queries) GetMostPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithGameRow, error) { + rows, err := q.db.Query(ctx, getMostPlayedSongsWithGame, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMostPlayedSongsWithGameRow + for rows.Next() { + var i GetMostPlayedSongsWithGameRow + if err := rows.Scan( + &i.SoundtrackID, + &i.SoundtrackName, + &i.SongName, + &i.Path, + &i.TimesPlayed, + &i.FileName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getNeverPlayedGames = `-- name: GetNeverPlayedGames :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.added, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL AND g.times_played = 0 +GROUP BY g.id, g.soundtrack_name, g.times_played, g.added +ORDER BY g.soundtrack_name +` + +type GetNeverPlayedGamesRow struct { + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + Added time.Time `json:"added"` + Songs []byte `json:"songs"` +} + +// Games that have never been played (times_played = 0) +func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGamesRow, error) { + rows, err := q.db.Query(ctx, getNeverPlayedGames) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetNeverPlayedGamesRow + for rows.Next() { + var i GetNeverPlayedGamesRow + if err := rows.Scan( + &i.SoundtrackID, + &i.SoundtrackName, + &i.SoundtrackPlayed, + &i.Added, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getOldestPlayedGames = `-- name: GetOldestPlayedGames :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL AND g.last_played IS NOT NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.last_played ASC +LIMIT $1 +` + +type GetOldestPlayedGamesRow struct { + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` +} + +// Oldest played soundtracks (least recently played, but has been played at least once) +func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetOldestPlayedGamesRow, error) { + rows, err := q.db.Query(ctx, getOldestPlayedGames, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetOldestPlayedGamesRow + for rows.Next() { + var i GetOldestPlayedGamesRow + if err := rows.Scan( + &i.SoundtrackID, + &i.SoundtrackName, + &i.SoundtrackPlayed, + &i.SoundtrackLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getStatisticsSummary = `-- name: GetStatisticsSummary :one +SELECT + COUNT(*) as total_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, + COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays +FROM soundtrack +WHERE deleted IS NULL +` + +type GetStatisticsSummaryRow struct { + TotalSoundtracks int64 `json:"total_soundtracks"` + PlayedSoundtracks int64 `json:"played_soundtracks"` + NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"` + TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"` + AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"` + MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"` + MinSoundtrackPlays int64 `json:"min_soundtrack_plays"` +} + +// Get statistics summary +func (q *Queries) GetStatisticsSummary(ctx context.Context) (GetStatisticsSummaryRow, error) { + row := q.db.QueryRow(ctx, getStatisticsSummary) + var i GetStatisticsSummaryRow + err := row.Scan( + &i.TotalSoundtracks, + &i.PlayedSoundtracks, + &i.NeverPlayedSoundtracks, + &i.TotalSoundtrackPlays, + &i.AvgSoundtrackPlays, + &i.MaxSoundtrackPlays, + &i.MinSoundtrackPlays, + ) + return i, err +} diff --git a/internal/db/test_helpers.go b/internal/db/test_helpers.go index ffb5475..29924f4 100644 --- a/internal/db/test_helpers.go +++ b/internal/db/test_helpers.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "fmt" "log" @@ -16,6 +17,8 @@ var ( testDBUser string testDBPassword string testDBName string + // TestDatabase is the database instance for tests + TestDatabase *Database ) // TestSetupDB initializes the test database using existing functions @@ -44,9 +47,28 @@ func TestSetupDB(t *testing.T) { // Create the database first (testuser is a superuser in the container) createTestDatabase(host, port, dbname, user, password) - // Now run migrations using the existing function - Migrate_db(host, port, user, password, dbname) - InitDB(host, port, user, password, dbname) + // Create database instance and run migrations + var err error + TestDatabase, err = NewDatabase(host, port, user, password, dbname) + if err != nil { + t.Fatalf("Failed to initialize test database: %v", err) + } + + // Clean up any existing schema to ensure clean state + ctx := context.Background() + _, err = TestDatabase.Pool.Exec(ctx, "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;") + if err != nil { + t.Logf("Warning: Could not clean schema: %v", err) + // Continue anyway, migrations might still work + } + + // Run migrations + if err := TestDatabase.RunMigrations(); err != nil { + // Clean up on failure to prevent nil pointer issues in other tests + TestDatabase.Close() + TestDatabase = nil + t.Fatalf("Failed to run migrations: %v", err) + } }) } @@ -86,33 +108,43 @@ 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 + // Note: We also don't nil TestDatabase to allow reuse across tests + // if TestDatabase != nil { + // TestDatabase.Close() + // TestDatabase = nil + // } } // TestClearDatabase clears all data from the test database // Useful for running tests with a clean slate func TestClearDatabase(t *testing.T) { - if Dbpool == nil { + if TestDatabase == nil || TestDatabase.Pool == nil { t.Skip("Database not initialized") } // Clear all tables in reverse order to respect foreign keys // Note: This assumes the tables exist and have the expected structure + // After migration 000005, game table was renamed to soundtrack tables := []string{ "song_list", "song", - "game", + "soundtrack", + "vgmq", + "sessions", } + ctx := context.Background() for _, table := range tables { - _, err := Dbpool.Exec(Ctx, "TRUNCATE TABLE "+table+" CASCADE") + _, err := TestDatabase.Pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE") if err != nil { t.Logf("Failed to truncate table %s: %v", table, err) } } - // Reset sequences - _, err := Dbpool.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/middleware/deprecation.go b/internal/server/middleware/deprecation.go new file mode 100644 index 0000000..cd30eaa --- /dev/null +++ b/internal/server/middleware/deprecation.go @@ -0,0 +1,16 @@ +package middleware + +import ( + "github.com/labstack/echo/v5" +) + +// 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) + } +} diff --git a/internal/server/musicHandler.go b/internal/server/musicHandler.go index f908e96..946dab9 100644 --- a/internal/server/musicHandler.go +++ b/internal/server/musicHandler.go @@ -229,8 +229,8 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error { return ctx.Stream(http.StatusOK, "audio/mpeg", file) } -// GetAllGames godoc -// @Summary Get all games +// GetAllSoundtracks godoc +// @Summary Get all soundtracks // @Description Returns a list of all games in order // @Tags music // @Accept json @@ -238,17 +238,17 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error { // @Success 200 {array} map[string]interface{} // @Failure 423 {string} string "Syncing is in progress" // @Router /music/all/order [get] -func (m *MusicHandler) GetAllGames(ctx *echo.Context) error { +func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error { if backend.Syncing { logging.GetLogger().Info("Syncing is in progress") return ctx.JSON(http.StatusLocked, "Syncing is in progress") } - gameList := backend.GetAllGames() - return ctx.JSON(http.StatusOK, gameList) + soundtrackList := backend.GetAllSoundtracks() + return ctx.JSON(http.StatusOK, soundtrackList) } -// GetAllGamesRandom godoc -// @Summary Get all games random +// GetAllSoundtracksRandom godoc +// @Summary Get all soundtracks random // @Description Returns a list of all games in random order // @Tags music // @Accept json @@ -256,13 +256,13 @@ func (m *MusicHandler) GetAllGames(ctx *echo.Context) error { // @Success 200 {array} map[string]interface{} // @Failure 423 {string} string "Syncing is in progress" // @Router /music/all/random [get] -func (m *MusicHandler) GetAllGamesRandom(ctx *echo.Context) error { +func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error { if backend.Syncing { logging.GetLogger().Info("Syncing is in progress") return ctx.JSON(http.StatusLocked, "Syncing is in progress") } - gameList := backend.GetAllGamesRandom() - return ctx.JSON(http.StatusOK, gameList) + soundtrackList := backend.GetAllSoundtracksRandom() + return ctx.JSON(http.StatusOK, soundtrackList) } // PutPlayed godoc diff --git a/internal/server/routes.go b/internal/server/routes.go index a83b8ce..17a4f92 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -58,59 +58,64 @@ func (s *Server) RegisterRoutes() http.Handler { // Swagger UI e.GET("/swagger/*", echoSwagger.WrapHandler) - health := NewHealthHandler() - e.GET("/health", health.HealthCheck) + // ============================================ + // Legacy Endpoints (Deprecated - use /api/v1/ instead) + // ============================================ + deprecatedMiddleware := middleware.DeprecationMiddleware + + health := NewHealthHandler(s.db) + e.GET("/health", deprecatedMiddleware(health.HealthCheck)) version := NewVersionHandler() - e.GET("/version", version.GetLatestVersion) - e.GET("/version/history", version.GetVersionHistory) + e.GET("/version", deprecatedMiddleware(version.GetLatestVersion)) + e.GET("/version/history", deprecatedMiddleware(version.GetVersionHistory)) character := NewCharacterHandler() - e.GET("/character", character.GetCharacter) - e.GET("/characters", character.GetCharacterList) + e.GET("/character", deprecatedMiddleware(character.GetCharacter)) + e.GET("/characters", deprecatedMiddleware(character.GetCharacterList)) download := NewDownloadHandler() - e.GET("/download", download.checkLatest) - e.GET("/download/list", download.listAssetsOfLatest) - e.GET("/download/windows", download.downloadLatestWindows) - e.GET("/download/linux", download.downloadLatestLinux) + e.GET("/download", deprecatedMiddleware(download.checkLatest)) + e.GET("/download/list", deprecatedMiddleware(download.listAssetsOfLatest)) + e.GET("/download/windows", deprecatedMiddleware(download.downloadLatestWindows)) + e.GET("/download/linux", deprecatedMiddleware(download.downloadLatestLinux)) sync := NewSyncHandler() syncGroup := e.Group("/sync") - syncGroup.GET("", sync.SyncGamesNewOnlyChanges) - syncGroup.GET("/progress", sync.SyncProgress) - syncGroup.GET("/new", sync.SyncGamesNewOnlyChanges) - syncGroup.GET("/full", sync.SyncGamesNewFull) - syncGroup.GET("/new/full", sync.SyncGamesNewFull) - syncGroup.GET("/quick", sync.SyncGamesNewOnlyChanges) - syncGroup.GET("/reset", sync.ResetGames) + syncGroup.GET("", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) + syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress)) + syncGroup.GET("/new", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) + syncGroup.GET("/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull)) + syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull)) + syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) + syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetDB)) music := NewMusicHandler() musicGroup := e.Group("/music") - musicGroup.GET("", music.GetSong) - musicGroup.GET("/soundTest", music.GetSoundCheckSong) - musicGroup.GET("/reset", music.ResetMusic) - musicGroup.GET("/rand", music.GetRandomSong) - musicGroup.GET("/rand/low", music.GetRandomSongLowChance) - musicGroup.GET("/rand/classic", music.GetRandomSongClassic) - musicGroup.GET("/info", music.GetSongInfo) - musicGroup.GET("/list", music.GetPlayedSongs) - musicGroup.GET("/next", music.GetNextSong) - musicGroup.GET("/previous", music.GetPreviousSong) - musicGroup.GET("/all", music.GetAllGamesRandom) - musicGroup.GET("/all/order", music.GetAllGames) - musicGroup.GET("/all/random", music.GetAllGamesRandom) - musicGroup.PUT("/played", music.PutPlayed) - musicGroup.GET("/addQue", music.AddLatestToQue) - musicGroup.GET("/addPlayed", music.AddLatestPlayed) + musicGroup.GET("", deprecatedMiddleware(music.GetSong)) + musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong)) + musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic)) + musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong)) + musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance)) + musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic)) + musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo)) + musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs)) + musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong)) + musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong)) + musicGroup.GET("/all", deprecatedMiddleware(music.GetAllSoundtracksRandom)) + musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllSoundtracks)) + musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllSoundtracksRandom)) + musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed)) + musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue)) + musicGroup.GET("/addPlayed", deprecatedMiddleware(music.AddLatestPlayed)) // ============================================ // 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) @@ -126,10 +131,38 @@ func (s *Server) RegisterRoutes() http.Handler { // 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) + // Protected group with token authentication + protectedV1 := apiV1.Group("", tokenAuthMiddleware) - // Note: Future protected endpoints (VGMQ, Statistics) will be added here + // Statistics API endpoints (protected by token auth) + statistics := s.statisticsHandler + protectedV1.GET("/statistics/games/most-played", func(c *echo.Context) error { + return statistics.GetMostPlayedGames(c) + }) + protectedV1.GET("/statistics/games/least-played", func(c *echo.Context) error { + return statistics.GetLeastPlayedGames(c) + }) + protectedV1.GET("/statistics/games/never-played", func(c *echo.Context) error { + return statistics.GetNeverPlayedGames(c) + }) + protectedV1.GET("/statistics/games/last-played", func(c *echo.Context) error { + return statistics.GetLastPlayedGames(c) + }) + protectedV1.GET("/statistics/games/oldest-played", func(c *echo.Context) error { + return statistics.GetOldestPlayedGames(c) + }) + protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error { + return statistics.GetMostPlayedSongs(c) + }) + protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error { + return statistics.GetLeastPlayedSongs(c) + }) + protectedV1.GET("/statistics/summary", func(c *echo.Context) error { + return statistics.GetStatisticsSummary(c) + }) + + // Future: VGMQ endpoints will be added to protectedV1 group + _ = protectedV1 // Use the variable to avoid unused variable error routes := e.Router().Routes() sort.Slice(routes, func(i, j int) bool { diff --git a/internal/server/server.go b/internal/server/server.go index b0e1b85..2c6fbc5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,10 +15,11 @@ import ( ) type Server struct { - port int - db *db.Database - tokenHandler *TokenHandler - httpServer *http.Server + port int + db *db.Database + tokenHandler *TokenHandler + statisticsHandler *StatisticsHandler + httpServer *http.Server } var ( @@ -67,12 +68,16 @@ func NewServerInstance() *Server { // Initialize token handler with database pool tokenHandler := NewTokenHandler(database.Pool) + + // Initialize statistics handler + statisticsHandler := NewStatisticsHandler() // Create the server instance appServer := &Server{ - port: port, - db: database, - tokenHandler: tokenHandler, + port: port, + db: database, + tokenHandler: tokenHandler, + statisticsHandler: statisticsHandler, } // Create the HTTP server diff --git a/internal/server/statistics_handler.go b/internal/server/statistics_handler.go new file mode 100644 index 0000000..614d247 --- /dev/null +++ b/internal/server/statistics_handler.go @@ -0,0 +1,275 @@ +package server + +import ( + "net/http" + "strconv" + + "music-server/internal/backend" + "music-server/internal/logging" + + "github.com/labstack/echo/v5" + "go.uber.org/zap" +) + +// StatisticsHandler handles statistics-related HTTP requests +type StatisticsHandler struct { + statsBackend *backend.StatisticsHandler +} + +// NewStatisticsHandler creates a new StatisticsHandler +func NewStatisticsHandler() *StatisticsHandler { + return &StatisticsHandler{ + statsBackend: backend.NewStatisticsHandler(), + } +} + +// GetMostPlayedGames returns top N most played games with songs +// GET /api/v1/statistics/games/most-played +// +// @Summary Get most played games +// @Description Returns the top N most played games with their songs +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.GameWithSongs +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/most-played [get] +func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error { + limit := 10 // default + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + // Cap at 100 for performance + if limit > 100 { + limit = 100 + } + } + + games, err := h.statsBackend.GetMostPlayedGamesWithSongs(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get most played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetLeastPlayedGames returns top N least played games with songs +// GET /api/v1/statistics/games/least-played +// +// @Summary Get least played games +// @Description Returns the top N least played games with their songs +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.GameWithSongs +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/least-played [get] +func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + games, err := h.statsBackend.GetLeastPlayedGamesWithSongs(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get least played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetMostPlayedSongs returns top N most played songs with game info +// GET /api/v1/statistics/songs/most-played +// +// @Summary Get most played songs +// @Description Returns the top N most played songs with their game info +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @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 { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + songs, err := h.statsBackend.GetMostPlayedSongsWithGame(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, songs) +} + +// GetLeastPlayedSongs returns top N least played songs with game info +// GET /api/v1/statistics/songs/least-played +// +// @Summary Get least played songs +// @Description Returns the top N least played songs with their game info +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @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 { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + songs, err := h.statsBackend.GetLeastPlayedSongsWithGame(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, songs) +} + +// GetNeverPlayedGames returns games that have never been played +// GET /api/v1/statistics/games/never-played +// +// @Summary Get never played games +// @Description Returns all games that have never been played (times_played = 0) +// @Tags statistics +// @Accept json +// @Produce json +// @Success 200 {array} backend.GameWithSongs +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/never-played [get] +func (h *StatisticsHandler) GetNeverPlayedGames(ctx *echo.Context) error { + games, err := h.statsBackend.GetNeverPlayedGames() + if err != nil { + logging.GetLogger().Error("Failed to get never played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetLastPlayedGames returns most recently played games +// GET /api/v1/statistics/games/last-played +// +// @Summary Get last played games +// @Description Returns the most recently played games +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.GameWithSongs +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/last-played [get] +func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + games, err := h.statsBackend.GetLastPlayedGames(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get last played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetOldestPlayedGames returns least recently played games +// GET /api/v1/statistics/games/oldest-played +// +// @Summary Get oldest played games +// @Description Returns the least recently played games (that have been played at least once) +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.GameWithSongs +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/oldest-played [get] +func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + games, err := h.statsBackend.GetOldestPlayedGames(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get oldest played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetStatisticsSummary returns overall statistics +// GET /api/v1/statistics/summary +// +// @Summary Get statistics summary +// @Description Returns overall statistics about the music library +// @Tags statistics +// @Accept json +// @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 { + summary, err := h.statsBackend.GetStatisticsSummary() + if err != nil { + logging.GetLogger().Error("Failed to get statistics summary", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, summary) +} diff --git a/internal/server/statistics_handler_test.go b/internal/server/statistics_handler_test.go new file mode 100644 index 0000000..3a663c0 --- /dev/null +++ b/internal/server/statistics_handler_test.go @@ -0,0 +1,179 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "music-server/internal/backend" + "music-server/internal/db" + "music-server/internal/db/repository" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" +) + +// TestStatisticsEndpoints tests the statistics API endpoints +func TestStatisticsEndpoints(t *testing.T) { + // Skip if test database not configured + e := StartTestServer(t) + if e == nil { + t.Skip("Test database not configured") + } + + // Get token first + token := getTestToken(t, e) + if token == "" { + t.Skip("Could not get test token") + } + + // Test /api/v1/statistics/summary + req := httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var summary backend.StatisticsSummary + err := json.Unmarshal(rec.Body.Bytes(), &summary) + require.NoError(t, err) + require.NotNil(t, summary) +} + +// TestPartialMigrationThenSyncThenComplete tests migration workflow +// Note: This test requires the database to be in a specific state +// It tests: partial migration → data insert → sync → complete migration +func TestPartialMigrationThenSyncThenComplete(t *testing.T) { + // This test is complex and requires careful setup + // For now, we test the final state: all migrations + sync + + e := StartTestServer(t) + if e == nil { + t.Skip("Test database not configured") + } + + // Get token + token := getTestToken(t, e) + if token == "" { + t.Skip("Could not get test token") + } + + // Insert test data manually (5 soundtracks with songs) + insertTestData(t) + + // Run sync to ensure data is properly loaded + req := httptest.NewRequest(http.MethodGet, "/sync/new", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + // Wait for sync to complete + if !waitForSyncCompletion(t, e, 60) { + t.Error("Sync did not complete within timeout") + } + + // Verify data via statistics endpoint + req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var summary backend.StatisticsSummary + err := json.Unmarshal(rec.Body.Bytes(), &summary) + require.NoError(t, err) + + // After sync with /sync/new, only soundtracks matching filesystem remain + // testMusic has 3 games + require.Equal(t, int64(3), summary.TotalGames) +} + +// insertTestData inserts 5 test soundtracks with songs into the database +func insertTestData(t *testing.T) { + if db.TestDatabase == nil || db.TestDatabase.Pool == nil { + t.Skip("Test database not initialized") + return + } + + ctx := context.Background() + queries := repository.New(db.TestDatabase.Pool) + + // Insert 5 soundtracks + soundtracks := []struct { + name string + path string + }{ + {"Test Soundtrack 1", "/path/to/soundtrack1"}, + {"Test Soundtrack 2", "/path/to/soundtrack2"}, + {"Test Soundtrack 3", "/path/to/soundtrack3"}, + {"Test Soundtrack 4", "/path/to/soundtrack4"}, + {"Test Soundtrack 5", "/path/to/soundtrack5"}, + } + + for _, st := range soundtracks { + _, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{ + SoundtrackName: st.name, + Path: st.path, + Hash: "test-hash-" + st.name, + }) + require.NoError(t, err, "Failed to insert soundtrack: %s", st.name) + } + + // Get soundtrack IDs + soundtrackIDs, err := queries.FindAllSoundtracks(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(soundtrackIDs), 5) + + // Insert songs for each soundtrack + songData := []struct { + soundtrackID int32 + songs []string + }{ + {soundtrackIDs[0].ID, []string{"Song A", "Song B"}}, + {soundtrackIDs[1].ID, []string{"Song C", "Song D"}}, + {soundtrackIDs[2].ID, []string{"Song E"}}, + {soundtrackIDs[3].ID, []string{"Song F", "Song G", "Song H"}}, + {soundtrackIDs[4].ID, []string{"Song I"}}, + } + + for _, sd := range songData { + for _, songName := range sd.songs { + err := queries.AddSong(ctx, repository.AddSongParams{ + SoundtrackID: sd.soundtrackID, + SongName: songName, + Path: "/path/to/" + songName + ".mp3", + }) + require.NoError(t, err, "Failed to insert song: %s", songName) + } + } + + t.Logf("Inserted %d soundtracks with %d total songs", len(soundtracks), 8) +} + +// getTestToken gets a valid token for testing +func getTestToken(t *testing.T, e *echo.Echo) string { + reqBody := `{"client_type": "test"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/token", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Logf("Failed to get token: %s", rec.Body.String()) + return "" + } + + var resp struct { + Token string `json:"token"` + } + err := json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + return resp.Token +} diff --git a/internal/server/syncHandler.go b/internal/server/syncHandler.go index 382a675..4ee7ce4 100644 --- a/internal/server/syncHandler.go +++ b/internal/server/syncHandler.go @@ -34,61 +34,61 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error { return ctx.JSON(http.StatusOK, response) } -// SyncGamesNewOnlyChanges godoc -// @Summary Sync games with only changes +// SyncSoundtracksNewOnlyChanges godoc +// @Summary Sync soundtracks with only changes // @Description Starts syncing games with only new changes // @Tags sync // @Accept json // @Produce json -// @Success 200 {string} string "Start syncing games" +// @Success 200 {string} string "Start syncing soundtracks" // @Failure 423 {string} string "Syncing is in progress" // @Router /sync [get] -func (s *SyncHandler) SyncGamesNewOnlyChanges(ctx *echo.Context) error { +func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error { if backend.Syncing { logging.GetLogger().Warn("Syncing is already in progress") return ctx.JSON(http.StatusLocked, "Syncing is in progress") } logging.GetLogger().Info("Starting sync with only changes") backend.Syncing = true - go backend.SyncGamesNewOnlyChanges() - return ctx.JSON(http.StatusOK, "Start syncing games") + go backend.SyncSoundtracksNewOnlyChanges() + return ctx.JSON(http.StatusOK, "Start syncing soundtracks") } -// SyncGamesNewFull godoc +// SyncSoundtracksNewFull godoc // @Summary Sync all games fully // @Description Starts a full sync of all games // @Tags sync // @Accept json // @Produce json -// @Success 200 {string} string "Start syncing games full" +// @Success 200 {string} string "Start syncing soundtracks full" // @Failure 423 {string} string "Syncing is in progress" // @Router /sync/full [get] -func (s *SyncHandler) SyncGamesNewFull(ctx *echo.Context) error { +func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error { if backend.Syncing { logging.GetLogger().Warn("Syncing is already in progress") return ctx.JSON(http.StatusLocked, "Syncing is in progress") } logging.GetLogger().Info("Starting full sync") backend.Syncing = true - go backend.SyncGamesNewFull() - return ctx.JSON(http.StatusOK, "Start syncing games full") + go backend.SyncSoundtracksNewFull() + return ctx.JSON(http.StatusOK, "Start syncing soundtracks full") } -// ResetGames godoc -// @Summary Reset games database +// ResetDB godoc +// @Summary Reset soundtracks database // @Description Resets the games database by deleting all games and songs // @Tags sync // @Accept json // @Produce json -// @Success 200 {string} string "Games and songs are deleted from the database" +// @Success 200 {string} string "Soundtracks and songs are deleted from the database" // @Failure 423 {string} string "Syncing is in progress" // @Router /sync/reset [get] -func (s *SyncHandler) ResetGames(ctx *echo.Context) error { +func (s *SyncHandler) ResetDB(ctx *echo.Context) error { if backend.Syncing { logging.GetLogger().Warn("Cannot reset - syncing is in progress") return ctx.JSON(http.StatusLocked, "Syncing is in progress") } - logging.GetLogger().Info("Resetting games database") + logging.GetLogger().Info("Resetting soundtracks database") backend.ResetDB() - return ctx.JSON(http.StatusOK, "Games and songs are deleted from the database") + return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database") } diff --git a/internal/server/sync_handler_test.go b/internal/server/sync_handler_test.go index 15ecb6d..561182d 100644 --- a/internal/server/sync_handler_test.go +++ b/internal/server/sync_handler_test.go @@ -76,7 +76,7 @@ func TestSyncPopulatesDatabase(t *testing.T) { // Before sync - should have no games repo := repository.New(backend.BackendPool()) - gamesBefore, err := repo.FindAllGames(backend.BackendCtx()) + gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx()) assert.NoError(t, err) beforeCount := len(gamesBefore) t.Logf("Games before sync: %d", beforeCount) @@ -92,7 +92,7 @@ func TestSyncPopulatesDatabase(t *testing.T) { } // After sync - should have games - gamesAfter, err := repo.FindAllGames(backend.BackendCtx()) + gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx()) assert.NoError(t, err) afterCount := len(gamesAfter) t.Logf("Games after sync: %d", afterCount) @@ -113,7 +113,7 @@ func TestSyncMakesDifference(t *testing.T) { // Before sync - should have no games repo := repository.New(backend.BackendPool()) - gamesBefore, err := repo.FindAllGames(backend.BackendCtx()) + gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx()) assert.NoError(t, err) assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync") @@ -127,7 +127,7 @@ func TestSyncMakesDifference(t *testing.T) { } // After sync - should have games - gamesAfter, err := repo.FindAllGames(backend.BackendCtx()) + gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx()) assert.NoError(t, err) assert.True(t, len(gamesAfter) > 0, "Should have games after sync") } @@ -200,7 +200,7 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) { // Get initial count repo := repository.New(backend.BackendPool()) - gamesBefore, _ := repo.FindAllGames(backend.BackendCtx()) + gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx()) beforeCount := len(gamesBefore) // Run incremental sync (should not change count if nothing changed) @@ -211,7 +211,7 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) { time.Sleep(2 * time.Second) // Count should be the same - gamesAfter, _ := repo.FindAllGames(backend.BackendCtx()) + gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx()) afterCount := len(gamesAfter) // Note: This might not be exactly equal due to timing, but should be close @@ -228,7 +228,7 @@ func TestResetGames(t *testing.T) { // First ensure we have data repo := repository.New(backend.BackendPool()) - gamesBefore, _ := repo.FindAllGames(backend.BackendCtx()) + gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx()) beforeCount := len(gamesBefore) if beforeCount == 0 { @@ -238,7 +238,7 @@ func TestResetGames(t *testing.T) { t.Error("Sync did not complete within timeout") return } - gamesBefore, _ = repo.FindAllGames(backend.BackendCtx()) + gamesBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx()) beforeCount = len(gamesBefore) } @@ -253,7 +253,7 @@ func TestResetGames(t *testing.T) { // Note: reset might take a moment to propagate time.Sleep(1 * time.Second) - gamesAfter, _ := repo.FindAllGames(backend.BackendCtx()) + gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx()) afterCount := len(gamesAfter) t.Logf("Games after reset: %d", afterCount) @@ -282,7 +282,7 @@ func TestSyncGamesNewFull(t *testing.T) { // Verify database is populated repo := repository.New(backend.BackendPool()) - games, err := repo.FindAllGames(backend.BackendCtx()) + games, err := repo.FindAllSoundtracks(backend.BackendCtx()) assert.NoError(t, err) assert.True(t, len(games) > 0, "Database should be populated after full sync") t.Logf("Full sync populated %d games", len(games)) diff --git a/internal/server/test_helpers.go b/internal/server/test_helpers.go index e0e4f3e..153554f 100644 --- a/internal/server/test_helpers.go +++ b/internal/server/test_helpers.go @@ -50,20 +50,18 @@ func StartTestServer(t *testing.T) *echo.Echo { // Initialize database for tests db.TestSetupDB(t) - - // Initialize backend with the global Dbpool + + // Initialize backend with test database pool // This ensures BackendRepo() and BackendCtx() are available - if db.Dbpool != nil { - backend.InitBackend(db.Dbpool) + if db.TestDatabase != nil && db.TestDatabase.Pool != nil { + backend.InitBackend(db.TestDatabase.Pool) } // Create a Server instance and get its routes s := &Server{ - db: &db.Database{ - Pool: db.Dbpool, - Ctx: db.Ctx, - }, - tokenHandler: NewTokenHandler(db.Dbpool), + db: db.TestDatabase, + tokenHandler: NewTokenHandler(db.TestDatabase.Pool), + statisticsHandler: NewStatisticsHandler(), } handler := s.RegisterRoutes() diff --git a/internal/server/zz_music_handler_test.go b/internal/server/zz_music_handler_test.go index bd7e92d..1b4c9b6 100644 --- a/internal/server/zz_music_handler_test.go +++ b/internal/server/zz_music_handler_test.go @@ -16,7 +16,7 @@ import ( // ensureSyncRan ensures that sync has been run before testing music endpoints func ensureSyncRan(t *testing.T, e *echo.Echo) { repo := repository.New(backend.BackendPool()) - games, err := repo.FindAllGames(backend.BackendCtx()) + games, err := repo.FindAllSoundtracks(backend.BackendCtx()) assert.NoError(t, err) if len(games) == 0 { 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..."