diff --git a/.env.test b/.env.test
index 5efbdb7..e22b43f 100644
--- a/.env.test
+++ b/.env.test
@@ -13,3 +13,7 @@ CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters
PORT=8081
LOG_LEVEL=debug
LOG_JSON=false
+
+# Session Authentication (for testing)
+# Set to disable session auth during development
+DISABLE_SESSION_AUTH=true
diff --git a/Dockerfile b/Dockerfile
index d5c84e0..cc1dd39 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,7 +6,6 @@ COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
-COPY *.go ./
RUN find . -name "*_test.go" -delete && \
find . -name "integration_test.go" -delete
RUN go install github.com/a-h/templ/cmd/templ@latest
diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go
index 0e4fc35..7c752cb 100644
--- a/cmd/docs/docs.go
+++ b/cmd/docs/docs.go
@@ -23,6 +23,110 @@ var doc = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
+ "/api/v1/statistics/songs/least-played": {
+ "get": {
+ "description": "Returns the top N least played songs with their soundtrack 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 soundtrack 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/soundtracks/last-played": {
"get": {
"description": "Returns the most recently played soundtracks",
@@ -266,110 +370,6 @@ var doc = `{
}
}
},
- "/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",
@@ -404,7 +404,7 @@ var doc = `{
},
"/api/v1/token": {
"post": {
- "description": "Returns a new session token for API access",
+ "description": "Returns a new session token for API access. Requires valid API password.",
"consumes": [
"application/json"
],
@@ -417,7 +417,7 @@ var doc = `{
"summary": "Create session token",
"parameters": [
{
- "description": "Client type",
+ "description": "Password and client type",
"name": "request",
"in": "body",
"required": true,
@@ -442,6 +442,15 @@ var doc = `{
}
}
},
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
"500": {
"description": "Internal Server Error",
"schema": {
@@ -1369,9 +1378,38 @@ var doc = `{
}
},
"definitions": {
+ "backend.SongInfoForStats": {
+ "type": "object",
+ "properties": {
+ "file_name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "song_name": {
+ "type": "string"
+ },
+ "soundtrack_id": {
+ "type": "integer"
+ },
+ "soundtrack_name": {
+ "type": "string"
+ },
+ "times_played": {
+ "type": "integer"
+ }
+ }
+ },
"backend.SoundtrackWithSongs": {
"type": "object",
"properties": {
+ "songs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/backend.SongInfoForStats"
+ }
+ },
"soundtrack_id": {
"type": "integer"
},
@@ -1383,35 +1421,6 @@ var doc = `{
},
"soundtrack_played": {
"type": "integer"
- },
- "songs": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/backend.SongInfoForStats"
- }
- }
- }
- },
- "backend.SongInfoForStats": {
- "type": "object",
- "properties": {
- "file_name": {
- "type": "string"
- },
- "soundtrack_id": {
- "type": "integer"
- },
- "soundtrack_name": {
- "type": "string"
- },
- "path": {
- "type": "string"
- },
- "song_name": {
- "type": "string"
- },
- "times_played": {
- "type": "integer"
}
}
},
@@ -1466,6 +1475,10 @@ var doc = `{
"client_type": {
"description": "Optional: \"web\", \"mobile\", \"api\"",
"type": "string"
+ },
+ "password": {
+ "description": "Required: API password",
+ "type": "string"
}
}
},
diff --git a/cmd/docs/swagger.json b/cmd/docs/swagger.json
index 4abba18..2996b4d 100644
--- a/cmd/docs/swagger.json
+++ b/cmd/docs/swagger.json
@@ -4,6 +4,110 @@
"contact": {}
},
"paths": {
+ "/api/v1/statistics/songs/least-played": {
+ "get": {
+ "description": "Returns the top N least played songs with their soundtrack 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 soundtrack 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/soundtracks/last-played": {
"get": {
"description": "Returns the most recently played soundtracks",
@@ -247,110 +351,6 @@
}
}
},
- "/api/v1/statistics/songs/least-played": {
- "get": {
- "description": "Returns the top N least played songs with their soundtrack 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 soundtrack 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",
@@ -385,7 +385,7 @@
},
"/api/v1/token": {
"post": {
- "description": "Returns a new session token for API access",
+ "description": "Returns a new session token for API access. Requires valid API password.",
"consumes": [
"application/json"
],
@@ -398,7 +398,7 @@
"summary": "Create session token",
"parameters": [
{
- "description": "Client type",
+ "description": "Password and client type",
"name": "request",
"in": "body",
"required": true,
@@ -423,6 +423,15 @@
}
}
},
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
"500": {
"description": "Internal Server Error",
"schema": {
@@ -1350,9 +1359,38 @@
}
},
"definitions": {
+ "backend.SongInfoForStats": {
+ "type": "object",
+ "properties": {
+ "file_name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "song_name": {
+ "type": "string"
+ },
+ "soundtrack_id": {
+ "type": "integer"
+ },
+ "soundtrack_name": {
+ "type": "string"
+ },
+ "times_played": {
+ "type": "integer"
+ }
+ }
+ },
"backend.SoundtrackWithSongs": {
"type": "object",
"properties": {
+ "songs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/backend.SongInfoForStats"
+ }
+ },
"soundtrack_id": {
"type": "integer"
},
@@ -1364,35 +1402,6 @@
},
"soundtrack_played": {
"type": "integer"
- },
- "songs": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/backend.SongInfoForStats"
- }
- }
- }
- },
- "backend.SongInfoForStats": {
- "type": "object",
- "properties": {
- "file_name": {
- "type": "string"
- },
- "soundtrack_id": {
- "type": "integer"
- },
- "soundtrack_name": {
- "type": "string"
- },
- "path": {
- "type": "string"
- },
- "song_name": {
- "type": "string"
- },
- "times_played": {
- "type": "integer"
}
}
},
@@ -1447,6 +1456,10 @@
"client_type": {
"description": "Optional: \"web\", \"mobile\", \"api\"",
"type": "string"
+ },
+ "password": {
+ "description": "Required: API password",
+ "type": "string"
}
}
},
diff --git a/cmd/docs/swagger.yaml b/cmd/docs/swagger.yaml
index 27a9895..8e2dd2e 100644
--- a/cmd/docs/swagger.yaml
+++ b/cmd/docs/swagger.yaml
@@ -1,49 +1,49 @@
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
+ soundtrack_id:
+ type: integer
+ soundtrack_name:
+ type: string
times_played:
type: integer
type: object
+ backend.SoundtrackWithSongs:
+ properties:
+ songs:
+ items:
+ $ref: '#/definitions/backend.SongInfoForStats'
+ type: array
+ soundtrack_id:
+ type: integer
+ soundtrack_last_played:
+ type: string
+ soundtrack_name:
+ type: string
+ soundtrack_played:
+ type: integer
+ type: object
backend.StatisticsSummary:
properties:
- avg_game_plays:
+ avg_soundtrack_plays:
type: number
- max_game_plays:
+ max_soundtrack_plays:
type: integer
- min_game_plays:
+ min_soundtrack_plays:
type: integer
- never_played_games:
+ never_played_soundtracks:
type: integer
- played_games:
+ played_soundtracks:
type: integer
- total_game_plays:
+ total_soundtrack_plays:
type: integer
- total_games:
+ total_soundtracks:
type: integer
type: object
backend.VersionData:
@@ -64,6 +64,9 @@ definitions:
client_type:
description: 'Optional: "web", "mobile", "api"'
type: string
+ password:
+ description: 'Required: API password'
+ type: string
type: object
server.TokenResponse:
properties:
@@ -77,171 +80,11 @@ 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
+ description: Returns the top N least played songs with their soundtrack info
parameters:
- description: 'Number of results (default: 10)'
in: query
@@ -275,7 +118,7 @@ paths:
get:
consumes:
- application/json
- description: Returns the top N most played songs with their game info
+ description: Returns the top N most played songs with their soundtrack info
parameters:
- description: 'Number of results (default: 10)'
in: query
@@ -305,6 +148,167 @@ paths:
summary: Get most played songs
tags:
- statistics
+ /api/v1/statistics/soundtracks/last-played:
+ get:
+ consumes:
+ - application/json
+ description: Returns the most recently played soundtracks
+ 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.SoundtrackWithSongs'
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ summary: Get last played soundtracks
+ tags:
+ - statistics
+ /api/v1/statistics/soundtracks/least-played:
+ get:
+ consumes:
+ - application/json
+ description: Returns the top N least played soundtracks 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.SoundtrackWithSongs'
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ summary: Get least played soundtracks
+ tags:
+ - statistics
+ /api/v1/statistics/soundtracks/most-played:
+ get:
+ consumes:
+ - application/json
+ description: Returns the top N most played soundtracks 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.SoundtrackWithSongs'
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ summary: Get most played soundtracks
+ tags:
+ - statistics
+ /api/v1/statistics/soundtracks/never-played:
+ get:
+ consumes:
+ - application/json
+ description: Returns all soundtracks that have never been played (times_played
+ = 0)
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/backend.SoundtrackWithSongs'
+ type: array
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ summary: Get never played soundtracks
+ tags:
+ - statistics
+ /api/v1/statistics/soundtracks/oldest-played:
+ get:
+ consumes:
+ - application/json
+ description: Returns the least recently played soundtracks (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.SoundtrackWithSongs'
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ summary: Get oldest played soundtracks
+ tags:
+ - statistics
/api/v1/statistics/summary:
get:
consumes:
@@ -364,9 +368,10 @@ paths:
post:
consumes:
- application/json
- description: Returns a new session token for API access
+ description: Returns a new session token for API access. Requires valid API
+ password.
parameters:
- - description: Client type
+ - description: Password and client type
in: body
name: request
required: true
@@ -385,6 +390,12 @@ paths:
additionalProperties:
type: string
type: object
+ "401":
+ description: Unauthorized
+ schema:
+ additionalProperties:
+ type: string
+ type: object
"500":
description: Internal Server Error
schema:
@@ -606,7 +617,7 @@ paths:
get:
consumes:
- application/json
- description: Returns a list of all games in order
+ description: Returns a list of all soundtracks in order
produces:
- application/json
responses:
@@ -628,7 +639,7 @@ paths:
get:
consumes:
- application/json
- description: Returns a list of all games in random order
+ description: Returns a list of all soundtracks in random order
produces:
- application/json
responses:
@@ -852,7 +863,7 @@ paths:
get:
consumes:
- application/json
- description: Starts syncing games with only new changes
+ description: Starts syncing soundtracks with only new changes
produces:
- application/json
responses:
@@ -871,7 +882,7 @@ paths:
get:
consumes:
- application/json
- description: Starts a full sync of all games
+ description: Starts a full sync of all soundtracks
produces:
- application/json
responses:
@@ -883,7 +894,7 @@ paths:
description: Syncing is in progress
schema:
type: string
- summary: Sync all games fully
+ summary: Sync all soundtracks fully
tags:
- sync
/sync/progress:
@@ -906,7 +917,8 @@ paths:
get:
consumes:
- application/json
- description: Resets the games database by deleting all games and songs
+ description: Resets the soundtracks database by deleting all soundtracks and
+ songs
produces:
- application/json
responses:
diff --git a/cmd/web/base.templ b/cmd/web/base.templ
index 615affb..bcc3229 100644
--- a/cmd/web/base.templ
+++ b/cmd/web/base.templ
@@ -2,7 +2,7 @@ package web
templ Base() {
-
+
Music Search
diff --git a/cmd/web/search.go b/cmd/web/search.go
index d91a068..b9ee2a7 100644
--- a/cmd/web/search.go
+++ b/cmd/web/search.go
@@ -4,40 +4,23 @@ import (
"log"
"music-server/internal/backend"
"net/http"
- "regexp"
- "sort"
"strings"
- "sync"
- "unicode"
)
var soundtracks_added []string
-// Precomputed data for optimization
-type SoundtrackData struct {
- Original string
- Cleaned string
- Abbreviation string
-}
-
-var (
- precomputedSoundtracks []SoundtrackData
- precomputedOnce sync.Once
- regexCache = make(map[string]*regexp.Regexp)
- regexCacheMutex sync.Mutex
-)
-
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
+ return
}
search_term := r.FormValue("search_term")
-
search(search_term)
+ results := soundtracks_added
- component := FoundSoundtracks(soundtracks_added)
+ component := FoundSoundtracks(results)
err = component.Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -47,357 +30,48 @@ func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
func search(searchText string) {
soundtracks_added = nil
+ soundtrackData := backend.GetSearchCache()
+ results := searchWithData(searchText, soundtrackData)
+ soundtracks_added = results
+}
- // Precompute cleaned search term once
- cleanedSearchTerm := clean_term(searchText)
+// searchWithData is a testable version of search that takes soundtrack data as a parameter
+func searchWithData(searchText string, soundtrackData []backend.SoundtrackData) []string {
+ var results []string
+ seen := make(map[string]bool)
- // Use precomputed data for efficiency
- soundtrackData := getPrecomputedSoundtracks()
- seen := make(map[string]bool) // O(1) duplicate checking
+ // Precompute cleaned and lowercase search terms once
+ cleanedSearchTerm := strings.ToLower(searchText)
+ cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, " ", "")
+ cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "é", "e")
+ cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "+", "plus")
+ cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "&", "and")
+ cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "'n", "and")
+ lowercaseSearchText := strings.ToLower(searchText)
+ // Pass 1: Exact match (case-insensitive contains)
for _, data := range soundtrackData {
- // Check exact match (case-insensitive contains)
- if is_match_exact(searchText, data.Original) && !seen[data.Original] {
- soundtracks_added = append(soundtracks_added, data.Original)
+ if !seen[data.Original] && (searchText == "" || strings.Contains(data.Lowercase, lowercaseSearchText)) {
+ results = append(results, data.Original)
seen[data.Original] = true
}
}
+ // Pass 2: Contains match with cleaned terms
for _, data := range soundtrackData {
- // Check contains match with cleaned terms
- if !seen[data.Original] && is_match_contains(cleanedSearchTerm, data.Cleaned) {
- soundtracks_added = append(soundtracks_added, data.Original)
+ if !seen[data.Original] && (cleanedSearchTerm == "" || strings.Contains(data.Cleaned, cleanedSearchTerm)) {
+ results = append(results, data.Original)
seen[data.Original] = true
}
}
+ // Pass 3: Regex match with cleaned terms
for _, data := range soundtrackData {
- // Check regex match with cleaned terms
- if !seen[data.Original] && is_match_regex_cached(cleanedSearchTerm, data.Cleaned) {
- soundtracks_added = append(soundtracks_added, data.Original)
+ if !seen[data.Original] && backend.MatchRegexCached(cleanedSearchTerm, data.Cleaned) {
+ results = append(results, data.Original)
seen[data.Original] = true
}
}
-}
-func is_match_exact(search_term string, soundtrack_name string) bool {
- search_term = strings.ToLower(search_term)
- soundtrack_name = strings.ToLower(soundtrack_name)
-
- if search_term == "" {
- return true
- } else if strings.Contains(soundtrack_name, search_term) {
- return true
- } else {
- return false
- }
-}
-
-func is_match_contains(search_term string, soundtrack_name string) bool {
- if search_term == "" {
- return true
- } else if strings.Contains(soundtrack_name, search_term) {
- return true
- } else {
- return false
- }
-}
-
-func is_match_regex(search_term string, soundtrack_name string) bool {
- if search_term == "" {
- return true
- } else if compile_regex(search_term).MatchString(soundtrack_name) {
- return true
- } else {
- return false
- }
-}
-
-func compile_regex(search_term string) *regexp.Regexp {
- regText := ".*"
- for _, letter := range search_term {
- regText += string(letter) + ".*"
- }
- r, _ := regexp.Compile(regText)
-
- return r
-}
-
-// is_match_regex_cached uses cached compiled regex patterns for better performance
-func is_match_regex_cached(search_term string, soundtrack_name string) bool {
- if search_term == "" {
- return true
- }
-
- // Check cache first
- regexCacheMutex.Lock()
- re, exists := regexCache[search_term]
- if !exists {
- re = compile_regex(search_term)
- regexCache[search_term] = re
- }
- regexCacheMutex.Unlock()
-
- return re.MatchString(soundtrack_name)
-}
-
-func clean_term(term string) string {
- term = strings.ReplaceAll(term, " ", "")
- term = strings.ReplaceAll(term, "é", "e")
- term = strings.ReplaceAll(term, "+", "plus")
- term = strings.ReplaceAll(term, "&", "and")
- term = strings.ReplaceAll(term, "'n", "and")
- return strings.ToLower(term)
-}
-
-// toLower converts a string to lowercase.
-func toLower(s string) string {
- return strings.Map(unicode.ToLower, s)
-}
-
-// levenshteinWithThreshold calculates Levenshtein distance with early termination and space optimization.
-// Uses O(min(n,m)) space instead of O(n*m) and stops early if threshold is exceeded.
-func levenshteinWithThreshold(s, t string, threshold int) int {
- if threshold < 0 {
- threshold = 2 // default threshold
- }
-
- m, n := len(s), len(t)
-
- // Quick checks for early termination
- if m == 0 {
- return n
- }
- if n == 0 {
- return m
- }
- if abs(m-n) > threshold {
- return threshold + 1 // Can't be within threshold
- }
-
- // Use the shorter string for the row to minimize space
- if m < n {
- s, t = t, s
- m, n = n, m
- }
-
- // Space optimization: only store two rows
- prevRow := make([]int, n+1)
- currRow := make([]int, n+1)
-
- // Initialize first row
- for j := 0; j <= n; j++ {
- prevRow[j] = j
- }
-
- for i := 1; i <= m; i++ {
- currRow[0] = i
- minInRow := currRow[0]
-
- for j := 1; j <= n; j++ {
- if s[i-1] == t[j-1] {
- currRow[j] = prevRow[j-1]
- } else {
- currRow[j] = min3(prevRow[j], currRow[j-1], prevRow[j-1]) + 1
- }
- if currRow[j] < minInRow {
- minInRow = currRow[j]
- }
- }
-
- // Early termination: if minimum in current row exceeds threshold
- if minInRow > threshold {
- return threshold + 1
- }
-
- // Swap rows for next iteration
- prevRow, currRow = currRow, prevRow
- }
-
- return prevRow[n]
-}
-
-// Helper functions for optimized Levenshtein
-func abs(x int) int {
- if x < 0 {
- return -x
- }
- return x
-}
-
-func min3(a, b, c int) int {
- if a < b {
- if a < c {
- return a
- }
- return c
- }
- if b < c {
- return b
- }
- return c
-}
-
-// precomputeSoundtrackData initializes cleaned terms and abbreviations for all soundtracks
-func precomputeSoundtrackData() {
- precomputedOnce.Do(func() {
- soundtracks := backend.GetAllSoundtracks()
- precomputedSoundtracks = make([]SoundtrackData, len(soundtracks))
-
- for i, soundtrack := range soundtracks {
- cleaned := clean_term(soundtrack)
- precomputedSoundtracks[i] = SoundtrackData{
- Original: soundtrack,
- Cleaned: cleaned,
- Abbreviation: extractAbbreviation(soundtrack),
- }
- }
- })
-}
-
-// getPrecomputedSoundtracks ensures precomputed data is available and returns it
-func getPrecomputedSoundtracks() []SoundtrackData {
- precomputeSoundtrackData()
- return precomputedSoundtracks
-}
-
-// levenshtein maintains backward compatibility by calling the optimized version
-func levenshtein(s, t string) int {
- return levenshteinWithThreshold(s, t, -1) // -1 uses default threshold
-}
-
-// extractAbbreviation extracts the first letters of each word in a string.
-func extractAbbreviation(s string) string {
- words := strings.Fields(s)
- abbr := ""
- for _, word := range words {
- if len(word) > 0 {
- abbr += string(word[0])
- }
- }
- return toLower(abbr)
-}
-
-// fuzzyFindSubstring checks if the query fuzzy-matches any substring of the item.
-func fuzzyFindSubstring(query, item string, threshold int) (bool, int) {
- query = toLower(query)
- item = toLower(item)
- queryLen := len(query)
- itemLen := len(item)
- if queryLen > itemLen {
- return false, 0
- }
-
- // Check for exact substring match first (weight: 100)
- if strings.Contains(item, query) {
- return true, 100
- }
-
- // Check for fuzzy substring match using optimized Levenshtein
- for i := 0; i <= itemLen-queryLen; i++ {
- substring := item[i : i+queryLen]
- distance := levenshteinWithThreshold(query, substring, threshold)
- if distance <= threshold {
- // Weight: higher for matches at the start of the string
- weight := 50 - i // Higher weight for earlier matches
- return true, weight
- }
- }
- return false, 0
-}
-
-// fuzzyFind checks if the query matches the item (substring or abbreviation).
-func fuzzyFind(query string, item string, threshold int) (bool, int) {
- query = toLower(query)
- item = toLower(item)
-
- // Check for substring fuzzy match
- if matched, weight := fuzzyFindSubstring(query, item, threshold); matched {
- return true, weight
- }
-
- // Check for abbreviation match using optimized Levenshtein
- abbr := extractAbbreviation(item)
- distance := levenshteinWithThreshold(query, abbr, threshold)
- if distance <= threshold {
- // Weight: higher for exact abbreviation matches
- weight := 100 - distance*10 // Higher weight for exact matches
- return true, weight
- }
-
- return false, 0
-}
-
-// getAdaptiveThreshold returns a threshold based on query length
-func getAdaptiveThreshold(query string) int {
- threshold := 2 // Base threshold for minor typos
- queryLen := len(query)
- if queryLen > 6 {
- // For longer queries, allow more tolerance
- threshold = queryLen / 3
- if threshold < 2 {
- threshold = 2
- } else if threshold > 4 {
- threshold = 4 // Cap at 4 for very long queries
- }
- }
- return threshold
-}
-
-// fuzzySearch performs fuzzy search on soundtracks using the cleaned terms for consistency
-func fuzzySearch(searchText string) []string {
- query := clean_term(searchText)
- threshold := getAdaptiveThreshold(query)
-
- // Use precomputed data for efficiency
- soundtrackData := getPrecomputedSoundtracks()
-
- type match struct {
- item string
- weight int
- }
- var matches []match
- seen := make(map[string]bool) // O(1) duplicate checking
-
- for _, data := range soundtrackData {
- if matched, weight := fuzzyFind(query, data.Cleaned, threshold); matched {
- if !seen[data.Original] {
- matches = append(matches, match{data.Original, weight})
- seen[data.Original] = true
- }
- }
- }
-
- // Sort matches by weight (descending)
- sort.Slice(matches, func(i, j int) bool {
- return matches[i].weight > matches[j].weight
- })
-
- // Extract just the soundtrack names in order
- result := make([]string, len(matches))
- for i, m := range matches {
- result[i] = m.item
- }
-
- return result
-}
-
-// FindSoundtrackFuzzyWebHandler handles fuzzy search requests
-func FindSoundtrackFuzzyWebHandler(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err != nil {
- http.Error(w, "Bad Request", http.StatusBadRequest)
- return
- }
-
- search_term := r.FormValue("search_term")
-
- results := fuzzySearch(search_term)
-
- component := FoundSoundtracks(results)
- err = component.Render(r.Context(), w)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- log.Fatalf("Error rendering in FindSoundtrackFuzzyWebHandler: %e", err)
- }
+ return results
}
diff --git a/cmd/web/search.templ b/cmd/web/search.templ
index 11aab9e..9960039 100644
--- a/cmd/web/search.templ
+++ b/cmd/web/search.templ
@@ -3,49 +3,20 @@ package web
templ SearchForm() {
@Base() {
-
+
+
diff --git a/cmd/web/search_test.go b/cmd/web/search_test.go
index 006bce7..fe98b0b 100644
--- a/cmd/web/search_test.go
+++ b/cmd/web/search_test.go
@@ -1,49 +1,18 @@
package web
import (
+ "music-server/internal/backend"
+ "strings"
"testing"
)
-// TestExtractAbbreviation tests that abbreviations are correctly extracted from soundtrack names
-func TestExtractAbbreviation(t *testing.T) {
- tests := []struct {
- input string
- expected string
- }{
- // extractAbbreviation takes first letter of each word (split by spaces)
- {"Metal Gear Solid 4", "mgs4"},
- {"Resident Evil 2", "re2"},
- // "Final Fantasy VII" -> "Final", "Fantasy", "VII" -> "f" + "f" + "v" = "ffv"
- {"Final Fantasy VII", "ffv"},
- // "Ys III - Wanderers from Ys (+The Oath in Felghana)" ->
- // "Ys", "III", "-", "Wanderers", "from", "Ys", "(+The", "Oath", "in", "Felghana)"
- // -> "y" + "i" + "-" + "w" + "f" + "y" + "(" + "o" + "i" + "f" = "yi-wfy(oif"
- {"Ys III - Wanderers from Ys (+The Oath in Felghana)", "yi-wfy(oif"},
- // "Call of Duty: Modern Warfare" -> "Call", "of", "Duty:", "Modern", "Warfare" -> "c" + "o" + "d" + "m" + "w" = "codmw"
- {"Call of Duty: Modern Warfare", "codmw"},
- // "Half-Life 3" -> "Half-Life", "3" -> "h" + "3" = "h3"
- {"Half-Life 3", "h3"},
- {"Metal Gear Solid 5", "mgs5"},
- {"A", "a"},
- {"", ""},
- {"Single", "s"},
- }
-
- for _, tt := range tests {
- result := extractAbbreviation(tt.input)
- if result != tt.expected {
- t.Errorf("extractAbbreviation(%q) = %q, want %q", tt.input, result, tt.expected)
- }
- }
-}
-
// TestCleanTerm tests that search terms are cleaned correctly
func TestCleanTerm(t *testing.T) {
tests := []struct {
input string
expected string
}{
- // clean_term removes spaces, replaces specific chars, and lowercases
+ // CleanSearchTermForMatch removes spaces, replaces specific chars, and lowercases
{"MGS4", "mgs4"},
{"RE2", "re2"},
{"FFVII", "ffvii"},
@@ -52,7 +21,7 @@ func TestCleanTerm(t *testing.T) {
{"Test 123", "test123"},
{"A+B", "aplusb"},
{"é", "e"},
- // Note: clean_term does NOT remove apostrophes, it only replaces "'n" with "and"
+ // Note: CleanSearchTermForMatch does NOT remove apostrophes, it only replaces "'n" with "and"
{"It's", "it's"},
// But it does replace "'n" with "and"
{"I'n", "iand"},
@@ -60,75 +29,165 @@ func TestCleanTerm(t *testing.T) {
}
for _, tt := range tests {
- result := clean_term(tt.input)
+ result := backend.CleanSearchTermForMatch(tt.input)
if result != tt.expected {
- t.Errorf("clean_term(%q) = %q, want %q", tt.input, result, tt.expected)
+ t.Errorf("CleanSearchTermForMatch(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}
-// TestFuzzyFindAbbreviationMatches tests that abbreviations match the correct soundtracks
-func TestFuzzyFindAbbreviationMatches(t *testing.T) {
- tests := []struct {
- query string
- item string
- expected bool
- }{
- // Positive cases - should match
- {"MGS4", "Metal Gear Solid 4", true},
- {"RE2", "Resident Evil 2", true},
- {"FFVII", "Final Fantasy VII", true},
- {"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)", true},
- {"MGS", "Metal Gear Solid 4", true},
- {"RE", "Resident Evil 2", true},
- {"FF", "Final Fantasy VII", true},
-
- // Note: With fuzzy matching (threshold=2), similar items may also match.
- // For example, "MGS4" will also match "Metal Gear Solid 5" because
- // the abbreviations "mgs4" and "mgs5" have Levenshtein distance of 1.
- // This is intentional for fuzzy matching to allow minor typos.
- // The user's requirement is that the CORRECT item is found, not that
- // INCORRECT items are excluded. Results are sorted by weight, so the
- // exact match should appear first.
- }
-
- threshold := 2 // Default threshold
-
- for _, tt := range tests {
- matched, _ := fuzzyFind(tt.query, tt.item, threshold)
- if matched != tt.expected {
- t.Errorf("fuzzyFind(%q, %q, %d) = %v, want %v", tt.query, tt.item, threshold, matched, tt.expected)
+// searchWithDataHelper is a helper to create backend.SoundtrackData from original names
+func searchWithDataHelper(originals []string) []backend.SoundtrackData {
+ data := make([]backend.SoundtrackData, len(originals))
+ for i, orig := range originals {
+ data[i] = backend.SoundtrackData{
+ Original: orig,
+ Lowercase: strings.ToLower(orig),
+ Cleaned: backend.CleanSearchTermForMatch(orig),
}
}
+ return data
}
-// TestFuzzyFindWithAllTestData tests all the user's requirements comprehensively
-// The user's requirement is that each query SHOULD find the specified item.
-// Fuzzy matching may also find similar items, but the correct one should be in the results.
-func TestFuzzyFindWithAllTestData(t *testing.T) {
+// TestNormalSearch tests the normal search function
+func TestNormalSearch(t *testing.T) {
+ // Set up comprehensive test data with all user-provided soundtracks
+ testData := searchWithDataHelper([]string{
+ "A Bird Story",
+ "Actraiser",
+ "Actraiser 2",
+ "Bangai-O Spirits",
+ "Ciconia When they Cry",
+ "Civilization IV",
+ "Civilization VI",
+ "Clair Obscur - Expedition 33",
+ "Club House Games (42 All-Time Classics)",
+ "Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv)",
+ "Final Fantasy VII Crisis Core",
+ "Final Fantasy VII Rebirth",
+ "Final Fantasy VII Remake",
+ "Final Fantasy VII",
+ "Joe & Mac",
+ "Judgment",
+ "Kingdom Hearts",
+ "Kingdom Hearts Birth By Sleep",
+ "Kingdom Hearts II",
+ "Kingdom Hearts III",
+ "Lemmings (SNES + Mega Drive)",
+ "Lemmings 3D",
+ "Mega Man X4",
+ "Metal Gear Rising - Revengeance",
+ "Metal Gear Solid (+Twin Snakes)",
+ "Metal Gear Solid 2 - Sons of Liberty",
+ "Metal Gear Solid 3 - Snake Eater",
+ "Metal Gear Solid 4 - Guns of the Patriots",
+ "Metroid Prime",
+ "Octopath Traveler",
+ "Octopath Traveler 2",
+ "Ori and the Blind Forest",
+ "Ori and the Will of the Wisps",
+ "OutRun 2006 - Coast 2 Coast",
+ "Paper Mario - The Thousand Year Door",
+ "Pokémon HeartGold & SoulSilver",
+ "Resident Evil 2 (Remake)",
+ "Shenmue",
+ "Super Mario Galaxy",
+ "Trails in the Sky FC (Sora no Kiseki FC)",
+ "Trails in the Sky SC (Sora no Kiseki SC)",
+ "Turrican II - The Final Fight",
+ "Umineko no Naku Koro ni",
+ "Umineko no Naku Koro ni Chiru",
+ "VVVVVV",
+ "YS VIII - Lacrimosa of DANA",
+ "Yakuza 0",
+ "Ys (Chronicles)",
+ "Ys - Memories of Celceta",
+ "Ys - The Ark of Napishtim",
+ "Ys II (Chronicles)",
+ "Ys III - Wanderers from Ys (+The Oath in Felghana)",
+ "Ys IV - The Dawn of Ys",
+ "Ys Origin",
+ "Ys Seven",
+ "Ys X Nordics",
+ "Call of Duty: Modern Warfare",
+ "Half-Life 3",
+ })
+
+ // Test cases covering all match types
tests := []struct {
query string
- wantMatch string
+ wantFound string
}{
- {"MGS4", "Metal Gear Solid 4"},
- {"RE2", "Resident Evil 2"},
- {"FFVII", "Final Fantasy VII"},
+ // Exact substring matches
+ {"Metal", "Metal Gear Solid 4 - Guns of the Patriots"},
+ {"Resident", "Resident Evil 2 (Remake)"},
+ {"Final", "Final Fantasy VII"},
{"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
- }
- threshold := 2
+ // Contains matches (on cleaned terms)
+ {"gotp", "Metal Gear Solid 4 - Guns of the Patriots"},
+ {"mgs", "Metal Gear Solid 4 - Guns of the Patriots"},
+ {"kh", "Kingdom Hearts"},
+ {"mg", "Metal Gear Solid 4 - Guns of the Patriots"},
+
+ // Regex matches
+ {"mgs4go", "Metal Gear Solid 4 - Guns of the Patriots"},
+ {"re2r", "Resident Evil 2 (Remake)"},
+ {"ffv", "Final Fantasy VII"},
+ {"yiwfyt", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
+ {"codmw", "Call of Duty: Modern Warfare"},
+ {"mgs4gop", "Metal Gear Solid 4 - Guns of the Patriots"},
+ }
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
- // Verify the query matches the expected item
- matched, _ := fuzzyFind(tt.query, tt.wantMatch, threshold)
- if !matched {
- t.Errorf("Query %q did not match expected item %q", tt.query, tt.wantMatch)
+ results := searchWithData(tt.query, testData)
+
+ // Check that the expected item is in the results
+ found := false
+ for _, result := range results {
+ if result == tt.wantFound {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("searchWithData(%q, ...) did not find expected item %q, got %v", tt.query, tt.wantFound, results)
}
})
}
}
-// Note: We don't test fuzzySearch() directly because it depends on precomputed data
-// from the database. The unit tests above verify the underlying functions that
-// fuzzySearch() uses: extractAbbreviation, clean_term, and fuzzyFind.
+// TestSearchOrdering tests that Pass 1 matches come before Pass 2 matches
+func TestSearchOrdering(t *testing.T) {
+ // "MG" matches Pass 1 (exact substring on original)
+ // "Metal Gear" matches Pass 2 (contains on cleaned) but NOT Pass 1 (no "mg" substring in "metal gear")
+ testData := searchWithDataHelper([]string{
+ "Metal Gear",
+ "MG",
+ })
+
+ results := searchWithData("mg", testData)
+
+ // Find indices
+ indexPass1 := -1
+ indexPass2 := -1
+ for i, result := range results {
+ if result == "MG" {
+ indexPass1 = i
+ }
+ if result == "Metal Gear" {
+ indexPass2 = i
+ }
+ }
+
+ // Pass 1 match (MG) should come before Pass 2 match (Metal Gear)
+ if indexPass1 >= 0 && indexPass2 >= 0 && indexPass1 >= indexPass2 {
+ t.Errorf("Expected Pass 1 match 'MG' to come before Pass 2 match 'Metal Gear', but got order: %v", results)
+ }
+}
+
+// Note: The tests above verify:
+// 1. clean_term - the term cleaning logic
+// 2. search - the complete normal search with exact, contains, and regex matching
+// 3. ordering - exact matches come before contains matches
diff --git a/go.mod b/go.mod
index b892494..194461c 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/MShekow/directory-checksum v1.4.18
github.com/a-h/templ v0.3.1020
+ github.com/docker/go-connections v0.6.0 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.9.2
github.com/labstack/echo/v5 v5.1.1
@@ -18,6 +19,8 @@ require (
go.uber.org/zap v1.28.0
)
+require github.com/google/uuid v1.6.0
+
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
@@ -32,7 +35,6 @@ require (
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
- github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -50,7 +52,6 @@ require (
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
diff --git a/internal/backend/search_cache.go b/internal/backend/search_cache.go
new file mode 100644
index 0000000..565a9d4
--- /dev/null
+++ b/internal/backend/search_cache.go
@@ -0,0 +1,100 @@
+package backend
+
+import (
+ "regexp"
+ "strings"
+ "sync"
+)
+
+// SoundtrackData holds precomputed search data for a soundtrack
+type SoundtrackData struct {
+ Original string
+ Lowercase string
+ Cleaned string
+}
+
+var (
+ searchCache []SoundtrackData
+ searchCacheMutex sync.Mutex
+ searchRegexCache = make(map[string]*regexp.Regexp)
+ searchRegexCacheMutex sync.Mutex
+)
+
+// GetSearchCache returns the precomputed search cache, initializing it if needed
+func GetSearchCache() []SoundtrackData {
+ searchCacheMutex.Lock()
+ defer searchCacheMutex.Unlock()
+
+ if len(searchCache) == 0 {
+ loadSearchCache()
+ }
+ return searchCache
+}
+
+// ReloadSearchCache clears and reloads the search cache.
+// This should be called after a sync operation to ensure search results are up to date.
+func ReloadSearchCache() {
+ searchCacheMutex.Lock()
+ defer searchCacheMutex.Unlock()
+ searchCache = nil
+ loadSearchCache()
+}
+
+// loadSearchCache loads the soundtrack data into the cache
+func loadSearchCache() {
+ soundtracks := GetAllSoundtracks()
+ searchCache = make([]SoundtrackData, len(soundtracks))
+
+ for i, soundtrack := range soundtracks {
+ cleaned := cleanSearchTerm(soundtrack)
+ lowercase := strings.ToLower(soundtrack)
+ searchCache[i] = SoundtrackData{
+ Original: soundtrack,
+ Lowercase: lowercase,
+ Cleaned: cleaned,
+ }
+ }
+}
+
+// CleanSearchTermForMatch cleans a search term for better matching
+func CleanSearchTermForMatch(term string) string {
+ term = strings.ReplaceAll(term, " ", "")
+ term = strings.ReplaceAll(term, "é", "e")
+ term = strings.ReplaceAll(term, "+", "plus")
+ term = strings.ReplaceAll(term, "&", "and")
+ term = strings.ReplaceAll(term, "'n", "and")
+ return strings.ToLower(term)
+}
+
+// cleanSearchTerm cleans a search term for better matching (internal)
+func cleanSearchTerm(term string) string {
+ return CleanSearchTermForMatch(term)
+}
+
+// MatchRegexCached checks if a search term matches using cached compiled regex
+func MatchRegexCached(searchTerm string, soundtrackName string) bool {
+ if searchTerm == "" {
+ return true
+ }
+
+ // Check cache first
+ searchRegexCacheMutex.Lock()
+ re, exists := searchRegexCache[searchTerm]
+ if !exists {
+ re = compileSearchRegex(searchTerm)
+ searchRegexCache[searchTerm] = re
+ }
+ searchRegexCacheMutex.Unlock()
+
+ return re.MatchString(soundtrackName)
+}
+
+// compileSearchRegex compiles a search term into a regex pattern
+func compileSearchRegex(searchTerm string) *regexp.Regexp {
+ regText := ".*"
+ for _, letter := range searchTerm {
+ regText += string(letter) + ".*"
+ }
+ re, _ := regexp.Compile(regText)
+ return re
+}
diff --git a/internal/backend/sync.go b/internal/backend/sync.go
index 6dff1b6..61f47c6 100644
--- a/internal/backend/sync.go
+++ b/internal/backend/sync.go
@@ -16,6 +16,7 @@ import (
"sync"
"time"
+ "github.com/google/uuid"
"github.com/panjf2000/ants/v2"
"github.com/MShekow/directory-checksum/directory_checksum"
@@ -178,11 +179,13 @@ func SyncResult() SyncResponse {
func SyncSoundtracksFull() {
syncSoundtracks(true)
Reset()
+ ReloadSearchCache()
}
func SyncSoundtracksOnlyChanges() {
syncSoundtracks(false)
Reset()
+ ReloadSearchCache()
}
func syncSoundtracks(full bool) {
@@ -295,6 +298,7 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
var status SoundtrackStatus = NewSoundtrack
var oldSoundtrack repository.Soundtrack
var id int32 = -1
+ var soundtrackUuid uuid.UUID
//fmt.Printf("Soundtracks before: %d\n", len(soundtracksBeforeSync))
@@ -304,16 +308,19 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash == dirHash {
status = NotChanged
id = oldSoundtrack.ID
+ soundtrackUuid = oldSoundtrack.Uuid
//fmt.Printf("Soundtrack not changed\n")
break
} else if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash != dirHash {
status = SoundtrackChanged
id = oldSoundtrack.ID
+ soundtrackUuid = oldSoundtrack.Uuid
//fmt.Printf("Soundtrack changed\n")
break
} else if oldSoundtrack.SoundtrackName != file.Name() && oldSoundtrack.Hash == dirHash {
status = TitleChanged
id = oldSoundtrack.ID
+ soundtrackUuid = oldSoundtrack.Uuid
//fmt.Printf("SoundtrackName changed\n")
break
}
@@ -328,34 +335,37 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
}
switch status {
case NewSoundtrack:
- id = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
+ id, soundtrackUuid = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
logging.GetLogger().Debug("New soundtrack detected",
zap.Int32("id", id),
+ zap.String("uuid", soundtrackUuid.String()),
zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash),
zap.String("status", status.String()))
soundtracksAdded = append(soundtracksAdded, file.Name())
- checkSongs(entries, soundtrackDir, id)
+ checkSongs(entries, soundtrackDir, id, soundtrackUuid)
case SoundtrackChanged:
logging.GetLogger().Debug("Soundtrack changed",
zap.Int32("id", id),
+ zap.String("uuid", soundtrackUuid.String()),
zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash),
zap.String("status", status.String()))
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
handleError("UpdateSoundtrackHash", err, "")
soundtracksChangedContent = append(soundtracksChangedContent, file.Name())
- checkSongs(entries, soundtrackDir, id)
+ checkSongs(entries, soundtrackDir, id, soundtrackUuid)
case TitleChanged:
logging.GetLogger().Debug("Soundtrack title changed",
zap.Int32("id", id),
+ zap.String("uuid", soundtrackUuid.String()),
zap.String("oldName", oldSoundtrack.SoundtrackName),
zap.String("newName", file.Name()),
zap.String("hash", dirHash),
zap.String("status", status.String()))
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id})
handleError("UpdateSoundtrackName", err, "")
- checkSongs(entries, soundtrackDir, id)
+ checkSongs(entries, soundtrackDir, id, soundtrackUuid)
if soundtracksChangedTitle == nil {
soundtracksChangedTitle = make(map[string]string)
}
@@ -373,10 +383,11 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
}
}
if !found {
- checkSongs(entries, soundtrackDir, id)
+ checkSongs(entries, soundtrackDir, id, soundtrackUuid)
soundtracksReAdded = append(soundtracksReAdded, file.Name())
logging.GetLogger().Debug("Soundtrack added again",
zap.Int32("id", id),
+ zap.String("uuid", soundtrackUuid.String()),
zap.String("newName", file.Name()),
zap.String("hash", dirHash),
zap.String("status", status.String()))
@@ -390,6 +401,15 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
zap.String("status", status.String()))
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
handleError("RemoveSoundtrackDeletionDate", err, "")
+
+ // Remove .id files and update hash if any were removed
+ if removeIdFilesInSoundtrack(soundtrackDir) {
+ newHash := getHashForDir(soundtrackDir)
+ if newHash != dirHash {
+ err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: newHash, ID: id})
+ handleError("UpdateSoundtrackHash for .id cleanup", err, "")
+ }
+ }
}
foldersSynced++
logging.GetLogger().Debug("Sync progress",
@@ -398,24 +418,31 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100)))
}
-func insertSoundtrack(name string, path string, hash string) int32 {
+func insertSoundtrack(name string, path string, hash string) (int32, uuid.UUID) {
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
- id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash})
+ soundtrackUuid := uuid.New()
+ result, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{
+ SoundtrackName: name,
+ Path: path,
+ Hash: hash,
+ Uuid: soundtrackUuid,
+ })
handleError("InsertSoundtrack", err, "")
if err != nil {
- logging.GetLogger().Warn("ID collision detected, resetting sequence")
+ logging.GetLogger().Warn("ID collision or UUID duplicate detected, resetting sequence")
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
logging.GetLogger().Debug("Resetting soundtrack ID sequence")
_, err = repo.ResetSoundtrackIdSeq(BackendCtx())
handleError("ResetSoundtrackIdSeq", err, "")
- id = insertSoundtrack(name, path, hash)
+ id, uuid := insertSoundtrack(name, path, hash)
+ return id, uuid
}
}
- return id
+ return result.ID, result.Uuid
}
-func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 {
+func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) int32 {
//hasher := md5.New()
var numberOfSongs int32
numberOfFiles := len(entries)
@@ -425,7 +452,7 @@ func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 {
for _, entry := range entries {
poolSong.Submit(func() {
defer songWg.Done()
- if checkSong(entry, soundtrackDir, id) {
+ if checkSong(entry, soundtrackDir, id, soundtrackUuid) {
numberOfSongs++
}
})
@@ -434,7 +461,7 @@ func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 {
return numberOfSongs
}
-func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool {
+func checkSong(entry os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) bool {
fileInfo, err := entry.Info()
if err != nil {
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
@@ -487,8 +514,17 @@ func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool {
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{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))
+ songUuid := uuid.New()
+ err = repo.AddSong(BackendCtx(), repository.AddSongParams{
+ SoundtrackID: id,
+ SongName: songName,
+ Path: path,
+ FileName: &fileName,
+ Hash: songHash,
+ Uuid: songUuid,
+ SoundtrackUuid: soundtrackUuid,
+ })
+ handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | UUID: %s | Path: %s | SongName: %s | SongHash: %s", id, songUuid.String(), path, entry.Name(), songHash))
}
}
@@ -558,6 +594,33 @@ func isCoverImage(entry fs.FileInfo) bool {
(strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".png"))
}
+func removeIdFilesInSoundtrack(soundtrackDir string) bool {
+ entries, err := os.ReadDir(soundtrackDir)
+ if err != nil {
+ logging.GetLogger().Error("Failed to read soundtrack directory for .id file cleanup",
+ zap.String("path", soundtrackDir),
+ zap.String("error", err.Error()))
+ return false
+ }
+
+ anyRemoved := false
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".id") {
+ filePath := soundtrackDir + entry.Name()
+ err := os.Remove(filePath)
+ if err != nil {
+ logging.GetLogger().Error("Failed to remove .id file",
+ zap.String("path", filePath),
+ zap.String("error", err.Error()))
+ } else {
+ logging.GetLogger().Info("Removed .id file", zap.String("path", filePath))
+ anyRemoved = true
+ }
+ }
+ }
+ return anyRemoved
+}
+
func contains(s []string, searchTerm string) bool {
i := sort.SearchStrings(s, searchTerm)
return i < len(s) && s[i] == searchTerm
diff --git a/internal/backend/version.go b/internal/backend/version.go
index bc0d09c..650a1e4 100644
--- a/internal/backend/version.go
+++ b/internal/backend/version.go
@@ -7,8 +7,10 @@ type VersionData struct {
var data = []VersionData{
{
- Version: "5.0.0-Beta",
+ Version: "5.0.0",
Changelog: []string{
+ "#9 - Major routes refactor and expansion",
+ "#15 - Added API for statistics",
"#16 - Upgrade Echo framework from v4 to v5",
"#17 - Add Zap structured logging framework",
"#18 - Add OpenAPI/Swagger documentation",
@@ -17,6 +19,8 @@ var data = []VersionData{
"#21 - Refactor handlers into domain-specific files",
"#22 - Change VersionData Changelog from string to string array",
"#23 - Update all dependencies to latest versions",
+ "Added search cache backend",
+ "Backend sync improvements",
},
},
{
diff --git a/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql b/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql
deleted file mode 100644
index f0236de..0000000
--- a/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql
+++ /dev/null
@@ -1,33 +0,0 @@
--- 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
deleted file mode 100644
index 17c119b..0000000
--- a/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql
+++ /dev/null
@@ -1,32 +0,0 @@
--- 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
deleted file mode 100644
index 58628bf..0000000
--- a/internal/db/migrations/000006_add_id_to_song.down.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- 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
deleted file mode 100644
index ed1494d..0000000
--- a/internal/db/migrations/000006_add_id_to_song.up.sql
+++ /dev/null
@@ -1,36 +0,0 @@
--- 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/song.sql b/internal/db/queries/song.sql
index 95064ca..d7dc1b4 100644
--- a/internal/db/queries/song.sql
+++ b/internal/db/queries/song.sql
@@ -5,7 +5,7 @@ DELETE FROM song;
DELETE FROM song WHERE soundtrack_id = $1;
-- name: AddSong :exec
-INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
+INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7);
-- name: CheckSong :one
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2;
@@ -34,11 +34,19 @@ WHERE soundtrack_id = $1 AND song_name = $2;
-- name: FetchAllSongs :many
SELECT * FROM song;
--- name: GetSongById :one
-SELECT * FROM song WHERE id = $1;
+-- name: GetSongByUUID :one
+SELECT * FROM song WHERE uuid = $1;
-- name: RemoveBrokenSong :exec
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
-- name: RemoveBrokenSongs :exec
-DELETE FROM song WHERE id = ANY($1);
+DELETE FROM song WHERE uuid = ANY($1);
+
+-- name: GetSongsBySoundtrackUUID :many
+SELECT *
+FROM song
+WHERE soundtrack_uuid = $1;
+
+-- name: GetSongUUIDsBySoundtrackId :many
+SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
diff --git a/internal/db/queries/soundtrack.sql b/internal/db/queries/soundtrack.sql
index 1de82d5..863e4a3 100644
--- a/internal/db/queries/soundtrack.sql
+++ b/internal/db/queries/soundtrack.sql
@@ -29,7 +29,7 @@ UPDATE soundtrack SET deleted=NULL WHERE id=$1;
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;
+INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid;
-- name: InsertSoundtrackWithExistingId :exec
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
@@ -47,3 +47,12 @@ ORDER BY soundtrack_name;
-- name: AddSoundtrackPlayed :exec
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1;
+
+-- name: GetSoundtrackByUUID :one
+SELECT *
+FROM soundtrack
+WHERE uuid = $1
+AND deleted IS NULL;
+
+-- name: GetSoundtrackUUIDById :one
+SELECT uuid FROM soundtrack WHERE id = $1;
diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go
index 1a672cf..010d6c6 100644
--- a/internal/db/repository/models.go
+++ b/internal/db/repository/models.go
@@ -7,6 +7,7 @@ package repository
import (
"time"
+ "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -20,13 +21,14 @@ type Session struct {
}
type Song struct {
- 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"`
+ 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"`
+ Uuid uuid.UUID `json:"uuid"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
}
type SongList struct {
@@ -48,6 +50,7 @@ type Soundtrack struct {
LastPlayed *time.Time `json:"last_played"`
NumberOfSongs int32 `json:"number_of_songs"`
Hash string `json:"hash"`
+ Uuid uuid.UUID `json:"uuid"`
}
type Vgmq struct {
diff --git a/internal/db/repository/song.sql.go b/internal/db/repository/song.sql.go
index 048f630..58840f6 100644
--- a/internal/db/repository/song.sql.go
+++ b/internal/db/repository/song.sql.go
@@ -8,7 +8,7 @@ package repository
import (
"context"
- "github.com/jackc/pgx/v5/pgtype"
+ "github.com/google/uuid"
)
const addHashToSong = `-- name: AddHashToSong :exec
@@ -27,15 +27,17 @@ func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) er
}
const addSong = `-- name: AddSong :exec
-INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
+INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type AddSongParams struct {
- SoundtrackID int32 `json:"soundtrack_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"`
+ Uuid uuid.UUID `json:"uuid"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
}
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
@@ -45,6 +47,8 @@ func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
arg.Path,
arg.FileName,
arg.Hash,
+ arg.Uuid,
+ arg.SoundtrackUuid,
)
return err
}
@@ -110,7 +114,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int
}
const fetchAllSongs = `-- name: FetchAllSongs :many
-SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song
+SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song
`
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
@@ -129,7 +133,8 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
&i.TimesPlayed,
&i.Hash,
&i.FileName,
- &i.ID,
+ &i.Uuid,
+ &i.SoundtrackUuid,
); err != nil {
return nil, err
}
@@ -142,7 +147,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
}
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many
-SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id
+SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
FROM song
WHERE soundtrack_id = $1
`
@@ -163,7 +168,8 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
&i.TimesPlayed,
&i.Hash,
&i.FileName,
- &i.ID,
+ &i.Uuid,
+ &i.SoundtrackUuid,
); err != nil {
return nil, err
}
@@ -175,12 +181,12 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
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
+const getSongByUUID = `-- name: GetSongByUUID :one
+SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE uuid = $1
`
-func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) {
- row := q.db.QueryRow(ctx, getSongById, id)
+func (q *Queries) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) {
+ row := q.db.QueryRow(ctx, getSongByUUID, argUuid)
var i Song
err := row.Scan(
&i.SoundtrackID,
@@ -189,13 +195,43 @@ func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error)
&i.TimesPlayed,
&i.Hash,
&i.FileName,
- &i.ID,
+ &i.Uuid,
+ &i.SoundtrackUuid,
)
return i, err
}
+const getSongUUIDsBySoundtrackId = `-- name: GetSongUUIDsBySoundtrackId :many
+SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1
+`
+
+type GetSongUUIDsBySoundtrackIdRow struct {
+ Uuid uuid.UUID `json:"uuid"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
+}
+
+func (q *Queries) GetSongUUIDsBySoundtrackId(ctx context.Context, soundtrackID int32) ([]GetSongUUIDsBySoundtrackIdRow, error) {
+ rows, err := q.db.Query(ctx, getSongUUIDsBySoundtrackId, soundtrackID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetSongUUIDsBySoundtrackIdRow
+ for rows.Next() {
+ var i GetSongUUIDsBySoundtrackIdRow
+ if err := rows.Scan(&i.Uuid, &i.SoundtrackUuid); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getSongWithHash = `-- name: GetSongWithHash :one
-SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE hash = $1
+SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE hash = $1
`
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
@@ -208,11 +244,47 @@ func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error
&i.TimesPlayed,
&i.Hash,
&i.FileName,
- &i.ID,
+ &i.Uuid,
+ &i.SoundtrackUuid,
)
return i, err
}
+const getSongsBySoundtrackUUID = `-- name: GetSongsBySoundtrackUUID :many
+SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
+FROM song
+WHERE soundtrack_uuid = $1
+`
+
+func (q *Queries) GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error) {
+ rows, err := q.db.Query(ctx, getSongsBySoundtrackUUID, soundtrackUuid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Song
+ for rows.Next() {
+ var i Song
+ if err := rows.Scan(
+ &i.SoundtrackID,
+ &i.SongName,
+ &i.Path,
+ &i.TimesPlayed,
+ &i.Hash,
+ &i.FileName,
+ &i.Uuid,
+ &i.SoundtrackUuid,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const removeBrokenSong = `-- name: RemoveBrokenSong :exec
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2
`
@@ -228,11 +300,11 @@ func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongPara
}
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
-DELETE FROM song WHERE id = ANY($1)
+DELETE FROM song WHERE uuid = ANY($1)
`
-func (q *Queries) RemoveBrokenSongs(ctx context.Context, id pgtype.Int4) error {
- _, err := q.db.Exec(ctx, removeBrokenSongs, id)
+func (q *Queries) RemoveBrokenSongs(ctx context.Context, argUuid uuid.UUID) error {
+ _, err := q.db.Exec(ctx, removeBrokenSongs, argUuid)
return err
}
diff --git a/internal/db/repository/soundtrack.sql.go b/internal/db/repository/soundtrack.sql.go
index bc38704..97bc547 100644
--- a/internal/db/repository/soundtrack.sql.go
+++ b/internal/db/repository/soundtrack.sql.go
@@ -7,6 +7,8 @@ package repository
import (
"context"
+
+ "github.com/google/uuid"
)
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
@@ -28,7 +30,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
}
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
-SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
+SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
FROM soundtrack
WHERE deleted IS NULL
ORDER BY soundtrack_name
@@ -54,6 +56,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.LastPlayed,
&i.NumberOfSongs,
&i.Hash,
+ &i.Uuid,
); err != nil {
return nil, err
}
@@ -66,7 +69,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
}
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
-SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
+SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
FROM soundtrack
ORDER BY soundtrack_name
`
@@ -91,6 +94,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.LastPlayed,
&i.NumberOfSongs,
&i.Hash,
+ &i.Uuid,
); err != nil {
return nil, err
}
@@ -114,7 +118,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
}
const getSoundtrackById = `-- name: GetSoundtrackById :one
-SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
+SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
FROM soundtrack
WHERE id = $1
AND deleted IS NULL
@@ -134,6 +138,33 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.LastPlayed,
&i.NumberOfSongs,
&i.Hash,
+ &i.Uuid,
+ )
+ return i, err
+}
+
+const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
+SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
+FROM soundtrack
+WHERE uuid = $1
+AND deleted IS NULL
+`
+
+func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (Soundtrack, error) {
+ row := q.db.QueryRow(ctx, getSoundtrackByUUID, argUuid)
+ 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,
+ &i.Uuid,
)
return i, err
}
@@ -149,21 +180,43 @@ func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string,
return soundtrack_name, err
}
+const getSoundtrackUUIDById = `-- name: GetSoundtrackUUIDById :one
+SELECT uuid FROM soundtrack WHERE id = $1
+`
+
+func (q *Queries) GetSoundtrackUUIDById(ctx context.Context, id int32) (uuid.UUID, error) {
+ row := q.db.QueryRow(ctx, getSoundtrackUUIDById, id)
+ var uuid uuid.UUID
+ err := row.Scan(&uuid)
+ return uuid, err
+}
+
const insertSoundtrack = `-- name: InsertSoundtrack :one
-INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
+INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid
`
type InsertSoundtrackParams struct {
- SoundtrackName string `json:"soundtrack_name"`
- Path string `json:"path"`
- Hash string `json:"hash"`
+ SoundtrackName string `json:"soundtrack_name"`
+ Path string `json:"path"`
+ Hash string `json:"hash"`
+ Uuid uuid.UUID `json:"uuid"`
}
-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
+type InsertSoundtrackRow struct {
+ ID int32 `json:"id"`
+ Uuid uuid.UUID `json:"uuid"`
+}
+
+func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) {
+ row := q.db.QueryRow(ctx, insertSoundtrack,
+ arg.SoundtrackName,
+ arg.Path,
+ arg.Hash,
+ arg.Uuid,
+ )
+ var i InsertSoundtrackRow
+ err := row.Scan(&i.ID, &i.Uuid)
+ return i, err
}
const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec
diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go
index 603dd01..851062e 100644
--- a/internal/db/repository/statistics.sql.go
+++ b/internal/db/repository/statistics.sql.go
@@ -66,63 +66,6 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
return items, nil
}
-const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :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 GetLeastPlayedSoundtracksWithSongsRow 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) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) {
- rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var items []GetLeastPlayedSoundtracksWithSongsRow
- for rows.Next() {
- var i GetLeastPlayedSoundtracksWithSongsRow
- 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 getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
@@ -175,7 +118,7 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i
return items, nil
}
-const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
+const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -193,11 +136,11 @@ 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
+ORDER BY g.times_played ASC, g.soundtrack_name
LIMIT $1
`
-type GetMostPlayedSoundtracksWithSongsRow struct {
+type GetLeastPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -205,16 +148,16 @@ type GetMostPlayedSoundtracksWithSongsRow struct {
Songs []byte `json:"songs"`
}
-// Most played soundtracks with their songs
-func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) {
- rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit)
+// Least played soundtracks with their songs
+func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) {
+ rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit)
if err != nil {
return nil, err
}
defer rows.Close()
- var items []GetMostPlayedSoundtracksWithSongsRow
+ var items []GetLeastPlayedSoundtracksWithSongsRow
for rows.Next() {
- var i GetMostPlayedSoundtracksWithSongsRow
+ var i GetLeastPlayedSoundtracksWithSongsRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
@@ -284,6 +227,63 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in
return items, nil
}
+const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :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 GetMostPlayedSoundtracksWithSongsRow 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) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) {
+ rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetMostPlayedSoundtracksWithSongsRow
+ for rows.Next() {
+ var i GetMostPlayedSoundtracksWithSongsRow
+ 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 getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
diff --git a/internal/server/middleware/token_auth.go b/internal/server/middleware/token_auth.go
index 70f24cc..93656f1 100644
--- a/internal/server/middleware/token_auth.go
+++ b/internal/server/middleware/token_auth.go
@@ -2,6 +2,7 @@ package middleware
import (
"net/http"
+ "os"
"strings"
"time"
@@ -17,6 +18,15 @@ import (
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
+ // Session auth disable bypass - allow all requests through without token validation
+ disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
+ if disableSessionAuth {
+ logging.GetLogger().Debug("Session auth disabled - bypassing token authentication",
+ zap.String("path", c.Request().URL.Path),
+ zap.String("ip", c.RealIP()))
+ return next(c)
+ }
+
// Extract token from Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
diff --git a/internal/server/routes.go b/internal/server/routes.go
index ffa06f1..6e6b495 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -7,6 +7,7 @@ import (
"net/http"
"sort"
"strings"
+ "time"
"github.com/a-h/templ"
"github.com/labstack/echo/v5"
@@ -52,7 +53,6 @@ func (s *Server) RegisterRoutes() http.Handler {
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
- e.POST("/findfuzzy", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackFuzzyWebHandler)))
e.Static("/", "/frontend")
@@ -117,53 +117,162 @@ func (s *Server) RegisterRoutes() http.Handler {
// Create /api/v1 group
apiV1 := e.Group("/api/v1")
- // Public endpoints - no token required
- apiV1.POST("/token", func(c *echo.Context) error {
+ // Create rate limiter for token creation: 10 requests per minute per IP
+ tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute)
+ tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter)
+
+ // Token endpoints (public - no token required)
+ tokenGroup := apiV1.Group("/token")
+ tokenGroup.POST("", func(c *echo.Context) error {
return s.tokenHandler.CreateTokenHandler(c)
- })
- apiV1.DELETE("/token", func(c *echo.Context) error {
+ }, tokenRateLimitMiddleware)
+ tokenGroup.DELETE("", func(c *echo.Context) error {
return s.tokenHandler.DeleteTokenHandler(c)
})
- apiV1.POST("/token/cleanup", func(c *echo.Context) error {
+ tokenGroup.POST("/cleanup", func(c *echo.Context) error {
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
})
// Protected endpoints - require valid token
// Create token auth middleware with pool access
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
-
- // Protected group with token authentication
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
- // Statistics API endpoints (protected by token auth)
- statistics := s.statisticsHandler
- protectedV1.GET("/statistics/soundtracks/most-played", func(c *echo.Context) error {
- return statistics.GetMostPlayedSoundtracks(c)
+ // Statistics endpoints (protected)
+ statisticsHandler := s.statisticsHandler
+ statsGroup := protectedV1.Group("/statistics")
+ statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
+ return statisticsHandler.GetMostPlayedSoundtracks(c)
})
- protectedV1.GET("/statistics/soundtracks/least-played", func(c *echo.Context) error {
- return statistics.GetLeastPlayedSoundtracks(c)
+ statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
+ return statisticsHandler.GetLeastPlayedSoundtracks(c)
})
- protectedV1.GET("/statistics/soundtracks/never-played", func(c *echo.Context) error {
- return statistics.GetNeverPlayedSoundtracks(c)
+ statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
+ return statisticsHandler.GetNeverPlayedSoundtracks(c)
})
- protectedV1.GET("/statistics/soundtracks/last-played", func(c *echo.Context) error {
- return statistics.GetLastPlayedSoundtracks(c)
+ statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
+ return statisticsHandler.GetLastPlayedSoundtracks(c)
})
- protectedV1.GET("/statistics/soundtracks/oldest-played", func(c *echo.Context) error {
- return statistics.GetOldestPlayedSoundtracks(c)
+ statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
+ return statisticsHandler.GetOldestPlayedSoundtracks(c)
})
- protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error {
- return statistics.GetMostPlayedSongs(c)
+ statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
+ return statisticsHandler.GetMostPlayedSongs(c)
})
- protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error {
- return statistics.GetLeastPlayedSongs(c)
+ statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
+ return statisticsHandler.GetLeastPlayedSongs(c)
})
- protectedV1.GET("/statistics/summary", func(c *echo.Context) error {
- return statistics.GetStatisticsSummary(c)
+ statsGroup.GET("/summary", func(c *echo.Context) error {
+ return statisticsHandler.GetStatisticsSummary(c)
})
- // Future: VGMQ endpoints will be added to protectedV1 group
- _ = protectedV1 // Use the variable to avoid unused variable error
+ // Version endpoints (protected)
+ versionGroup := protectedV1.Group("/version")
+ versionGroup.GET("", func(c *echo.Context) error {
+ return version.GetLatestVersion(c)
+ })
+ versionGroup.GET("/history", func(c *echo.Context) error {
+ return version.GetVersionHistory(c)
+ })
+
+ // Character endpoints (protected)
+ characterGroup := protectedV1.Group("/character")
+ characterGroup.GET("", func(c *echo.Context) error {
+ return character.GetCharacter(c)
+ })
+ characterGroup.GET("/list", func(c *echo.Context) error {
+ return character.GetCharacterList(c)
+ })
+
+ // Download endpoints (protected)
+ downloadGroup := protectedV1.Group("/download")
+ downloadGroup.GET("", func(c *echo.Context) error {
+ return download.checkLatest(c)
+ })
+ downloadGroup.GET("/list", func(c *echo.Context) error {
+ return download.listAssetsOfLatest(c)
+ })
+ downloadGroup.GET("/windows", func(c *echo.Context) error {
+ return download.downloadLatestWindows(c)
+ })
+ downloadGroup.GET("/linux", func(c *echo.Context) error {
+ return download.downloadLatestLinux(c)
+ })
+
+ // Sync endpoints (protected)
+ protectedSyncGroup := protectedV1.Group("/sync")
+ protectedSyncGroup.GET("", func(c *echo.Context) error {
+ return sync.SyncSoundtracksNewOnlyChanges(c)
+ })
+ protectedSyncGroup.GET("/progress", func(c *echo.Context) error {
+ return sync.SyncProgress(c)
+ })
+ protectedSyncGroup.GET("/new", func(c *echo.Context) error {
+ return sync.SyncSoundtracksNewOnlyChanges(c)
+ })
+ protectedSyncGroup.GET("/full", func(c *echo.Context) error {
+ return sync.SyncSoundtracksNewFull(c)
+ })
+ protectedSyncGroup.GET("/new/full", func(c *echo.Context) error {
+ return sync.SyncSoundtracksNewFull(c)
+ })
+ protectedSyncGroup.GET("/quick", func(c *echo.Context) error {
+ return sync.SyncSoundtracksNewOnlyChanges(c)
+ })
+ protectedSyncGroup.GET("/reset", func(c *echo.Context) error {
+ return sync.ResetDB(c)
+ })
+
+ // Music endpoints (protected)
+ protectedMusicGroup := protectedV1.Group("/music")
+ protectedMusicGroup.GET("", func(c *echo.Context) error {
+ return music.GetSong(c)
+ })
+ protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error {
+ return music.GetSoundCheckSong(c)
+ })
+ protectedMusicGroup.GET("/reset", func(c *echo.Context) error {
+ return music.ResetMusic(c)
+ })
+ protectedMusicGroup.GET("/rand", func(c *echo.Context) error {
+ return music.GetRandomSong(c)
+ })
+ protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error {
+ return music.GetRandomSongLowChance(c)
+ })
+ protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error {
+ return music.GetRandomSongClassic(c)
+ })
+ protectedMusicGroup.GET("/info", func(c *echo.Context) error {
+ return music.GetSongInfo(c)
+ })
+ protectedMusicGroup.GET("/list", func(c *echo.Context) error {
+ return music.GetPlayedSongs(c)
+ })
+ protectedMusicGroup.GET("/next", func(c *echo.Context) error {
+ return music.GetNextSong(c)
+ })
+ protectedMusicGroup.GET("/previous", func(c *echo.Context) error {
+ return music.GetPreviousSong(c)
+ })
+ protectedMusicGroup.GET("/all", func(c *echo.Context) error {
+ return music.GetAllSoundtracksRandom(c)
+ })
+ protectedMusicGroup.GET("/all/order", func(c *echo.Context) error {
+ return music.GetAllSoundtracks(c)
+ })
+ protectedMusicGroup.GET("/all/random", func(c *echo.Context) error {
+ return music.GetAllSoundtracksRandom(c)
+ })
+ protectedMusicGroup.PUT("/played", func(c *echo.Context) error {
+ return music.PutPlayed(c)
+ })
+ protectedMusicGroup.GET("/addQue", func(c *echo.Context) error {
+ return music.AddLatestToQue(c)
+ })
+ protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error {
+ return music.AddLatestPlayed(c)
+ })
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 2c6fbc5..a4e8072 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -15,19 +15,14 @@ import (
)
type Server struct {
- port int
- db *db.Database
- tokenHandler *TokenHandler
+ port int
+ db *db.Database
+ tokenHandler *TokenHandler
statisticsHandler *StatisticsHandler
- httpServer *http.Server
+ httpServer *http.Server
}
var (
- host = os.Getenv("DB_HOST")
- dbPort = os.Getenv("DB_PORT")
- dbName = os.Getenv("DB_NAME")
- username = os.Getenv("DB_USERNAME")
- password = os.Getenv("DB_PASSWORD")
musicPath = os.Getenv("MUSIC_PATH")
charactersPath = os.Getenv("CHARACTERS_PATH")
logLevel = os.Getenv("LOG_LEVEL")
@@ -37,11 +32,20 @@ var (
// NewServerInstance creates a new Server instance with all dependencies initialized.
// Use this for dependency injection and proper lifecycle management.
func NewServerInstance() *Server {
+ // Re-read environment variables at server creation time (not at package import time)
+ // This allows tests to override environment variables
+ host := os.Getenv("DB_HOST")
+ dbPort := os.Getenv("DB_PORT")
+ dbName := os.Getenv("DB_NAME")
+ username := os.Getenv("DB_USERNAME")
+ password := os.Getenv("DB_PASSWORD")
+
// Initialize logger
- if logLevel == "" {
- logLevel = "info"
+ currentLogLevel := logLevel
+ if currentLogLevel == "" {
+ currentLogLevel = "info"
}
- logging.Init(logLevel, logJSON)
+ logging.Init(currentLogLevel, logJSON)
logger := logging.GetLogger()
@@ -68,15 +72,15 @@ 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,
}
diff --git a/internal/server/sql_dump_migration_test.go b/internal/server/sql_dump_migration_test.go
new file mode 100644
index 0000000..bde802c
--- /dev/null
+++ b/internal/server/sql_dump_migration_test.go
@@ -0,0 +1,411 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/wait"
+)
+
+// saveEnvVars saves the current state of relevant environment variables
+func saveEnvVars() map[string]string {
+ vars := []string{
+ "DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME",
+ "MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL",
+ }
+ saved := make(map[string]string)
+ for _, v := range vars {
+ saved[v] = os.Getenv(v)
+ }
+ return saved
+}
+
+// restoreEnvVars restores environment variables from a saved state
+func restoreEnvVars(saved map[string]string) {
+ for k, v := range saved {
+ if v == "" {
+ os.Unsetenv(k)
+ } else {
+ os.Setenv(k, v)
+ }
+ }
+}
+
+// checkTableExists returns true if the specified table exists in the database
+func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) {
+ var exists bool
+ err := pool.QueryRow(ctx, `
+ SELECT EXISTS (
+ SELECT FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = $1
+ )
+ `, tableName).Scan(&exists)
+ return exists, err
+}
+
+// loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database
+func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error {
+ // Copy the SQL dump file to the container
+ basename := filepath.Base(dumpPath)
+ targetPath := "/tmp/" + basename
+ if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil {
+ return fmt.Errorf("failed to copy dump file to container: %w", err)
+ }
+
+ // Execute psql to load the dump as the postgres superuser
+ // We execute psql inside the container, so we use localhost and port 5432 (the container's internal port)
+ // The postgres user is the superuser created by the Docker image
+ // Use sh instead of bash as postgres:15-alpine uses Alpine Linux
+ cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"}
+
+ exitCode, reader, err := container.Exec(ctx, cmd)
+ if err != nil {
+ return fmt.Errorf("failed to execute psql: %w", err)
+ }
+
+ // Read the output
+ output, err := io.ReadAll(reader)
+ if err != nil {
+ return fmt.Errorf("failed to read psql output: %w", err)
+ }
+
+ if exitCode != 0 {
+ return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output))
+ }
+
+ t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname)
+ return nil
+}
+
+// createRoleInContainer creates a role in the PostgreSQL container
+func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error {
+ // Get the mapped port for the container
+ mappedPort, err := container.MappedPort(ctx, "5432")
+ if err != nil {
+ return fmt.Errorf("failed to get mapped port: %w", err)
+ }
+
+ // Connect to the postgres database to create the role
+ connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
+ mappedPort.Port())
+
+ pool, err := pgxpool.New(ctx, connStr)
+ if err != nil {
+ return fmt.Errorf("failed to connect to postgres database: %w", err)
+ }
+ defer pool.Close()
+
+ // Check if role already exists
+ var exists bool
+ err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists)
+ if err == nil && exists {
+ return nil // Role already exists
+ }
+
+ // Clear any error from the role check (role might not exist, which is expected)
+ // Create the role
+ _, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName))
+ if err != nil {
+ return fmt.Errorf("failed to create role %s: %w", roleName, err)
+ }
+
+ return nil
+}
+
+// createDatabaseInContainer creates a new database in the PostgreSQL container
+func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error {
+ // Get the mapped port for the container
+ mappedPort, err := container.MappedPort(ctx, "5432")
+ if err != nil {
+ return fmt.Errorf("failed to get mapped port: %w", err)
+ }
+
+ // Add a small delay to ensure PostgreSQL is fully ready
+ time.Sleep(2 * time.Second)
+
+ // Connect to the default 'postgres' database to create the new database
+ // Use localhost and the mapped port
+ connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
+ mappedPort.Port())
+
+ pool, err := pgxpool.New(ctx, connStr)
+ if err != nil {
+ return fmt.Errorf("failed to connect to postgres database: %w", err)
+ }
+ defer pool.Close()
+
+ // Check if database already exists and drop it if so
+ var exists int
+ err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
+ if err == nil && exists == 1 {
+ _, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)")
+ if err != nil {
+ return fmt.Errorf("failed to drop existing database: %w", err)
+ }
+ t.Logf("Dropped existing database %s", dbname)
+ }
+
+ // Create the new database
+ _, err = pool.Exec(ctx, "CREATE DATABASE "+dbname)
+ if err != nil {
+ return fmt.Errorf("failed to create database: %w", err)
+ }
+
+ t.Logf("Created database %s", dbname)
+ return nil
+}
+
+// startPostgreSQLContainer starts a PostgreSQL container and returns it
+func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) {
+ req := testcontainers.ContainerRequest{
+ Image: "postgres:15-alpine",
+ ExposedPorts: []string{"5432/tcp"},
+ Env: map[string]string{
+ "POSTGRES_PASSWORD": "postgres",
+ "POSTGRES_DB": "postgres",
+ },
+ WaitingFor: wait.ForLog("database system is ready to accept connections"),
+ }
+
+ container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
+ ContainerRequest: req,
+ Started: true,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to start container: %w", err)
+ }
+
+ return container, nil
+}
+
+// TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work
+func TestMusicRefDump(t *testing.T) {
+ testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test")
+}
+
+// TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work
+func TestMusicTestDump(t *testing.T) {
+ testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test")
+}
+
+// TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work
+func TestMusicProdDump(t *testing.T) {
+ testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test")
+}
+
+// testSQLDump is the common test function for all SQL dump tests
+func testSQLDump(t *testing.T, dumpFile, dbname string) {
+ ctx := context.Background()
+
+ // Save original environment variables
+ originalEnv := saveEnvVars()
+ defer restoreEnvVars(originalEnv)
+
+ // Start PostgreSQL container
+ t.Log("Starting PostgreSQL container...")
+ container, err := startPostgreSQLContainer(t, ctx)
+ require.NoError(t, err)
+ defer func() {
+ t.Log("Stopping container...")
+ container.Terminate(ctx)
+ }()
+
+ // Get container connection details
+ endpoint, err := container.Endpoint(ctx, "")
+ require.NoError(t, err)
+ t.Logf("Container running at: %s", endpoint)
+
+ // Create database for this test
+ t.Logf("Creating database %s...", dbname)
+ err = createDatabaseInContainer(t, ctx, container, dbname)
+ require.NoError(t, err)
+
+ // Create common roles that might be referenced in dump files
+ err = createRoleInContainer(t, ctx, container, "sansan", "postgres")
+ if err != nil {
+ t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err)
+ }
+
+ // Load the SQL dump
+ // The dump files are in the project root
+ // Get the absolute path to the dump file
+ absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile))
+ require.NoError(t, err)
+ if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
+ // Try relative to current working directory
+ absDumpPath = filepath.Join(".", dumpFile)
+ if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
+ absDumpPath = dumpFile // Try as-is
+ }
+ }
+
+ t.Logf("Loading SQL dump from: %s", absDumpPath)
+ err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath)
+ require.NoError(t, err)
+
+ // ============================================
+ // PRE-MIGRATION CHECK
+ // ============================================
+ t.Log("Running pre-migration checks...")
+
+ // For connecting to the database from the test, we need to use the container's endpoint
+ // But the container's endpoint might be localhost:random_port
+ // We need to parse it to get host and port
+ // endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456"
+ // Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port
+ // Let's extract the mapped port
+
+ // Get the mapped port from the container
+ mappedPort, err := container.MappedPort(ctx, "5432")
+ require.NoError(t, err)
+ t.Logf("Container mapped port: %s", mappedPort)
+
+ // Use localhost as the host since we're on the same machine
+ dbHost := "localhost"
+ dbPort := mappedPort.Port()
+
+ // Create a connection to the database to check pre-migration state
+ connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
+ dbHost, dbPort, "postgres", "postgres", dbname)
+
+ pool, err := pgxpool.New(ctx, connStr)
+ require.NoError(t, err)
+ defer pool.Close()
+
+ // Check that game table exists (pre-migration state)
+ gameExists, err := checkTableExists(ctx, pool, "game")
+ require.NoError(t, err)
+ assert.True(t, gameExists, "Before migration: game table should exist")
+
+ // Check that soundtrack table does NOT exist
+ soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack")
+ require.NoError(t, err)
+ assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist")
+
+ t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not")
+
+ // ============================================
+ // START APPLICATION (triggers migrations)
+ // ============================================
+ t.Log("Starting application...")
+
+ // Set environment variables for the server
+ // The application needs to connect to the container's mapped port
+ os.Setenv("DB_HOST", dbHost)
+ os.Setenv("DB_PORT", dbPort)
+ os.Setenv("DB_USERNAME", "postgres")
+ os.Setenv("DB_PASSWORD", "postgres")
+ os.Setenv("DB_NAME", dbname)
+ os.Setenv("MUSIC_PATH", "./testMusic")
+ os.Setenv("CHARACTERS_PATH", "./testCharacters")
+
+ // Use different ports for each test to allow for sequential execution
+ // and to make port detection reliable
+ portMap := map[string]string{
+ "music_server_ref_test": "8082",
+ "music_server_test_dump_test": "8083",
+ "music_server_prod_test": "8084",
+ }
+ appPort := portMap[dbname]
+ if appPort == "" {
+ appPort = "8085" // fallback
+ }
+ os.Setenv("PORT", appPort)
+
+ os.Setenv("DISABLE_SESSION_AUTH", "true")
+ os.Setenv("LOG_LEVEL", "error")
+
+ // Create server instance (this runs migrations automatically)
+ serverInstance := NewServerInstance()
+ httpServer := serverInstance.HTTPServer()
+
+ // Start HTTP server in background
+ go func() {
+ if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ t.Logf("Server error: %v", err)
+ }
+ }()
+
+ defer func() {
+ t.Log("Stopping HTTP server...")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ httpServer.Shutdown(ctx)
+ }()
+
+ // Wait for server to start by polling /health endpoint
+ // Use the fixed port we configured for the application
+ healthURL := fmt.Sprintf("http://localhost:%s/health", appPort)
+
+ maxAttempts := 30
+ for attempt := 1; attempt <= maxAttempts; attempt++ {
+ resp, err := http.Get(healthURL)
+ if err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusOK {
+ var healthData map[string]string
+ json.NewDecoder(resp.Body).Decode(&healthData)
+ if healthData["status"] == "up" {
+ t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt)
+ break
+ }
+ }
+ }
+ if attempt == maxAttempts {
+ require.Fail(t, "Server did not become healthy within timeout")
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ // ============================================
+ // POST-MIGRATION CHECK
+ // ============================================
+ t.Log("Running post-migration checks...")
+
+ // Check that game table no longer exists
+ gameExistsAfter, err := checkTableExists(ctx, pool, "game")
+ require.NoError(t, err)
+ assert.False(t, gameExistsAfter, "After migration: game table should NOT exist")
+
+ // Check that soundtrack table now exists
+ soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack")
+ require.NoError(t, err)
+ assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist")
+
+ // Verify data was preserved
+ var soundtrackCount int
+ err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
+ require.NoError(t, err)
+ assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0")
+ t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount)
+
+ // ============================================
+ // HEALTH CHECK
+ // ============================================
+ t.Log("Running health check...")
+
+ resp, err := http.Get(healthURL)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var healthData map[string]string
+ err = json.NewDecoder(resp.Body).Decode(&healthData)
+ require.NoError(t, err)
+ assert.Equal(t, "up", healthData["status"])
+
+ t.Log("✓ Health check passed")
+ t.Log("✓ Test completed successfully!")
+}
diff --git a/internal/server/statistics_handler_test.go b/internal/server/statistics_handler_test.go
index 966ae9b..1927fc2 100644
--- a/internal/server/statistics_handler_test.go
+++ b/internal/server/statistics_handler_test.go
@@ -12,6 +12,7 @@ import (
"music-server/internal/db"
"music-server/internal/db/repository"
+ "github.com/google/uuid"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/require"
)
@@ -122,33 +123,37 @@ func insertTestData(t *testing.T) {
SoundtrackName: st.name,
Path: st.path,
Hash: "test-hash-" + st.name,
+ Uuid: uuid.New(),
})
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
}
- // Get soundtrack IDs
+ // Get soundtrack IDs and UUIDs
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
+ soundtrackID int32
+ soundtrackUuid uuid.UUID
+ 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"}},
+ {soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}},
+ {soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}},
+ {soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}},
+ {soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}},
+ {soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []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",
+ SoundtrackID: sd.soundtrackID,
+ SongName: songName,
+ Path: "/path/to/" + songName + ".mp3",
+ Uuid: uuid.New(),
+ SoundtrackUuid: sd.soundtrackUuid,
})
require.NoError(t, err, "Failed to insert song: %s", songName)
}
diff --git a/internal/server/token_handler.go b/internal/server/token_handler.go
index e234e67..0037c06 100644
--- a/internal/server/token_handler.go
+++ b/internal/server/token_handler.go
@@ -4,28 +4,30 @@ import (
"crypto/rand"
"encoding/base64"
"net/http"
+ "os"
"strings"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
- "github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
// TokenRequest represents a request to generate a new token
type TokenRequest struct {
+ Password string `json:"password"` // Required: API password
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
}
// TokenResponse represents the response with a new token
type TokenResponse struct {
- Token string `json:"token"`
- ExpiresAt time.Time `json:"expires_at"`
- ClientType string `json:"client_type"`
+ Token string `json:"token"`
+ ExpiresAt time.Time `json:"expires_at"`
+ ClientType string `json:"client_type"`
}
// TokenHandler contains the database pool for token operations
@@ -53,13 +55,14 @@ func (h *TokenHandler) generateToken() (string, error) {
// POST /api/v1/token
//
// @Summary Create session token
-// @Description Returns a new session token for API access
+// @Description Returns a new session token for API access. Requires valid API password.
// @Tags auth
// @Accept json
// @Produce json
-// @Param request body TokenRequest true "Client type"
+// @Param request body TokenRequest true "Password and client type"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} map[string]string
+// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [post]
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
@@ -68,6 +71,27 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
}
+ // Check if session auth is disabled
+ disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
+ apiPassword := os.Getenv("API_PASSWORD")
+
+ // Password validation logic:
+ // - If session auth is disabled, skip validation
+ // - If API_PASSWORD is empty, allow any password (open access mode)
+ // - If API_PASSWORD is set, require exact password match
+ if !disableSessionAuth {
+ if apiPassword != "" {
+ // API password is set - require exact match
+ if req.Password != apiPassword {
+ logging.GetLogger().Warn("Invalid password attempt for token creation",
+ zap.String("ip", c.RealIP()),
+ zap.String("user_agent", c.Request().UserAgent()))
+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
+ }
+ }
+ // If API_PASSWORD is empty, allow any password through (open access mode)
+ }
+
if req.ClientType == "" {
req.ClientType = "web"
}
@@ -98,8 +122,8 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
}
response := TokenResponse{
- Token: session.Token,
- ExpiresAt: session.ExpiresAt.Time,
+ Token: session.Token,
+ ExpiresAt: session.ExpiresAt.Time,
ClientType: *session.ClientType,
}
diff --git a/justfile b/justfile
index b4bd9c8..3aa992b 100644
--- a/justfile
+++ b/justfile
@@ -85,7 +85,7 @@ build-run: build
test: build
@echo "Starting test database container..."
- @podman-compose -f compose.test.yaml up -d
+ @docker compose -f compose.test.yaml up -d
@sleep 10
@echo "Running integration tests..."
@just test-integration
@@ -97,54 +97,75 @@ clean:
@echo "Cleaning..."
@rm -f main
-podman-build:
- @echo "Building Docker image with podman..."
- @podman build -t music-server .
+docker-build:
+ @echo "Building Docker image with docker..."
+ @docker build -t music-server .
-podman-run:
- @podman-compose up --build
+docker-run:
+ @docker compose up --build
-podman-down:
- @podman-compose down
+docker-down:
+ @docker compose down
-# Run integration tests with podman
+# Run integration tests with docker
# Starts a test PostgreSQL container, runs tests, then cleans up
test-integration:
@echo "Cleaning old test database..."
- @podman-compose -f compose.test.yaml down -v
+ @docker compose -f compose.test.yaml down -v
@echo "Starting fresh test database container..."
- @podman-compose -f compose.test.yaml up -d
+ @docker compose -f compose.test.yaml up -d
@sleep 10
@echo "Running integration tests..."
- @DB_HOST=localhost DB_PORT=5433 DB_USERNAME=testuser DB_PASSWORD=testpass DB_NAME=music_server_test MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters PORT=8081 LOG_LEVEL=debug LOG_JSON=false go test -v -timeout 30m -p 1 -parallel 1 ./internal/...
+ @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net DB_HOST=localhost DB_PORT=5433 DB_USERNAME=testuser DB_PASSWORD=testpass DB_NAME=music_server_test MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters PORT=8081 LOG_LEVEL=debug LOG_JSON=false go test -v -timeout 30m -p 1 -parallel 1 ./internal/...
-# Alternative: Run integration tests using testcontainers with podman provider
+# Alternative: Run integration tests using testcontainers with docker provider
test-integration-tc:
- @echo "Running integration tests with testcontainers (podman provider)..."
- @TESTCONTAINERS_PROVIDER=podman go test -v -timeout 30m .
+ @echo "Creating test network (if it doesn't exist)..."
+ @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
+ @echo "Running integration tests with testcontainers (docker provider)..."
+ @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 30m .
+
+# Run SQL dump migration tests
+# Tests that the application can start with each SQL dump and migrations work correctly
+test-sql-dump:
+ @echo "Creating test network (if it doesn't exist)..."
+ @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
+ @echo "Running SQL dump migration tests..."
+ @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 15m -run "TestMusic.*Dump" ./internal/server/
+
+# Run individual SQL dump tests
+test-sql-dump-ref:
+ @echo "Creating test network (if it doesn't exist)..."
+ @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
+ @echo "Running SQL dump migration test for ref dump..."
+ @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicRefDump" ./internal/server/
+
+test-sql-dump-test:
+ @echo "Creating test network (if it doesn't exist)..."
+ @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
+ @echo "Running SQL dump migration test for test dump..."
+ @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicTestDump" ./internal/server/
+
+test-sql-dump-prod:
+ @echo "Creating test network (if it doesn't exist)..."
+ @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
+ @echo "Running SQL dump migration test for prod dump..."
+ @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicProdDump" ./internal/server/
# Stop and remove test database container
test-integration-down:
@echo "Stopping test database container..."
- @podman-compose -f compose.test.yaml down -v
+ @docker compose -f compose.test.yaml down -v
-# Create DB container
-docker-run:
- @if docker compose up --build 2>/dev/null; then \
- : ; \
- else \
- echo "Falling back to Docker Compose V1"; \
- docker-compose up --build; \
- fi
+# Build and run with Docker Compose
+build-run-docker:
+ @echo "Building and running with Docker Compose..."
+ @docker compose up --build
-# Shutdown DB container
-docker-down:
- @if docker compose down 2>/dev/null; then \
- : ; \
- else \
- echo "Falling back to Docker Compose V1"; \
- docker-compose down; \
- fi
+# Shutdown containers
+stop-docker:
+ @echo "Stopping containers..."
+ @docker compose down
watch-templ:
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."
diff --git a/sqlc.yaml b/sqlc.yaml
index a6d95b0..e689c52 100644
--- a/sqlc.yaml
+++ b/sqlc.yaml
@@ -39,3 +39,12 @@ sql:
go_type:
import: "time"
type: "Time"
+ - db_type: "uuid"
+ go_type:
+ import: "github.com/google/uuid"
+ type: "UUID"
+ - db_type: "uuid"
+ nullable: true
+ go_type:
+ import: "github.com/google/uuid"
+ type: "UUID"
diff --git a/testMusic/A Bird Story/.45.id b/testMusic/A Bird Story/.45.id
deleted file mode 100644
index e69de29..0000000
diff --git a/testMusic/Clair Obscur - Expedition 33/.1078.id b/testMusic/Clair Obscur - Expedition 33/.1078.id
deleted file mode 100644
index e69de29..0000000
diff --git a/testMusic/Metal Gear Rising - Revengeance/.480.id b/testMusic/Metal Gear Rising - Revengeance/.480.id
deleted file mode 100644
index e69de29..0000000