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 {
}
}
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..."