5 Commits
Author SHA1 Message Date
Sansan 581d91f78b Fixed workflow file
Build / build (push) Successful in 39s
Publish / publish (push) Successful in 49s
2026-09-26 17:16:23 +02:00
Sansan a8a2602f50 #9 Major routes refactor and expansion, #15 Added API for statistics, Added search cache backend and functionality tests, Backend sync improvements
Build / build (push) Successful in 56s
2026-09-26 16:48:40 +02:00
Sansan e86c0f8e04 Combine Issue #28 (UUID Phase 2 - NOT NULL) into migration 000005
- Add NOT NULL constraints to soundtrack.uuid, song.uuid, and song.soundtrack_uuid
- Add corresponding DROP NOT NULL in down migration
- Update header comments to reflect combined Phase 1 & 2

This eliminates the need for a separate migration 000007 while maintaining
proper rollback capability. The NOT NULL constraints are added AFTER
all UUID backfills are complete, ensuring data integrity.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-28 14:16:04 +02:00
Sansan eda60e0c07 Added fuzzy finder to search, made sync check as middleware, .id file is never used
Build / build (push) Successful in 45s
2026-07-02 09:30:24 +02:00
Sansan a8df738108 #9 Changed the term game to soundtrack everywhere
Build / build (push) Successful in 45s
2026-06-29 18:39:09 +02:00
51 changed files with 2986 additions and 1799 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# Directories
.gitea
git
.idea
testMusic
testCharacters
# Files
Dockerfile
docker-compose*
compose.yaml
.dockerignore
.gitignore
*.pprof
main
conf.yaml
output.css
tailwindcss
.env
# Node.js (frontend build artifacts)
node_modules
package.json
package-lock.json
# Test files
*_test.go
integration_test.go
# OS
.DS_Store
+4
View File
@@ -13,3 +13,7 @@ CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters
PORT=8081 PORT=8081
LOG_LEVEL=debug LOG_LEVEL=debug
LOG_JSON=false LOG_JSON=false
# Session Authentication (for testing)
# Set to disable session auth during development
DISABLE_SESSION_AUTH=true
+9 -15
View File
@@ -1,33 +1,27 @@
# Stage 1: Build frontend # Stage 1: Build backend
FROM node:18-alpine AS frontend-builder
RUN apk add --no-cache git
WORKDIR /app
RUN git clone https://gitea.sanplex.xyz/Sansan/MusicFrontend.git
WORKDIR /app/MusicFrontend
RUN npm install
RUN npm run build
# Generate config.js with empty API_HOSTNAME (relative paths)
RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > dist/config.js
# Stage 2: Build backend
FROM golang:1.25-alpine as build_go FROM golang:1.25-alpine as build_go
RUN apk add --no-cache curl RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY cmd/ ./cmd/
COPY internal/ ./internal/
RUN find . -name "*_test.go" -delete && \
find . -name "integration_test.go" -delete
RUN go install github.com/a-h/templ/cmd/templ@latest RUN go install github.com/a-h/templ/cmd/templ@latest
RUN templ generate RUN templ generate
RUN go build -o main cmd/main.go RUN go build -o main cmd/main.go
# Stage 3: Final image # Stage 2: Final image
FROM golang:1.25-alpine FROM golang:1.25-alpine
EXPOSE 8080 EXPOSE 8080
VOLUME /sorted VOLUME /sorted
VOLUME /characters VOLUME /characters
COPY --from=build_go /app/main . COPY --from=build_go /app/main .
COPY --from=frontend-builder /app/MusicFrontend/dist /frontend COPY --from=gitea.sanplex.xyz/sansan/musicfrontend:latest /usr/share/nginx/html /frontend
# Generate config.js with empty API_HOSTNAME (relative paths)
RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > /frontend/config.js
COPY ./songs/ ./songs/ COPY ./songs/ ./songs/
ENV PORT 8080 ENV PORT 8080
+302 -289
View File
@@ -23,252 +23,9 @@ var doc = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/api/v1/statistics/games/last-played": {
"get": {
"description": "Returns the most recently played games",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get last played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/least-played": {
"get": {
"description": "Returns the top N least played games with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get least played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/most-played": {
"get": {
"description": "Returns the top N most played games with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get most played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/never-played": {
"get": {
"description": "Returns all games that have never been played (times_played = 0)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get never played games",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/oldest-played": {
"get": {
"description": "Returns the least recently played games (that have been played at least once)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get oldest played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/songs/least-played": { "/api/v1/statistics/songs/least-played": {
"get": { "get": {
"description": "Returns the top N least played songs with their game info", "description": "Returns the top N least played songs with their soundtrack info",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -320,7 +77,7 @@ var doc = `{
}, },
"/api/v1/statistics/songs/most-played": { "/api/v1/statistics/songs/most-played": {
"get": { "get": {
"description": "Returns the top N most played songs with their game info", "description": "Returns the top N most played songs with their soundtrack info",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -370,6 +127,249 @@ var doc = `{
} }
} }
}, },
"/api/v1/statistics/soundtracks/last-played": {
"get": {
"description": "Returns the most recently played soundtracks",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get last played soundtracks",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/soundtracks/least-played": {
"get": {
"description": "Returns the top N least played soundtracks with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get least played soundtracks",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/soundtracks/most-played": {
"get": {
"description": "Returns the top N most played soundtracks with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get most played soundtracks",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/soundtracks/never-played": {
"get": {
"description": "Returns all soundtracks that have never been played (times_played = 0)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get never played soundtracks",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/soundtracks/oldest-played": {
"get": {
"description": "Returns the least recently played soundtracks (that have been played at least once)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get oldest played soundtracks",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/summary": { "/api/v1/statistics/summary": {
"get": { "get": {
"description": "Returns overall statistics about the music library", "description": "Returns overall statistics about the music library",
@@ -404,7 +404,7 @@ var doc = `{
}, },
"/api/v1/token": { "/api/v1/token": {
"post": { "post": {
"description": "Returns a new session token for API access", "description": "Returns a new session token for API access. Requires valid API password.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -417,7 +417,7 @@ var doc = `{
"summary": "Create session token", "summary": "Create session token",
"parameters": [ "parameters": [
{ {
"description": "Client type", "description": "Password and client type",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -442,6 +442,15 @@ var doc = `{
} }
} }
}, },
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": { "500": {
"description": "Internal Server Error", "description": "Internal Server Error",
"schema": { "schema": {
@@ -824,7 +833,7 @@ var doc = `{
}, },
"/music/all/order": { "/music/all/order": {
"get": { "get": {
"description": "Returns a list of all games in order", "description": "Returns a list of all soundtracks in order",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -857,7 +866,7 @@ var doc = `{
}, },
"/music/all/random": { "/music/all/random": {
"get": { "get": {
"description": "Returns a list of all games in random order", "description": "Returns a list of all soundtracks in random order",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1197,7 +1206,7 @@ var doc = `{
}, },
"/sync": { "/sync": {
"get": { "get": {
"description": "Starts syncing games with only new changes", "description": "Starts syncing soundtracks with only new changes",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1226,7 +1235,7 @@ var doc = `{
}, },
"/sync/full": { "/sync/full": {
"get": { "get": {
"description": "Starts a full sync of all games", "description": "Starts a full sync of all soundtracks",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1236,7 +1245,7 @@ var doc = `{
"tags": [ "tags": [
"sync" "sync"
], ],
"summary": "Sync all games fully", "summary": "Sync all soundtracks fully",
"responses": { "responses": {
"200": { "200": {
"description": "Start syncing soundtracks full", "description": "Start syncing soundtracks full",
@@ -1279,7 +1288,7 @@ var doc = `{
}, },
"/sync/reset": { "/sync/reset": {
"get": { "get": {
"description": "Resets the games database by deleting all games and songs", "description": "Resets the soundtracks database by deleting all soundtracks and songs",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1369,74 +1378,74 @@ var doc = `{
} }
}, },
"definitions": { "definitions": {
"backend.GameWithSongs": {
"type": "object",
"properties": {
"game_id": {
"type": "integer"
},
"game_last_played": {
"type": "string"
},
"game_name": {
"type": "string"
},
"game_played": {
"type": "integer"
},
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
}
},
"backend.SongInfoForStats": { "backend.SongInfoForStats": {
"type": "object", "type": "object",
"properties": { "properties": {
"file_name": { "file_name": {
"type": "string" "type": "string"
}, },
"game_id": {
"type": "integer"
},
"game_name": {
"type": "string"
},
"path": { "path": {
"type": "string" "type": "string"
}, },
"song_name": { "song_name": {
"type": "string" "type": "string"
}, },
"soundtrack_id": {
"type": "integer"
},
"soundtrack_name": {
"type": "string"
},
"times_played": { "times_played": {
"type": "integer" "type": "integer"
} }
} }
}, },
"backend.SoundtrackWithSongs": {
"type": "object",
"properties": {
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
},
"soundtrack_id": {
"type": "integer"
},
"soundtrack_last_played": {
"type": "string"
},
"soundtrack_name": {
"type": "string"
},
"soundtrack_played": {
"type": "integer"
}
}
},
"backend.StatisticsSummary": { "backend.StatisticsSummary": {
"type": "object", "type": "object",
"properties": { "properties": {
"avg_game_plays": { "avg_soundtrack_plays": {
"type": "number" "type": "number"
}, },
"max_game_plays": { "max_soundtrack_plays": {
"type": "integer" "type": "integer"
}, },
"min_game_plays": { "min_soundtrack_plays": {
"type": "integer" "type": "integer"
}, },
"never_played_games": { "never_played_soundtracks": {
"type": "integer" "type": "integer"
}, },
"played_games": { "played_soundtracks": {
"type": "integer" "type": "integer"
}, },
"total_game_plays": { "total_soundtrack_plays": {
"type": "integer" "type": "integer"
}, },
"total_games": { "total_soundtracks": {
"type": "integer" "type": "integer"
} }
} }
@@ -1466,6 +1475,10 @@ var doc = `{
"client_type": { "client_type": {
"description": "Optional: \"web\", \"mobile\", \"api\"", "description": "Optional: \"web\", \"mobile\", \"api\"",
"type": "string" "type": "string"
},
"password": {
"description": "Required: API password",
"type": "string"
} }
} }
}, },
+302 -289
View File
@@ -4,252 +4,9 @@
"contact": {} "contact": {}
}, },
"paths": { "paths": {
"/api/v1/statistics/games/last-played": {
"get": {
"description": "Returns the most recently played games",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get last played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/least-played": {
"get": {
"description": "Returns the top N least played games with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get least played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/most-played": {
"get": {
"description": "Returns the top N most played games with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get most played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/never-played": {
"get": {
"description": "Returns all games that have never been played (times_played = 0)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get never played games",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/oldest-played": {
"get": {
"description": "Returns the least recently played games (that have been played at least once)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get oldest played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/songs/least-played": { "/api/v1/statistics/songs/least-played": {
"get": { "get": {
"description": "Returns the top N least played songs with their game info", "description": "Returns the top N least played songs with their soundtrack info",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -301,7 +58,7 @@
}, },
"/api/v1/statistics/songs/most-played": { "/api/v1/statistics/songs/most-played": {
"get": { "get": {
"description": "Returns the top N most played songs with their game info", "description": "Returns the top N most played songs with their soundtrack info",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -351,6 +108,249 @@
} }
} }
}, },
"/api/v1/statistics/soundtracks/last-played": {
"get": {
"description": "Returns the most recently played soundtracks",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get last played soundtracks",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/soundtracks/least-played": {
"get": {
"description": "Returns the top N least played soundtracks with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get least played soundtracks",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/soundtracks/most-played": {
"get": {
"description": "Returns the top N most played soundtracks with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get most played soundtracks",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/soundtracks/never-played": {
"get": {
"description": "Returns all soundtracks that have never been played (times_played = 0)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get never played soundtracks",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/soundtracks/oldest-played": {
"get": {
"description": "Returns the least recently played soundtracks (that have been played at least once)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get oldest played soundtracks",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SoundtrackWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/summary": { "/api/v1/statistics/summary": {
"get": { "get": {
"description": "Returns overall statistics about the music library", "description": "Returns overall statistics about the music library",
@@ -385,7 +385,7 @@
}, },
"/api/v1/token": { "/api/v1/token": {
"post": { "post": {
"description": "Returns a new session token for API access", "description": "Returns a new session token for API access. Requires valid API password.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -398,7 +398,7 @@
"summary": "Create session token", "summary": "Create session token",
"parameters": [ "parameters": [
{ {
"description": "Client type", "description": "Password and client type",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -423,6 +423,15 @@
} }
} }
}, },
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": { "500": {
"description": "Internal Server Error", "description": "Internal Server Error",
"schema": { "schema": {
@@ -805,7 +814,7 @@
}, },
"/music/all/order": { "/music/all/order": {
"get": { "get": {
"description": "Returns a list of all games in order", "description": "Returns a list of all soundtracks in order",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -838,7 +847,7 @@
}, },
"/music/all/random": { "/music/all/random": {
"get": { "get": {
"description": "Returns a list of all games in random order", "description": "Returns a list of all soundtracks in random order",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1178,7 +1187,7 @@
}, },
"/sync": { "/sync": {
"get": { "get": {
"description": "Starts syncing games with only new changes", "description": "Starts syncing soundtracks with only new changes",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1207,7 +1216,7 @@
}, },
"/sync/full": { "/sync/full": {
"get": { "get": {
"description": "Starts a full sync of all games", "description": "Starts a full sync of all soundtracks",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1217,7 +1226,7 @@
"tags": [ "tags": [
"sync" "sync"
], ],
"summary": "Sync all games fully", "summary": "Sync all soundtracks fully",
"responses": { "responses": {
"200": { "200": {
"description": "Start syncing soundtracks full", "description": "Start syncing soundtracks full",
@@ -1260,7 +1269,7 @@
}, },
"/sync/reset": { "/sync/reset": {
"get": { "get": {
"description": "Resets the games database by deleting all games and songs", "description": "Resets the soundtracks database by deleting all soundtracks and songs",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1350,74 +1359,74 @@
} }
}, },
"definitions": { "definitions": {
"backend.GameWithSongs": {
"type": "object",
"properties": {
"game_id": {
"type": "integer"
},
"game_last_played": {
"type": "string"
},
"game_name": {
"type": "string"
},
"game_played": {
"type": "integer"
},
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
}
},
"backend.SongInfoForStats": { "backend.SongInfoForStats": {
"type": "object", "type": "object",
"properties": { "properties": {
"file_name": { "file_name": {
"type": "string" "type": "string"
}, },
"game_id": {
"type": "integer"
},
"game_name": {
"type": "string"
},
"path": { "path": {
"type": "string" "type": "string"
}, },
"song_name": { "song_name": {
"type": "string" "type": "string"
}, },
"soundtrack_id": {
"type": "integer"
},
"soundtrack_name": {
"type": "string"
},
"times_played": { "times_played": {
"type": "integer" "type": "integer"
} }
} }
}, },
"backend.SoundtrackWithSongs": {
"type": "object",
"properties": {
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
},
"soundtrack_id": {
"type": "integer"
},
"soundtrack_last_played": {
"type": "string"
},
"soundtrack_name": {
"type": "string"
},
"soundtrack_played": {
"type": "integer"
}
}
},
"backend.StatisticsSummary": { "backend.StatisticsSummary": {
"type": "object", "type": "object",
"properties": { "properties": {
"avg_game_plays": { "avg_soundtrack_plays": {
"type": "number" "type": "number"
}, },
"max_game_plays": { "max_soundtrack_plays": {
"type": "integer" "type": "integer"
}, },
"min_game_plays": { "min_soundtrack_plays": {
"type": "integer" "type": "integer"
}, },
"never_played_games": { "never_played_soundtracks": {
"type": "integer" "type": "integer"
}, },
"played_games": { "played_soundtracks": {
"type": "integer" "type": "integer"
}, },
"total_game_plays": { "total_soundtrack_plays": {
"type": "integer" "type": "integer"
}, },
"total_games": { "total_soundtracks": {
"type": "integer" "type": "integer"
} }
} }
@@ -1447,6 +1456,10 @@
"client_type": { "client_type": {
"description": "Optional: \"web\", \"mobile\", \"api\"", "description": "Optional: \"web\", \"mobile\", \"api\"",
"type": "string" "type": "string"
},
"password": {
"description": "Required: API password",
"type": "string"
} }
} }
}, },
+208 -196
View File
@@ -1,49 +1,49 @@
definitions: 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: backend.SongInfoForStats:
properties: properties:
file_name: file_name:
type: string type: string
game_id:
type: integer
game_name:
type: string
path: path:
type: string type: string
song_name: song_name:
type: string type: string
soundtrack_id:
type: integer
soundtrack_name:
type: string
times_played: times_played:
type: integer type: integer
type: object 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: backend.StatisticsSummary:
properties: properties:
avg_game_plays: avg_soundtrack_plays:
type: number type: number
max_game_plays: max_soundtrack_plays:
type: integer type: integer
min_game_plays: min_soundtrack_plays:
type: integer type: integer
never_played_games: never_played_soundtracks:
type: integer type: integer
played_games: played_soundtracks:
type: integer type: integer
total_game_plays: total_soundtrack_plays:
type: integer type: integer
total_games: total_soundtracks:
type: integer type: integer
type: object type: object
backend.VersionData: backend.VersionData:
@@ -64,6 +64,9 @@ definitions:
client_type: client_type:
description: 'Optional: "web", "mobile", "api"' description: 'Optional: "web", "mobile", "api"'
type: string type: string
password:
description: 'Required: API password'
type: string
type: object type: object
server.TokenResponse: server.TokenResponse:
properties: properties:
@@ -77,171 +80,11 @@ definitions:
info: info:
contact: {} contact: {}
paths: 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: /api/v1/statistics/songs/least-played:
get: get:
consumes: consumes:
- application/json - 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: parameters:
- description: 'Number of results (default: 10)' - description: 'Number of results (default: 10)'
in: query in: query
@@ -275,7 +118,7 @@ paths:
get: get:
consumes: consumes:
- application/json - 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: parameters:
- description: 'Number of results (default: 10)' - description: 'Number of results (default: 10)'
in: query in: query
@@ -305,6 +148,167 @@ paths:
summary: Get most played songs summary: Get most played songs
tags: tags:
- statistics - 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: /api/v1/statistics/summary:
get: get:
consumes: consumes:
@@ -364,9 +368,10 @@ paths:
post: post:
consumes: consumes:
- application/json - 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: parameters:
- description: Client type - description: Password and client type
in: body in: body
name: request name: request
required: true required: true
@@ -385,6 +390,12 @@ paths:
additionalProperties: additionalProperties:
type: string type: string
type: object type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500": "500":
description: Internal Server Error description: Internal Server Error
schema: schema:
@@ -606,7 +617,7 @@ paths:
get: get:
consumes: consumes:
- application/json - application/json
description: Returns a list of all games in order description: Returns a list of all soundtracks in order
produces: produces:
- application/json - application/json
responses: responses:
@@ -628,7 +639,7 @@ paths:
get: get:
consumes: consumes:
- application/json - application/json
description: Returns a list of all games in random order description: Returns a list of all soundtracks in random order
produces: produces:
- application/json - application/json
responses: responses:
@@ -852,7 +863,7 @@ paths:
get: get:
consumes: consumes:
- application/json - application/json
description: Starts syncing games with only new changes description: Starts syncing soundtracks with only new changes
produces: produces:
- application/json - application/json
responses: responses:
@@ -871,7 +882,7 @@ paths:
get: get:
consumes: consumes:
- application/json - application/json
description: Starts a full sync of all games description: Starts a full sync of all soundtracks
produces: produces:
- application/json - application/json
responses: responses:
@@ -883,7 +894,7 @@ paths:
description: Syncing is in progress description: Syncing is in progress
schema: schema:
type: string type: string
summary: Sync all games fully summary: Sync all soundtracks fully
tags: tags:
- sync - sync
/sync/progress: /sync/progress:
@@ -906,7 +917,8 @@ paths:
get: get:
consumes: consumes:
- application/json - 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: produces:
- application/json - application/json
responses: responses:
+1 -1
View File
@@ -2,7 +2,7 @@ package web
templ Base() { templ Base() {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<title>Music Search</title> <title>Music Search</title>
-117
View File
@@ -1,117 +0,0 @@
package web
import (
"log"
"music-server/internal/backend"
"net/http"
"regexp"
"strings"
)
var games_added []string
func FindGameWebHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
}
search_term := r.FormValue("search_term")
search(search_term)
component := FoundGames(games_added)
err = component.Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Fatalf("Error rendering in FindGameWebHandler: %e", err)
}
}
func search(searchText string) {
games_added = nil
games := backend.GetAllSoundtracks()
for _, game := range games {
if is_match_exact(searchText, game) {
add_game(game)
}
}
for _, game := range games {
if is_match_contains(clean_term(searchText), clean_term(game)) {
add_game(game)
}
}
for _, game := range games {
if is_match_regex(clean_term(searchText), clean_term(game)) {
add_game(game)
}
}
}
func is_match_exact(search_term string, game_name string) bool {
search_term = strings.ToLower(search_term)
game_name = strings.ToLower(game_name)
if search_term == "" {
return true
} else if strings.Contains(game_name, search_term) {
return true
} else {
return false
}
}
func is_match_contains(search_term string, game_name string) bool {
if search_term == "" {
return true
} else if strings.Contains(game_name, search_term) {
return true
} else {
return false
}
}
func is_match_regex(search_term string, game_name string) bool {
if search_term == "" {
return true
} else if compile_regex(search_term).MatchString(game_name) {
return true
} else {
return false
}
}
func add_game(game string) {
if !check_if_game_exists(game) {
games_added = append(games_added, game)
}
}
func check_if_game_exists(gameName string) bool {
game_exists := false
for _, child := range games_added {
if child == gameName {
game_exists = true
}
}
return game_exists
}
func compile_regex(search_term string) *regexp.Regexp {
regText := ".*"
for _, letter := range search_term {
regText += string(letter) + ".*"
}
r, _ := regexp.Compile(regText)
return r
}
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)
}
+77
View File
@@ -0,0 +1,77 @@
package web
import (
"log"
"music-server/internal/backend"
"net/http"
"strings"
)
var soundtracks_added []string
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(results)
err = component.Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Fatalf("Error rendering in FindSoundtrackWebHandler: %e", err)
}
}
func search(searchText string) {
soundtracks_added = nil
soundtrackData := backend.GetSearchCache()
results := searchWithData(searchText, soundtrackData)
soundtracks_added = results
}
// 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)
// 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 {
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 {
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 {
if !seen[data.Original] && backend.MatchRegexCached(cleanedSearchTerm, data.Cleaned) {
results = append(results, data.Original)
seen[data.Original] = true
}
}
return results
}
+12 -15
View File
@@ -1,25 +1,22 @@
package web package web
templ HelloForm() { templ SearchForm() {
@Base() { @Base() {
<button id="dark-mode-toggle">🌙</button> <button id="dark-mode-toggle">🌙</button>
<div id="search-container"> <input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#games-container"/>
<button type="button" id="clear" name="clear">Clear</button> <button type="button" id="clear" name="clear">Clear</button>
</div> <div id="soundtracks-container"></div>
<div id="games-container"></div>
<script> <script>
document.addEventListener('readystatechange', () => { document.addEventListener('readystatechange', () => {
if (document.readyState == 'complete') { if (document.readyState == 'complete') {
htmx.ajax('POST', '/find', '#games-container'); // Initialize search
htmlx.ajax('POST', '/find', '#soundtracks-container');
document.getElementById("search_term").focus(); document.getElementById("search_term").focus();
// Initialize dark mode from localStorage (default to dark) // Initialize theme from localStorage (default to dark)
const savedTheme = localStorage.getItem('theme') || 'dark'; const savedTheme = localStorage.getItem('theme') || 'dark';
if (savedTheme === 'dark') { document.documentElement.setAttribute('data-theme', savedTheme);
document.documentElement.setAttribute('data-theme', 'dark'); document.getElementById('dark-mode-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
document.getElementById('dark-mode-toggle').textContent = '☀️';
}
} }
}); });
@@ -38,17 +35,17 @@ templ HelloForm() {
document.getElementById("clear").addEventListener("click", function (event) { document.getElementById("clear").addEventListener("click", function (event) {
document.getElementById("search_term").value = ""; document.getElementById("search_term").value = "";
htmx.ajax('POST', '/find', '#games-container'); htmlx.ajax('POST', '/find', '#soundtracks-container');
document.getElementById("search_term").focus(); document.getElementById("search_term").focus();
}); });
</script> </script>
} }
} }
templ FoundGames(games []string) { templ FoundSoundtracks(soundtracks []string) {
for _, game := range games { for _, soundtrack := range soundtracks {
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6"> <div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
<p class="game-text">{ game }</p> <p class="soundtrack-text">{ soundtrack }</p>
</div> </div>
} }
} }
+193
View File
@@ -0,0 +1,193 @@
package web
import (
"music-server/internal/backend"
"strings"
"testing"
)
// TestCleanTerm tests that search terms are cleaned correctly
func TestCleanTerm(t *testing.T) {
tests := []struct {
input string
expected string
}{
// CleanSearchTermForMatch removes spaces, replaces specific chars, and lowercases
{"MGS4", "mgs4"},
{"RE2", "re2"},
{"FFVII", "ffvii"},
{"Ys", "ys"},
{" test ", "test"},
{"Test 123", "test123"},
{"A+B", "aplusb"},
{"é", "e"},
// 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"},
{"", ""},
}
for _, tt := range tests {
result := backend.CleanSearchTermForMatch(tt.input)
if result != tt.expected {
t.Errorf("CleanSearchTermForMatch(%q) = %q, want %q", tt.input, result, 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
}
// 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
wantFound string
}{
// 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)"},
// 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) {
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)
}
})
}
}
// 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
+3 -2
View File
@@ -5,6 +5,7 @@ go 1.25.0
require ( require (
github.com/MShekow/directory-checksum v1.4.18 github.com/MShekow/directory-checksum v1.4.18
github.com/a-h/templ v0.3.1020 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/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/labstack/echo/v5 v5.1.1 github.com/labstack/echo/v5 v5.1.1
@@ -18,6 +19,8 @@ require (
go.uber.org/zap v1.28.0 go.uber.org/zap v1.28.0
) )
require github.com/google/uuid v1.6.0
require ( require (
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // 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/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // 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/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // 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/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/go-openapi/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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
+50 -50
View File
@@ -12,8 +12,8 @@ import (
) )
type SongInfo struct { type SongInfo struct {
Game string `json:"Game"` Soundtrack string `json:"Soundtrack"`
GamePlayed int32 `json:"GamePlayed"` SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
Song string `json:"Song"` Song string `json:"Song"`
SongPlayed int32 `json:"SongPlayed"` SongPlayed int32 `json:"SongPlayed"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"` CurrentlyPlaying bool `json:"CurrentlyPlaying"`
@@ -22,7 +22,7 @@ type SongInfo struct {
var currentSong = -1 var currentSong = -1
var gamesNew []repository.Soundtrack var soundtracksNew []repository.Soundtrack
var songQueNew []repository.Song var songQueNew []repository.Song
@@ -37,12 +37,12 @@ func initRepo() {
} }
} }
func getAllGames() []repository.Soundtrack { func getAllSoundtracks() []repository.Soundtrack {
if len(gamesNew) == 0 { if len(soundtracksNew) == 0 {
initRepo() initRepo()
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx()) soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
} }
return gamesNew return soundtracksNew
} }
@@ -59,7 +59,7 @@ func Reset() {
songQueNew = nil songQueNew = nil
currentSong = -1 currentSong = -1
initRepo() initRepo()
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx()) soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
} }
func AddLatestToQue() { func AddLatestToQue() {
@@ -92,34 +92,34 @@ func SetPlayed(songNumber int) {
} }
func GetRandomSong() string { func GetRandomSong() string {
getAllGames() getAllSoundtracks()
if len(gamesNew) == 0 { if len(soundtracksNew) == 0 {
return "" return ""
} }
song := getSongFromList(gamesNew) song := getSongFromList(soundtracksNew)
lastFetchedNew = song lastFetchedNew = song
return song.Path return song.Path
} }
func GetRandomSongLowChance() string { func GetRandomSongLowChance() string {
getAllGames() getAllSoundtracks()
var listOfGames []repository.Soundtrack var listOfSoundtracks []repository.Soundtrack
var averagePlayed = getAveragePlayed() var averagePlayed = getAveragePlayed()
for _, data := range gamesNew { for _, data := range soundtracksNew {
timesToAdd := averagePlayed - data.TimesPlayed timesToAdd := averagePlayed - data.TimesPlayed
if timesToAdd <= 0 { if timesToAdd <= 0 {
listOfGames = append(listOfGames, data) listOfSoundtracks = append(listOfSoundtracks, data)
} else { } else {
for i := int32(0); i < timesToAdd; i++ { for i := int32(0); i < timesToAdd; i++ {
listOfGames = append(listOfGames, data) listOfSoundtracks = append(listOfSoundtracks, data)
} }
} }
} }
song := getSongFromList(listOfGames) song := getSongFromList(listOfSoundtracks)
lastFetchedNew = song lastFetchedNew = song
return song.Path return song.Path
@@ -127,11 +127,11 @@ func GetRandomSongLowChance() string {
} }
func GetRandomSongClassic() string { func GetRandomSongClassic() string {
getAllGames() getAllSoundtracks()
var listOfAllSongs []repository.Song var listOfAllSongs []repository.Song
for _, game := range gamesNew { for _, soundtrack := range soundtracksNew {
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID) songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
listOfAllSongs = append(listOfAllSongs, songList...) listOfAllSongs = append(listOfAllSongs, songList...)
} }
@@ -139,25 +139,25 @@ func GetRandomSongClassic() string {
var song repository.Song var song repository.Song
for !songFound { for !songFound {
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))] song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
gameData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID) soundtrackData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
if err != nil { if err != nil {
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", gameData.SoundtrackName), zap.String("soundtrack", soundtrackData.SoundtrackName),
zap.String("filename", *song.FileName)) zap.String("filename", *song.FileName))
continue continue
} }
//Check if file exists and open //Check if file exists and open
openFile, err := os.Open(song.Path) openFile, err := os.Open(song.Path)
if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) { if err != nil || (song.FileName != nil && soundtrackData.Path+*song.FileName != song.Path) {
//File not found //File not found
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", gameData.SoundtrackName), zap.String("soundtrack", soundtrackData.SoundtrackName),
zap.String("filename", *song.FileName)) zap.String("filename", *song.FileName))
} else { } else {
songFound = true songFound = true
@@ -177,11 +177,11 @@ func GetSongInfo() SongInfo {
} }
var currentSongData = songQueNew[currentSong] var currentSongData = songQueNew[currentSong]
currentGameData := getCurrentGame(currentSongData) currentSoundtrackData := getCurrentSoundtrack(currentSongData)
return SongInfo{ return SongInfo{
Game: currentGameData.SoundtrackName, Soundtrack: currentSoundtrackData.SoundtrackName,
GamePlayed: currentGameData.TimesPlayed, SoundtrackPlayed: currentSoundtrackData.TimesPlayed,
Song: currentSongData.SongName, Song: currentSongData.SongName,
SongPlayed: currentSongData.TimesPlayed, SongPlayed: currentSongData.TimesPlayed,
CurrentlyPlaying: true, CurrentlyPlaying: true,
@@ -193,10 +193,10 @@ func GetPlayedSongs() []SongInfo {
var songList []SongInfo var songList []SongInfo
for i, song := range songQueNew { for i, song := range songQueNew {
gameData := getCurrentGame(song) soundtrackData := getCurrentSoundtrack(song)
songList = append(songList, SongInfo{ songList = append(songList, SongInfo{
Game: gameData.SoundtrackName, Soundtrack: soundtrackData.SoundtrackName,
GamePlayed: gameData.TimesPlayed, SoundtrackPlayed: soundtrackData.TimesPlayed,
Song: song.SongName, Song: song.SongName,
SongPlayed: song.TimesPlayed, SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == currentSong, CurrentlyPlaying: i == currentSong,
@@ -218,21 +218,21 @@ func GetSong(song string) string {
} }
func GetAllSoundtracks() []string { func GetAllSoundtracks() []string {
getAllGames() getAllSoundtracks()
var jsonArray []string var jsonArray []string
for _, game := range gamesNew { for _, soundtrack := range soundtracksNew {
jsonArray = append(jsonArray, game.SoundtrackName) jsonArray = append(jsonArray, soundtrack.SoundtrackName)
} }
return jsonArray return jsonArray
} }
func GetAllSoundtracksRandom() []string { func GetAllSoundtracksRandom() []string {
getAllGames() getAllSoundtracks()
var jsonArray []string var jsonArray []string
for _, game := range gamesNew { for _, soundtrack := range soundtracksNew {
jsonArray = append(jsonArray, game.SoundtrackName) jsonArray = append(jsonArray, soundtrack.SoundtrackName)
} }
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] }) rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
return jsonArray return jsonArray
@@ -266,12 +266,12 @@ func GetPreviousSong() string {
} }
} }
func getSongFromList(games []repository.Soundtrack) repository.Song { func getSongFromList(soundtracks []repository.Soundtrack) repository.Song {
songFound := false songFound := false
var song repository.Song var song repository.Song
for !songFound { for !songFound {
game := getRandomGame(games) soundtrack := getRandomSoundtrack(soundtracks)
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID) songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
if len(songs) == 0 { if len(songs) == 0 {
continue continue
} }
@@ -280,12 +280,12 @@ func getSongFromList(games []repository.Soundtrack) repository.Song {
//Check if file exists and open //Check if file exists and open
openFile, err := os.Open(song.Path) openFile, err := os.Open(song.Path)
if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) { if err != nil || (song.FileName != nil && soundtrack.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
//File not found //File not found
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", game.SoundtrackName), zap.String("soundtrack", soundtrack.SoundtrackName),
zap.Any("filename", song.FileName)) zap.Any("filename", song.FileName))
} else { } else {
songFound = true songFound = true
@@ -299,24 +299,24 @@ func getSongFromList(games []repository.Soundtrack) repository.Song {
return song return song
} }
func getCurrentGame(currentSongData repository.Song) repository.Soundtrack { func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
for _, game := range gamesNew { for _, soundtrack := range soundtracksNew {
if game.ID == currentSongData.SoundtrackID { if soundtrack.ID == currentSongData.SoundtrackID {
return game return soundtrack
} }
} }
return repository.Soundtrack{} return repository.Soundtrack{}
} }
func getAveragePlayed() int32 { func getAveragePlayed() int32 {
getAllGames() getAllSoundtracks()
var sum int32 var sum int32
for _, data := range gamesNew { for _, data := range soundtracksNew {
sum += data.TimesPlayed sum += data.TimesPlayed
} }
return sum / int32(len(gamesNew)) return sum / int32(len(soundtracksNew))
} }
func getRandomGame(listOfGames []repository.Soundtrack) repository.Soundtrack { func getRandomSoundtrack(listOfSoundtracks []repository.Soundtrack) repository.Soundtrack {
return listOfGames[rand.Intn(len(listOfGames))] return listOfSoundtracks[rand.Intn(len(listOfSoundtracks))]
} }
+59 -59
View File
@@ -9,17 +9,17 @@ import (
// Test the average calculation logic directly without database access // Test the average calculation logic directly without database access
func TestCalculateAverage(t *testing.T) { func TestCalculateAverage(t *testing.T) {
games := []repository.Soundtrack{ soundtracks := []repository.Soundtrack{
{SoundtrackName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Soundtrack3", TimesPlayed: 30},
} }
var sum int32 var sum int32
for _, data := range games { for _, data := range soundtracks {
sum += data.TimesPlayed sum += data.TimesPlayed
} }
result := sum / int32(len(games)) result := sum / int32(len(soundtracks))
expected := int32(20) expected := int32(20)
if result != expected { if result != expected {
@@ -28,9 +28,9 @@ func TestCalculateAverage(t *testing.T) {
} }
func TestCalculateAverageEmpty(t *testing.T) { func TestCalculateAverageEmpty(t *testing.T) {
games := []repository.Soundtrack{} soundtracks := []repository.Soundtrack{}
if len(games) == 0 { if len(soundtracks) == 0 {
result := int32(0) result := int32(0)
expected := int32(0) expected := int32(0)
if result != expected { if result != expected {
@@ -40,10 +40,10 @@ func TestCalculateAverageEmpty(t *testing.T) {
} }
var sum int32 var sum int32
for _, data := range games { for _, data := range soundtracks {
sum += data.TimesPlayed sum += data.TimesPlayed
} }
result := sum / int32(len(games)) result := sum / int32(len(soundtracks))
expected := int32(0) expected := int32(0)
if result != expected { if result != expected {
@@ -52,74 +52,74 @@ func TestCalculateAverageEmpty(t *testing.T) {
} }
func TestCalculateAverageSingle(t *testing.T) { func TestCalculateAverageSingle(t *testing.T) {
games := []repository.Soundtrack{ soundtracks := []repository.Soundtrack{
{SoundtrackName: "Game1", TimesPlayed: 42}, {SoundtrackName: "Soundtrack1", TimesPlayed: 42},
} }
var sum int32 var sum int32
for _, data := range games { for _, data := range soundtracks {
sum += data.TimesPlayed sum += data.TimesPlayed
} }
result := sum / int32(len(games)) result := sum / int32(len(soundtracks))
expected := int32(42) expected := int32(42)
if result != expected { if result != expected {
t.Errorf("Average calculation with single game = %v, want %v", result, expected) t.Errorf("Average calculation with single soundtrack = %v, want %v", result, expected)
} }
} }
func TestGetRandomGame(t *testing.T) { func TestGetRandomSoundtrack(t *testing.T) {
games := []repository.Soundtrack{ soundtracks := []repository.Soundtrack{
{SoundtrackName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Soundtrack3", TimesPlayed: 30},
} }
// Set seed for reproducible tests // Set seed for reproducible tests
rand.Seed(42) rand.Seed(42)
result := games[rand.Intn(len(games))] result := soundtracks[rand.Intn(len(soundtracks))]
if result.SoundtrackName == "" { if result.SoundtrackName == "" {
t.Error("random game selection returned empty game") t.Error("random soundtrack selection returned empty soundtrack")
} }
found := false found := false
for _, g := range games { for _, s := range soundtracks {
if g.SoundtrackName == result.SoundtrackName { if s.SoundtrackName == result.SoundtrackName {
found = true found = true
break break
} }
} }
if !found { if !found {
t.Errorf("random game selection returned game not in list: %v", result.SoundtrackName) t.Errorf("random soundtrack selection returned soundtrack not in list: %v", result.SoundtrackName)
} }
} }
func TestFindGameByID(t *testing.T) { func TestFindSoundtrackByID(t *testing.T) {
games := []repository.Soundtrack{ soundtracks := []repository.Soundtrack{
{ID: 1, SoundtrackName: "Game1", TimesPlayed: 10}, {ID: 1, SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{ID: 2, SoundtrackName: "Game2", TimesPlayed: 20}, {ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{ID: 3, SoundtrackName: "Game3", TimesPlayed: 30}, {ID: 3, SoundtrackName: "Soundtrack3", TimesPlayed: 30},
} }
tests := []struct { tests := []struct {
name string name string
games []repository.Soundtrack soundtracks []repository.Soundtrack
gameID int32 soundtrackID int32
expected repository.Soundtrack expected repository.Soundtrack
}{ }{
{ {
name: "existing game", name: "existing soundtrack",
games: games, soundtracks: soundtracks,
gameID: 2, soundtrackID: 2,
expected: repository.Soundtrack{ID: 2, SoundtrackName: "Game2", TimesPlayed: 20}, expected: repository.Soundtrack{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
}, },
{ {
name: "non-existing game", name: "non-existing soundtrack",
games: games, soundtracks: soundtracks,
gameID: 99, soundtrackID: 99,
expected: repository.Soundtrack{}, expected: repository.Soundtrack{},
}, },
} }
@@ -127,32 +127,32 @@ func TestFindGameByID(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var result repository.Soundtrack var result repository.Soundtrack
for _, game := range tt.games { for _, s := range tt.soundtracks {
if game.ID == tt.gameID { if s.ID == tt.soundtrackID {
result = game result = s
break break
} }
} }
if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName { if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName {
t.Errorf("findGameByID() = %v, want %v", result, tt.expected) t.Errorf("findSoundtrackByID() = %v, want %v", result, tt.expected)
} }
}) })
} }
} }
func TestExtractSoundtrackNames(t *testing.T) { func TestExtractSoundtrackNames(t *testing.T) {
games := []repository.Soundtrack{ soundtracks := []repository.Soundtrack{
{SoundtrackName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Soundtrack3", TimesPlayed: 30},
} }
var result []string var result []string
for _, game := range games { for _, s := range soundtracks {
result = append(result, game.SoundtrackName) result = append(result, s.SoundtrackName)
} }
expected := []string{"Game1", "Game2", "Game3"} expected := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
if len(result) != len(expected) { if len(result) != len(expected) {
t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected)) t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected))
@@ -167,29 +167,29 @@ func TestExtractSoundtrackNames(t *testing.T) {
} }
func TestShuffleSoundtrackNames(t *testing.T) { func TestShuffleSoundtrackNames(t *testing.T) {
games := []string{"Game1", "Game2", "Game3"} soundtracks := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
// Test that shuffle doesn't lose any elements // Test that shuffle doesn't lose any elements
// We can't test the order since it's random, but we can test length and contents // We can't test the order since it's random, but we can test length and contents
original := make([]string, len(games)) original := make([]string, len(soundtracks))
copy(original, games) copy(original, soundtracks)
// Simple shuffle implementation for testing // Simple shuffle implementation for testing
for i := range games { for i := range soundtracks {
j := i // In real code this would be random j := i // In real code this would be random
games[i], games[j] = games[j], games[i] soundtracks[i], soundtracks[j] = soundtracks[j], soundtracks[i]
} }
if len(games) != len(original) { if len(soundtracks) != len(original) {
t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(games)) t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(soundtracks))
return return
} }
// Check all original elements are still present // Check all original elements are still present
for _, orig := range original { for _, orig := range original {
found := false found := false
for _, g := range games { for _, s := range soundtracks {
if g == orig { if s == orig {
found = true found = true
break break
} }
+100
View File
@@ -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
}
+55 -55
View File
@@ -9,19 +9,19 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// GameWithSongs represents a game with its songs for statistics // SoundtrackWithSongs represents a soundtrack with its songs for statistics
type GameWithSongs struct { type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"game_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"game_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"game_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"game_last_played,omitempty"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
Songs []SongInfoForStats `json:"songs"` Songs []SongInfoForStats `json:"songs"`
} }
// SongInfoForStats represents a song with game info for statistics // SongInfoForStats represents a song with soundtrack info for statistics
type SongInfoForStats struct { type SongInfoForStats struct {
SoundtrackID int32 `json:"game_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"game_name"` SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"` SongName string `json:"song_name"`
Path string `json:"path"` Path string `json:"path"`
TimesPlayed int32 `json:"times_played"` TimesPlayed int32 `json:"times_played"`
@@ -30,13 +30,13 @@ type SongInfoForStats struct {
// StatisticsSummary holds overall statistics // StatisticsSummary holds overall statistics
type StatisticsSummary struct { type StatisticsSummary struct {
TotalGames int64 `json:"total_games"` TotalSoundtracks int64 `json:"total_soundtracks"`
PlayedGames int64 `json:"played_games"` PlayedSoundtracks int64 `json:"played_soundtracks"`
NeverPlayedGames int64 `json:"never_played_games"` NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"`
TotalGamePlays int64 `json:"total_game_plays"` TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"`
AvgGamePlays float64 `json:"avg_game_plays"` AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"`
MaxGamePlays int64 `json:"max_game_plays"` MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"`
MinGamePlays int64 `json:"min_game_plays"` MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
} }
// StatisticsHandler manages statistics operations // StatisticsHandler manages statistics operations
@@ -49,19 +49,19 @@ func NewStatisticsHandler() *StatisticsHandler {
return &StatisticsHandler{} return &StatisticsHandler{}
} }
// GetMostPlayedGamesWithSongs returns the top N most played games with their songs // GetMostPlayedSoundtracksWithSongs returns the top N most played soundtracks with their songs
func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) { func (h *StatisticsHandler) GetMostPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
// Get raw results // Get raw results
rows, err := queries.GetMostPlayedGamesWithSongs(ctx, limit) rows, err := queries.GetMostPlayedSoundtracksWithSongs(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Convert to GameWithSongs // Convert to SoundtrackWithSongs
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -71,7 +71,7 @@ func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWith
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
@@ -82,17 +82,17 @@ func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWith
return result, nil return result, nil
} }
// GetLeastPlayedGamesWithSongs returns the top N least played games with their songs // GetLeastPlayedSoundtracksWithSongs returns the top N least played soundtracks with their songs
func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) { func (h *StatisticsHandler) GetLeastPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetLeastPlayedGamesWithSongs(ctx, limit) rows, err := queries.GetLeastPlayedSoundtracksWithSongs(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -100,7 +100,7 @@ func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWit
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
@@ -111,12 +111,12 @@ func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWit
return result, nil return result, nil
} }
// GetMostPlayedSongsWithGame returns the top N most played songs with their game info // GetMostPlayedSongsWithSoundtrack returns the top N most played songs with their soundtrack info
func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) { func (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetMostPlayedSongsWithGame(ctx, limit) rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -135,12 +135,12 @@ func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoF
return result, nil return result, nil
} }
// GetLeastPlayedSongsWithGame returns the top N least played songs with their game info // GetLeastPlayedSongsWithSoundtrack returns the top N least played songs with their soundtrack info
func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) { func (h *StatisticsHandler) GetLeastPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetLeastPlayedSongsWithGame(ctx, limit) rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -159,17 +159,17 @@ func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfo
return result, nil return result, nil
} }
// GetNeverPlayedGames returns games that have never been played // GetNeverPlayedSoundtracks returns soundtracks that have never been played
func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) { func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetNeverPlayedGames(ctx) rows, err := queries.GetNeverPlayedSoundtracks(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -177,7 +177,7 @@ func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
@@ -188,17 +188,17 @@ func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
return result, nil return result, nil
} }
// GetLastPlayedGames returns the most recently played games // GetLastPlayedSoundtracks returns the most recently played soundtracks
func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, error) { func (h *StatisticsHandler) GetLastPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetLastPlayedGames(ctx, limit) rows, err := queries.GetLastPlayedSoundtracks(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -206,7 +206,7 @@ func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, er
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
@@ -217,17 +217,17 @@ func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, er
return result, nil return result, nil
} }
// GetOldestPlayedGames returns the least recently played games // GetOldestPlayedSoundtracks returns the least recently played soundtracks
func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs, error) { func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetOldestPlayedGames(ctx, limit) rows, err := queries.GetOldestPlayedSoundtracks(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -235,7 +235,7 @@ func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs,
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
@@ -257,13 +257,13 @@ func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
} }
return &StatisticsSummary{ return &StatisticsSummary{
TotalGames: int64(row.TotalSoundtracks), TotalSoundtracks: int64(row.TotalSoundtracks),
PlayedGames: int64(row.PlayedSoundtracks), PlayedSoundtracks: int64(row.PlayedSoundtracks),
NeverPlayedGames: int64(row.NeverPlayedSoundtracks), NeverPlayedSoundtracks: int64(row.NeverPlayedSoundtracks),
TotalGamePlays: int64(row.TotalSoundtrackPlays), TotalSoundtrackPlays: int64(row.TotalSoundtrackPlays),
AvgGamePlays: float64(row.AvgSoundtrackPlays), AvgSoundtrackPlays: float64(row.AvgSoundtrackPlays),
MaxGamePlays: int64(row.MaxSoundtrackPlays), MaxSoundtrackPlays: int64(row.MaxSoundtrackPlays),
MinGamePlays: int64(row.MinSoundtrackPlays), MinSoundtrackPlays: int64(row.MinSoundtrackPlays),
}, nil }, nil
} }
+194 -163
View File
@@ -16,6 +16,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/google/uuid"
"github.com/panjf2000/ants/v2" "github.com/panjf2000/ants/v2"
"github.com/MShekow/directory-checksum/directory_checksum" "github.com/MShekow/directory-checksum/directory_checksum"
@@ -30,14 +31,14 @@ var start time.Time
var totalTime time.Duration var totalTime time.Duration
var timeSpent time.Duration var timeSpent time.Duration
var allGames []repository.Soundtrack var allSoundtracks []repository.Soundtrack
var gamesBeforeSync []repository.Soundtrack var soundtracksBeforeSync []repository.Soundtrack
var gamesAfterSync []repository.Soundtrack var soundtracksAfterSync []repository.Soundtrack
var gamesAdded []string var soundtracksAdded []string
var gamesReAdded []string var soundtracksReAdded []string
var gamesChangedTitle map[string]string var soundtracksChangedTitle map[string]string
var gamesChangedContent []string var soundtracksChangedContent []string
var gamesRemoved []string var soundtracksRemoved []string
var catchedErrors []string var catchedErrors []string
type brokenSong struct { type brokenSong struct {
@@ -50,11 +51,11 @@ var pool *ants.Pool
var poolSong *ants.Pool var poolSong *ants.Pool
type SyncResponse struct { type SyncResponse struct {
GamesAdded []string `json:"games_added"` SoundtracksAdded []string `json:"soundtracks_added"`
GamesReAdded []string `json:"games_re_added"` SoundtracksReAdded []string `json:"soundtracks_re_added"`
GamesChangedTitle map[string]string `json:"games_changed_title"` SoundtracksChangedTitle map[string]string `json:"soundtracks_changed_title"`
GamesChangedContent []string `json:"games_changed_content"` SoundtracksChangedContent []string `json:"soundtracks_changed_content"`
GamesRemoved []string `json:"games_removed"` SoundtracksRemoved []string `json:"soundtracks_removed"`
CatchedErrors []string `json:"catched_errors"` CatchedErrors []string `json:"catched_errors"`
TotalTime string `json:"total_time"` TotalTime string `json:"total_time"`
} }
@@ -64,24 +65,24 @@ type ProgressResponse struct {
TimeSpent string `json:"time_spent"` TimeSpent string `json:"time_spent"`
} }
type GameStatus int type SoundtrackStatus int
const ( const (
NotChanged GameStatus = iota NotChanged SoundtrackStatus = iota
TitleChanged TitleChanged
GameChanged SoundtrackChanged
NewGame NewSoundtrack
) )
var statusName = map[GameStatus]string{ var statusName = map[SoundtrackStatus]string{
NotChanged: "Not changed", NotChanged: "Not changed",
TitleChanged: "Title changed", TitleChanged: "Title changed",
GameChanged: "Game changed", SoundtrackChanged: "Soundtrack changed",
NewGame: "New game", NewSoundtrack: "New soundtrack",
} }
func (gs GameStatus) String() string { func (ss SoundtrackStatus) String() string {
return statusName[gs] return statusName[ss]
} }
func ResetDB() { func ResetDB() {
@@ -107,54 +108,54 @@ func SyncProgress() ProgressResponse {
func SyncResult() SyncResponse { func SyncResult() SyncResponse {
logging.GetLogger().Info("Sync completed", logging.GetLogger().Info("Sync completed",
zap.Int("games_before", len(gamesBeforeSync)), zap.Int("soundtracks_before", len(soundtracksBeforeSync)),
zap.Int("games_after", len(gamesAfterSync))) zap.Int("soundtracks_after", len(soundtracksAfterSync)))
if len(gamesAdded) > 0 { if len(soundtracksAdded) > 0 {
logging.GetLogger().Debug("Games added", zap.Strings("games", gamesAdded)) logging.GetLogger().Debug("Soundtracks added", zap.Strings("soundtracks", soundtracksAdded))
} }
if len(gamesReAdded) > 0 { if len(soundtracksReAdded) > 0 {
logging.GetLogger().Debug("Games readded", zap.Strings("games", gamesReAdded)) logging.GetLogger().Debug("Soundtracks readded", zap.Strings("soundtracks", soundtracksReAdded))
} }
if len(gamesChangedTitle) > 0 { if len(soundtracksChangedTitle) > 0 {
logging.GetLogger().Debug("Games with changed title", zap.Any("changes", gamesChangedTitle)) logging.GetLogger().Debug("Soundtracks with changed title", zap.Any("changes", soundtracksChangedTitle))
} }
if len(gamesChangedContent) > 0 { if len(soundtracksChangedContent) > 0 {
logging.GetLogger().Debug("Games with changed content", zap.Strings("games", gamesChangedContent)) logging.GetLogger().Debug("Soundtracks with changed content", zap.Strings("soundtracks", soundtracksChangedContent))
} }
var gamesRemovedTemp []string var soundtracksRemovedTemp []string
for _, beforeGame := range gamesBeforeSync { for _, beforeSoundtrack := range soundtracksBeforeSync {
var found = false var found = false
for _, afterGame := range gamesAfterSync { for _, afterSoundtrack := range soundtracksAfterSync {
if beforeGame.SoundtrackName == afterGame.SoundtrackName { if beforeSoundtrack.SoundtrackName == afterSoundtrack.SoundtrackName {
found = true found = true
break break
} }
} }
if !found { if !found {
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.SoundtrackName) soundtracksRemovedTemp = append(soundtracksRemovedTemp, beforeSoundtrack.SoundtrackName)
} }
} }
for _, game := range gamesRemovedTemp { for _, soundtrack := range soundtracksRemovedTemp {
var found bool = false var found bool = false
for key := range gamesChangedTitle { for key := range soundtracksChangedTitle {
if game == key { if soundtrack == key {
found = true found = true
break break
} }
} }
if !found { if !found {
gamesRemoved = append(gamesRemoved, game) soundtracksRemoved = append(soundtracksRemoved, soundtrack)
} }
} }
if len(gamesRemoved) > 0 { if len(soundtracksRemoved) > 0 {
logging.GetLogger().Debug("Games removed", zap.Strings("games", gamesRemoved)) logging.GetLogger().Debug("Soundtracks removed", zap.Strings("soundtracks", soundtracksRemoved))
} }
if len(catchedErrors) > 0 { if len(catchedErrors) > 0 {
@@ -165,27 +166,29 @@ func SyncResult() SyncResponse {
logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000"))) logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000")))
return SyncResponse{ return SyncResponse{
GamesAdded: gamesAdded, SoundtracksAdded: soundtracksAdded,
GamesReAdded: gamesReAdded, SoundtracksReAdded: soundtracksReAdded,
GamesChangedTitle: gamesChangedTitle, SoundtracksChangedTitle: soundtracksChangedTitle,
GamesChangedContent: gamesChangedContent, SoundtracksChangedContent: soundtracksChangedContent,
GamesRemoved: gamesRemoved, SoundtracksRemoved: soundtracksRemoved,
CatchedErrors: catchedErrors, CatchedErrors: catchedErrors,
TotalTime: out.Format("15:04:05"), TotalTime: out.Format("15:04:05"),
} }
} }
func SyncSoundtracksNewFull() { func SyncSoundtracksFull() {
syncGamesNew(true) syncSoundtracks(true)
Reset() Reset()
ReloadSearchCache()
} }
func SyncSoundtracksNewOnlyChanges() { func SyncSoundtracksOnlyChanges() {
syncGamesNew(false) syncSoundtracks(false)
Reset() Reset()
ReloadSearchCache()
} }
func syncGamesNew(full bool) { func syncSoundtracks(full bool) {
musicPath := os.Getenv("MUSIC_PATH") musicPath := os.Getenv("MUSIC_PATH")
fmt.Printf("dir: %s\n", musicPath) fmt.Printf("dir: %s\n", musicPath)
logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath)) logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath))
@@ -201,19 +204,19 @@ func syncGamesNew(full bool) {
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip)) logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
var err error var err error
gamesAdded = nil soundtracksAdded = nil
gamesReAdded = nil soundtracksReAdded = nil
gamesChangedTitle = nil soundtracksChangedTitle = nil
gamesChangedContent = nil soundtracksChangedContent = nil
gamesRemoved = nil soundtracksRemoved = nil
catchedErrors = nil catchedErrors = nil
brokenSongs = nil brokenSongs = nil
gamesBeforeSync, err = repo.FindAllSoundtracks(BackendCtx()) soundtracksBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
handleError("FindAllSoundtracks Before", err, "") handleError("FindAllSoundtracks Before", err, "")
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync))) logging.GetLogger().Info("Starting sync", zap.Int("soundtracks_before", len(soundtracksBeforeSync)))
allGames, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx()) allSoundtracks, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx())
handleError("GetAllSoundtracksIncludingDeleted", err, "") handleError("GetAllSoundtracksIncludingDeleted", err, "")
err = repo.SetSoundtrackDeletionDate(BackendCtx()) err = repo.SetSoundtrackDeletionDate(BackendCtx())
handleError("SetSoundtrackDeletionDate", err, "") handleError("SetSoundtrackDeletionDate", err, "")
@@ -233,13 +236,13 @@ func syncGamesNew(full bool) {
for _, dir := range directories { for _, dir := range directories {
pool.Submit(func() { pool.Submit(func() {
defer syncWg.Done() defer syncWg.Done()
syncGameNew(dir, foldersToSkip, musicPath, full) syncSoundtrack(dir, foldersToSkip, musicPath, full)
}) })
} }
syncWg.Wait() syncWg.Wait()
checkBrokenSongsNew() checkBrokenSongs()
gamesAfterSync, err = repo.FindAllSoundtracks(BackendCtx()) soundtracksAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
handleError("FindAllSoundtracks After", err, "") handleError("FindAllSoundtracks After", err, "")
finished := time.Now() finished := time.Now()
@@ -250,7 +253,7 @@ func syncGamesNew(full bool) {
Syncing = false Syncing = false
} }
func checkBrokenSongsNew() { func checkBrokenSongs() {
allSongs, err := repo.FetchAllSongs(BackendCtx()) allSongs, err := repo.FetchAllSongs(BackendCtx())
handleError("FetchAllSongs", err, "") handleError("FetchAllSongs", err, "")
var brokenWg sync.WaitGroup var brokenWg sync.WaitGroup
@@ -261,7 +264,7 @@ func checkBrokenSongsNew() {
for _, song := range allSongs { for _, song := range allSongs {
poolBroken.Submit(func() { poolBroken.Submit(func() {
defer brokenWg.Done() defer brokenWg.Done()
checkBrokenSongNew(song) checkBrokenSong(song)
}) })
} }
brokenWg.Wait() brokenWg.Wait()
@@ -271,7 +274,7 @@ func checkBrokenSongsNew() {
} }
} }
func checkBrokenSongNew(song repository.Song) { func checkBrokenSong(song repository.Song) {
//Check if file exists and open //Check if file exists and open
openFile, err := os.Open(song.Path) openFile, err := os.Open(song.Path)
if err != nil { if err != nil {
@@ -286,118 +289,93 @@ func checkBrokenSongNew(song repository.Song) {
} }
} }
func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) { func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) {
if file.IsDir() && !contains(foldersToSkip, file.Name()) { if file.IsDir() && !contains(foldersToSkip, file.Name()) {
logging.GetLogger().Debug("Syncing game", zap.String("game", file.Name())) logging.GetLogger().Debug("Syncing soundtrack", zap.String("soundtrack", file.Name()))
gameDir := baseDir + file.Name() + "/" soundtrackDir := baseDir + file.Name() + "/"
dirHash := getHashForDir(gameDir) dirHash := getHashForDir(soundtrackDir)
var status GameStatus = NewGame var status SoundtrackStatus = NewSoundtrack
var oldGame repository.Soundtrack var oldSoundtrack repository.Soundtrack
var id int32 = -1 var id int32 = -1
var soundtrackUuid uuid.UUID
//fmt.Printf("Games before: %d\n", len(gamesBeforeSync)) //fmt.Printf("Soundtracks before: %d\n", len(soundtracksBeforeSync))
for _, currentGame := range allGames { for _, currentSoundtrack := range allSoundtracks {
oldGame = currentGame oldSoundtrack = currentSoundtrack
//fmt.Printf("%s | %s\n", oldGame.SoundtrackName, oldGame.Hash) //fmt.Printf("%s | %s\n", oldSoundtrack.SoundtrackName, oldSoundtrack.Hash)
if oldGame.SoundtrackName == file.Name() && oldGame.Hash == dirHash { if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash == dirHash {
status = NotChanged status = NotChanged
id = oldGame.ID id = oldSoundtrack.ID
//fmt.Printf("Game not changed\n") soundtrackUuid = oldSoundtrack.Uuid
//fmt.Printf("Soundtrack not changed\n")
break break
} else if oldGame.SoundtrackName == file.Name() && oldGame.Hash != dirHash { } else if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash != dirHash {
status = GameChanged status = SoundtrackChanged
id = oldGame.ID id = oldSoundtrack.ID
//fmt.Printf("Game changed\n") soundtrackUuid = oldSoundtrack.Uuid
//fmt.Printf("Soundtrack changed\n")
break break
} else if oldGame.SoundtrackName != file.Name() && oldGame.Hash == dirHash { } else if oldSoundtrack.SoundtrackName != file.Name() && oldSoundtrack.Hash == dirHash {
status = TitleChanged status = TitleChanged
id = oldGame.ID id = oldSoundtrack.ID
soundtrackUuid = oldSoundtrack.Uuid
//fmt.Printf("SoundtrackName changed\n") //fmt.Printf("SoundtrackName changed\n")
break break
} }
} }
if full && status != NewGame { if full && status != NewSoundtrack {
status = TitleChanged status = TitleChanged
} }
entries, err := os.ReadDir(gameDir) entries, err := os.ReadDir(soundtrackDir)
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to read game directory", zap.String("path", gameDir), zap.String("error", err.Error())) logging.GetLogger().Error("Failed to read soundtrack directory", zap.String("path", soundtrackDir), zap.String("error", err.Error()))
} }
switch status { switch status {
case NewGame: case NewSoundtrack:
if id != -1 { id, soundtrackUuid = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
for _, entry := range entries { logging.GetLogger().Debug("New soundtrack detected",
fileInfo, err := entry.Info()
if err != nil {
logging.GetLogger().Error("Failed to get file info", zap.String("error", err.Error()))
continue
}
id = getIdFromFileNew(fileInfo)
if id != -1 {
break
}
}
err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, SoundtrackName: file.Name(), Path: gameDir, Hash: dirHash})
handleError("InsertSoundtrackWithExistingId", err, "")
if err != nil {
logging.GetLogger().Debug("Game already exists, removing old ID file",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("game_dir", gameDir)) zap.String("uuid", soundtrackUuid.String()),
fileName := gameDir + "/." + strconv.Itoa(int(id)) + ".id" zap.String("soundtrack", file.Name()),
logging.GetLogger().Debug("Removing ID file", zap.String("filename", fileName))
err := os.Remove(fileName)
if err != nil {
logging.GetLogger().Error("Failed to remove ID file", zap.String("filename", fileName), zap.String("error", err.Error()))
}
newDirHash := getHashForDir(gameDir)
id = insertGameNew(file.Name(), gameDir, newDirHash)
}
} else {
id = insertGameNew(file.Name(), gameDir, dirHash)
}
logging.GetLogger().Debug("New game detected",
zap.Int32("id", id),
zap.String("game", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
gamesAdded = append(gamesAdded, file.Name()) soundtracksAdded = append(soundtracksAdded, file.Name())
newCheckSongs(entries, gameDir, id) checkSongs(entries, soundtrackDir, id, soundtrackUuid)
case GameChanged: case SoundtrackChanged:
logging.GetLogger().Debug("Game changed", logging.GetLogger().Debug("Soundtrack changed",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("game", file.Name()), zap.String("uuid", soundtrackUuid.String()),
zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id}) err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
handleError("UpdateSoundtrackHash", err, "") handleError("UpdateSoundtrackHash", err, "")
gamesChangedContent = append(gamesChangedContent, file.Name()) soundtracksChangedContent = append(soundtracksChangedContent, file.Name())
newCheckSongs(entries, gameDir, id) checkSongs(entries, soundtrackDir, id, soundtrackUuid)
case TitleChanged: case TitleChanged:
logging.GetLogger().Debug("Game title changed", logging.GetLogger().Debug("Soundtrack title changed",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("oldName", oldGame.SoundtrackName), zap.String("uuid", soundtrackUuid.String()),
zap.String("oldName", oldSoundtrack.SoundtrackName),
zap.String("newName", file.Name()), zap.String("newName", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: gameDir, ID: id}) err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id})
handleError("UpdateSoundtrackName", err, "") handleError("UpdateSoundtrackName", err, "")
newCheckSongs(entries, gameDir, id) checkSongs(entries, soundtrackDir, id, soundtrackUuid)
if gamesChangedTitle == nil { if soundtracksChangedTitle == nil {
gamesChangedTitle = make(map[string]string) soundtracksChangedTitle = make(map[string]string)
} }
gamesChangedTitle[oldGame.SoundtrackName] = file.Name() soundtracksChangedTitle[oldSoundtrack.SoundtrackName] = file.Name()
case NotChanged: case NotChanged:
var found bool = false var found bool = false
for _, beforeGame := range gamesBeforeSync { for _, beforeSoundtrack := range soundtracksBeforeSync {
if dirHash == beforeGame.Hash { if dirHash == beforeSoundtrack.Hash {
found = true found = true
logging.GetLogger().Debug("Game not changed", logging.GetLogger().Debug("Soundtrack not changed",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("newName", file.Name()), zap.String("newName", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
@@ -405,23 +383,33 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
} }
} }
if !found { if !found {
newCheckSongs(entries, gameDir, id) checkSongs(entries, soundtrackDir, id, soundtrackUuid)
gamesReAdded = append(gamesReAdded, file.Name()) soundtracksReAdded = append(soundtracksReAdded, file.Name())
logging.GetLogger().Debug("Game added again", logging.GetLogger().Debug("Soundtrack added again",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("uuid", soundtrackUuid.String()),
zap.String("newName", file.Name()), zap.String("newName", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
} }
} }
logging.GetLogger().Debug("Game sync status", logging.GetLogger().Debug("Soundtrack sync status",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("game", file.Name()), zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id) err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
handleError("RemoveSoundtrackDeletionDate", err, "") 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++ foldersSynced++
logging.GetLogger().Debug("Sync progress", logging.GetLogger().Debug("Sync progress",
@@ -430,24 +418,31 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100))) zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100)))
} }
func insertGameNew(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") 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, "") handleError("InsertSoundtrack", err, "")
if err != nil { 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()) { if strings.HasPrefix(err.Error(), duplicateError.Error()) {
logging.GetLogger().Debug("Resetting game ID sequence") logging.GetLogger().Debug("Resetting soundtrack ID sequence")
_, err = repo.ResetSoundtrackIdSeq(BackendCtx()) _, err = repo.ResetSoundtrackIdSeq(BackendCtx())
handleError("ResetSoundtrackIdSeq", err, "") handleError("ResetSoundtrackIdSeq", err, "")
id = insertGameNew(name, path, hash) id, uuid := insertSoundtrack(name, path, hash)
return id, uuid
} }
} }
return id return result.ID, result.Uuid
} }
func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 { func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) int32 {
//hasher := md5.New() //hasher := md5.New()
var numberOfSongs int32 var numberOfSongs int32
numberOfFiles := len(entries) numberOfFiles := len(entries)
@@ -457,7 +452,7 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
for _, entry := range entries { for _, entry := range entries {
poolSong.Submit(func() { poolSong.Submit(func() {
defer songWg.Done() defer songWg.Done()
if newCheckSong(entry, gameDir, id) { if checkSong(entry, soundtrackDir, id, soundtrackUuid) {
numberOfSongs++ numberOfSongs++
} }
}) })
@@ -466,7 +461,7 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
return numberOfSongs return numberOfSongs
} }
func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool { func checkSong(entry os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) bool {
fileInfo, err := entry.Info() fileInfo, err := entry.Info()
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
@@ -474,7 +469,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
} }
if isSong(fileInfo) { if isSong(fileInfo) {
path := gameDir + entry.Name() path := soundtrackDir + entry.Name()
songHash := getHashForFile(path) songHash := getHashForFile(path)
//numberOfSongs++ //numberOfSongs++
@@ -490,7 +485,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
} }
} }
logging.GetLogger().Debug("Song changed", logging.GetLogger().Debug("Song changed",
zap.Int32("game_id", id), zap.Int32("soundtrack_id", id),
zap.String("path", path), zap.String("path", path),
zap.String("song_name", songName), zap.String("song_name", songName),
zap.String("song_hash", songHash)) zap.String("song_hash", songHash))
@@ -519,8 +514,17 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path}) 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)) handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
} else { } else {
err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash}) songUuid := uuid.New()
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) err = repo.AddSong(BackendCtx(), repository.AddSongParams{
SoundtrackID: id,
SongName: songName,
Path: path,
FileName: &fileName,
Hash: songHash,
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))
} }
} }
@@ -548,8 +552,8 @@ func handleError(funcName string, err error, msg string) {
} }
} }
func getHashForDir(gameDir string) string { func getHashForDir(soundtrackDir string) string {
directory, _ := directory_checksum.ScanDirectory(gameDir, afero.NewOsFs()) directory, _ := directory_checksum.ScanDirectory(soundtrackDir, afero.NewOsFs())
hash, _ := directory.ComputeDirectoryChecksums() hash, _ := directory.ComputeDirectoryChecksums()
return hash return hash
@@ -570,7 +574,7 @@ func getHashForFile(path string) string {
return hex.EncodeToString(hasher.Sum(nil)) return hex.EncodeToString(hasher.Sum(nil))
} }
func getIdFromFileNew(file os.FileInfo) int32 { func getIdFromFile(file os.FileInfo) int32 {
name := file.Name() name := file.Name()
if !file.IsDir() && strings.HasSuffix(name, ".id") { if !file.IsDir() && strings.HasSuffix(name, ".id") {
name = strings.Replace(name, ".id", "", 1) name = strings.Replace(name, ".id", "", 1)
@@ -590,6 +594,33 @@ func isCoverImage(entry fs.FileInfo) bool {
(strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".png")) (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 { func contains(s []string, searchTerm string) bool {
i := sort.SearchStrings(s, searchTerm) i := sort.SearchStrings(s, searchTerm)
return i < len(s) && s[i] == searchTerm return i < len(s) && s[i] == searchTerm
+2 -2
View File
@@ -155,9 +155,9 @@ func TestGetIdFromFileNew(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := getIdFromFileNew(tt.fileInfo) result := getIdFromFile(tt.fileInfo)
if result != tt.expected { if result != tt.expected {
t.Errorf("getIdFromFileNew() = %v, want %v", result, tt.expected) t.Errorf("getIdFromFile() = %v, want %v", result, tt.expected)
} }
}) })
} }
+5 -1
View File
@@ -7,8 +7,10 @@ type VersionData struct {
var data = []VersionData{ var data = []VersionData{
{ {
Version: "5.0.0-Beta", Version: "5.0.0",
Changelog: []string{ Changelog: []string{
"#9 - Major routes refactor and expansion",
"#15 - Added API for statistics",
"#16 - Upgrade Echo framework from v4 to v5", "#16 - Upgrade Echo framework from v4 to v5",
"#17 - Add Zap structured logging framework", "#17 - Add Zap structured logging framework",
"#18 - Add OpenAPI/Swagger documentation", "#18 - Add OpenAPI/Swagger documentation",
@@ -17,6 +19,8 @@ var data = []VersionData{
"#21 - Refactor handlers into domain-specific files", "#21 - Refactor handlers into domain-specific files",
"#22 - Change VersionData Changelog from string to string array", "#22 - Change VersionData Changelog from string to string array",
"#23 - Update all dependencies to latest versions", "#23 - Update all dependencies to latest versions",
"Added search cache backend",
"Backend sync improvements",
}, },
}, },
{ {
+3 -3
View File
@@ -56,10 +56,10 @@ func CloseDb() {
Dbpool.Close() Dbpool.Close()
} }
func ResetGameIdSeq() { func ResetSoundtrackIdSeq() {
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);") _, err := Dbpool.Query(Ctx, "SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);")
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to reset game ID sequence", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to reset soundtrack ID sequence", zap.String("error", err.Error()))
} }
} }
@@ -0,0 +1,64 @@
-- Rollback: Remove UUID columns and revert rename
-- Step 1: Drop UUID-related indexes
DROP INDEX IF EXISTS idx_song_uuid;
DROP INDEX IF EXISTS idx_song_soundtrack_uuid;
DROP INDEX IF EXISTS idx_soundtrack_uuid;
DROP INDEX IF EXISTS idx_song_soundtrack_id;
DROP INDEX IF EXISTS idx_song_path;
-- Step 1.5: Remove NOT NULL constraints before dropping columns (Issue #28 rollback)
ALTER TABLE song ALTER COLUMN soundtrack_uuid DROP NOT NULL;
ALTER TABLE song ALTER COLUMN uuid DROP NOT NULL;
ALTER TABLE soundtrack ALTER COLUMN uuid DROP NOT NULL;
-- Step 2: Drop UUID-related constraints
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_path_unique;
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_uuid_fkey;
ALTER TABLE soundtrack DROP CONSTRAINT IF EXISTS soundtrack_uuid_unique;
-- Step 3: Drop UUID columns from song table
ALTER TABLE song DROP COLUMN IF EXISTS soundtrack_uuid;
ALTER TABLE song DROP COLUMN IF EXISTS uuid;
-- Step 4: Drop UUID column from soundtrack table
ALTER TABLE soundtrack DROP COLUMN IF EXISTS uuid;
-- Step 5: Drop the uuid-based primary key
ALTER TABLE song DROP CONSTRAINT song_pkey;
-- Step 6: Restore composite primary key on song
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (soundtrack_id, path);
-- Step 7: Drop foreign key constraint
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
-- Step 8: Revert rename operations
-- Revert song_list table references
ALTER TABLE song_list 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 columns in game table
ALTER TABLE game RENAME COLUMN soundtrack_name TO game_name;
-- Revert primary key sequence
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
-- Revert: Rename soundtrack table back to game
ALTER TABLE soundtrack RENAME TO game;
-- Revert foreign key constraint
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_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;
@@ -0,0 +1,99 @@
-- Migration: Rename game to soundtrack AND add UUID columns
-- Combines original migrations 000005 (rename), 000006 (UUID columns), and Issue #28 (Phase 2: NOT NULL constraints)
-- Phase 1 & 2: Dual-write with required UUIDs (integer IDs still exist alongside UUIDs)
-- ============ PART 1: RENAME (from original migration 000005) ============
-- 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_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;
-- ============ PART 2: UUID COLUMNS (from original migration 000006) ============
-- Step 0: Enable uuid-ossp extension for UUID generation
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Add UUID column to soundtrack table
ALTER TABLE soundtrack ADD COLUMN uuid UUID DEFAULT gen_random_uuid();
-- Add unique constraint on soundtrack.uuid
ALTER TABLE soundtrack ADD CONSTRAINT soundtrack_uuid_unique UNIQUE (uuid);
-- Backfill existing soundtracks with UUIDs (should already have values from DEFAULT)
UPDATE soundtrack SET uuid = gen_random_uuid() WHERE uuid IS NULL;
-- Add index on soundtrack.uuid for query performance
CREATE INDEX IF NOT EXISTS idx_soundtrack_uuid ON soundtrack(uuid);
-- Add UUID columns to song table
ALTER TABLE song ADD COLUMN uuid UUID DEFAULT gen_random_uuid();
ALTER TABLE song ADD COLUMN soundtrack_uuid UUID;
-- Backfill existing songs with UUIDs
UPDATE song SET uuid = gen_random_uuid() WHERE uuid IS NULL;
-- Backfill song.soundtrack_uuid from soundtrack.uuid via JOIN
UPDATE song s
SET soundtrack_uuid = st.uuid
FROM soundtrack st
WHERE s.soundtrack_id = st.id AND s.soundtrack_uuid IS NULL;
-- Add foreign key constraint from song.soundtrack_uuid to soundtrack.uuid
ALTER TABLE song ADD CONSTRAINT song_soundtrack_uuid_fkey
FOREIGN KEY (soundtrack_uuid) REFERENCES soundtrack(uuid);
-- Add indexes on song UUID columns
CREATE INDEX IF NOT EXISTS idx_song_uuid ON song(uuid);
CREATE INDEX IF NOT EXISTS idx_song_soundtrack_uuid ON song(soundtrack_uuid);
-- Drop the composite primary key (soundtrack_id, path)
ALTER TABLE song DROP CONSTRAINT song_pkey;
-- Add new primary key on uuid column
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (uuid);
-- Add unique constraint to maintain same semantics as old PK
-- This ensures (soundtrack, path) combinations remain unique
ALTER TABLE song ADD CONSTRAINT song_soundtrack_path_unique UNIQUE (soundtrack_uuid, path);
-- Create index on soundtrack_id for query performance
CREATE INDEX IF NOT EXISTS idx_song_soundtrack_id ON song(soundtrack_id);
-- Create index on path for lookups
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
-- ============ PART 3: MAKE UUID COLUMNS NOT NULL (Issue #28 - Phase 2) ============
-- Add NOT NULL constraint to soundtrack.uuid
ALTER TABLE soundtrack ALTER COLUMN uuid SET NOT NULL;
-- Add NOT NULL constraint to song.uuid
ALTER TABLE song ALTER COLUMN uuid SET NOT NULL;
-- Add NOT NULL constraint to song.soundtrack_uuid
ALTER TABLE song ALTER COLUMN soundtrack_uuid SET NOT 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;
@@ -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;
@@ -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);
@@ -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);
+12 -4
View File
@@ -5,7 +5,7 @@ DELETE FROM song;
DELETE FROM song WHERE soundtrack_id = $1; DELETE FROM song WHERE soundtrack_id = $1;
-- name: AddSong :exec -- 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 -- name: CheckSong :one
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2; 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 -- name: FetchAllSongs :many
SELECT * FROM song; SELECT * FROM song;
-- name: GetSongById :one -- name: GetSongByUUID :one
SELECT * FROM song WHERE id = $1; SELECT * FROM song WHERE uuid = $1;
-- name: RemoveBrokenSong :exec -- name: RemoveBrokenSong :exec
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2; DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
-- name: RemoveBrokenSongs :exec -- 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;
+10 -1
View File
@@ -29,7 +29,7 @@ UPDATE soundtrack SET deleted=NULL WHERE id=$1;
SELECT id FROM soundtrack WHERE soundtrack_name = $1; SELECT id FROM soundtrack WHERE soundtrack_name = $1;
-- name: InsertSoundtrack :one -- 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 -- name: InsertSoundtrackWithExistingId :exec
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now()); 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 -- name: AddSoundtrackPlayed :exec
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1; 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;
+8 -8
View File
@@ -1,5 +1,5 @@
-- Most played soundtracks with their songs -- Most played soundtracks with their songs
-- name: GetMostPlayedGamesWithSongs :many -- name: GetMostPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -21,7 +21,7 @@ ORDER BY g.times_played DESC, g.soundtrack_name
LIMIT $1; LIMIT $1;
-- Least played soundtracks with their songs -- Least played soundtracks with their songs
-- name: GetLeastPlayedGamesWithSongs :many -- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -43,7 +43,7 @@ ORDER BY g.times_played ASC, g.soundtrack_name
LIMIT $1; LIMIT $1;
-- Most played songs with their soundtrack info -- Most played songs with their soundtrack info
-- name: GetMostPlayedSongsWithGame :many -- name: GetMostPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -58,7 +58,7 @@ ORDER BY s.times_played DESC, s.song_name
LIMIT $1; LIMIT $1;
-- Least played songs with their soundtrack info -- Least played songs with their soundtrack info
-- name: GetLeastPlayedSongsWithGame :many -- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -72,8 +72,8 @@ WHERE g.deleted IS NULL
ORDER BY s.times_played ASC, s.song_name ORDER BY s.times_played ASC, s.song_name
LIMIT $1; LIMIT $1;
-- Games that have never been played (times_played = 0) -- Soundtracks that have never been played (times_played = 0)
-- name: GetNeverPlayedGames :many -- name: GetNeverPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -93,7 +93,7 @@ GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
ORDER BY g.soundtrack_name; ORDER BY g.soundtrack_name;
-- Last played soundtracks (most recently played) -- Last played soundtracks (most recently played)
-- name: GetLastPlayedGames :many -- name: GetLastPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -114,7 +114,7 @@ ORDER BY g.last_played DESC
LIMIT $1; LIMIT $1;
-- Oldest played soundtracks (least recently played, but has been played at least once) -- Oldest played soundtracks (least recently played, but has been played at least once)
-- name: GetOldestPlayedGames :many -- name: GetOldestPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
+4 -1
View File
@@ -7,6 +7,7 @@ package repository
import ( import (
"time" "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
@@ -26,7 +27,8 @@ type Song struct {
TimesPlayed int32 `json:"times_played"` TimesPlayed int32 `json:"times_played"`
Hash string `json:"hash"` Hash string `json:"hash"`
FileName *string `json:"file_name"` FileName *string `json:"file_name"`
ID pgtype.Int4 `json:"id"` Uuid uuid.UUID `json:"uuid"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
} }
type SongList struct { type SongList struct {
@@ -48,6 +50,7 @@ type Soundtrack struct {
LastPlayed *time.Time `json:"last_played"` LastPlayed *time.Time `json:"last_played"`
NumberOfSongs int32 `json:"number_of_songs"` NumberOfSongs int32 `json:"number_of_songs"`
Hash string `json:"hash"` Hash string `json:"hash"`
Uuid uuid.UUID `json:"uuid"`
} }
type Vgmq struct { type Vgmq struct {
+88 -16
View File
@@ -8,7 +8,7 @@ package repository
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype" "github.com/google/uuid"
) )
const addHashToSong = `-- name: AddHashToSong :exec const addHashToSong = `-- name: AddHashToSong :exec
@@ -27,7 +27,7 @@ func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) er
} }
const addSong = `-- name: AddSong :exec 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 { type AddSongParams struct {
@@ -36,6 +36,8 @@ type AddSongParams struct {
Path string `json:"path"` Path string `json:"path"`
FileName *string `json:"file_name"` FileName *string `json:"file_name"`
Hash string `json:"hash"` 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 { 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.Path,
arg.FileName, arg.FileName,
arg.Hash, arg.Hash,
arg.Uuid,
arg.SoundtrackUuid,
) )
return err return err
} }
@@ -110,7 +114,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int
} }
const fetchAllSongs = `-- name: FetchAllSongs :many 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) { 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.TimesPlayed,
&i.Hash, &i.Hash,
&i.FileName, &i.FileName,
&i.ID, &i.Uuid,
&i.SoundtrackUuid,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -142,7 +147,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
} }
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many 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 FROM song
WHERE soundtrack_id = $1 WHERE soundtrack_id = $1
` `
@@ -163,7 +168,8 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
&i.TimesPlayed, &i.TimesPlayed,
&i.Hash, &i.Hash,
&i.FileName, &i.FileName,
&i.ID, &i.Uuid,
&i.SoundtrackUuid,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -175,12 +181,12 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
return items, nil return items, nil
} }
const getSongById = `-- name: GetSongById :one const getSongByUUID = `-- name: GetSongByUUID :one
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE id = $1 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) { func (q *Queries) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) {
row := q.db.QueryRow(ctx, getSongById, id) row := q.db.QueryRow(ctx, getSongByUUID, argUuid)
var i Song var i Song
err := row.Scan( err := row.Scan(
&i.SoundtrackID, &i.SoundtrackID,
@@ -189,13 +195,43 @@ func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error)
&i.TimesPlayed, &i.TimesPlayed,
&i.Hash, &i.Hash,
&i.FileName, &i.FileName,
&i.ID, &i.Uuid,
&i.SoundtrackUuid,
) )
return i, err 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 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) { 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.TimesPlayed,
&i.Hash, &i.Hash,
&i.FileName, &i.FileName,
&i.ID, &i.Uuid,
&i.SoundtrackUuid,
) )
return i, err 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 const removeBrokenSong = `-- name: RemoveBrokenSong :exec
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2 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 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 { func (q *Queries) RemoveBrokenSongs(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, removeBrokenSongs, id) _, err := q.db.Exec(ctx, removeBrokenSongs, argUuid)
return err return err
} }
+62 -9
View File
@@ -7,6 +7,8 @@ package repository
import ( import (
"context" "context"
"github.com/google/uuid"
) )
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
@@ -28,7 +30,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
} }
const findAllSoundtracks = `-- name: FindAllSoundtracks :many 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 FROM soundtrack
WHERE deleted IS NULL WHERE deleted IS NULL
ORDER BY soundtrack_name ORDER BY soundtrack_name
@@ -54,6 +56,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.LastPlayed, &i.LastPlayed,
&i.NumberOfSongs, &i.NumberOfSongs,
&i.Hash, &i.Hash,
&i.Uuid,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -66,7 +69,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
} }
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many 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 FROM soundtrack
ORDER BY soundtrack_name ORDER BY soundtrack_name
` `
@@ -91,6 +94,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.LastPlayed, &i.LastPlayed,
&i.NumberOfSongs, &i.NumberOfSongs,
&i.Hash, &i.Hash,
&i.Uuid,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -114,7 +118,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
} }
const getSoundtrackById = `-- name: GetSoundtrackById :one 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 FROM soundtrack
WHERE id = $1 WHERE id = $1
AND deleted IS NULL AND deleted IS NULL
@@ -134,6 +138,33 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.LastPlayed, &i.LastPlayed,
&i.NumberOfSongs, &i.NumberOfSongs,
&i.Hash, &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 return i, err
} }
@@ -149,21 +180,43 @@ func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string,
return soundtrack_name, err 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 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 { type InsertSoundtrackParams struct {
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
Path string `json:"path"` Path string `json:"path"`
Hash string `json:"hash"` Hash string `json:"hash"`
Uuid uuid.UUID `json:"uuid"`
} }
func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) { type InsertSoundtrackRow struct {
row := q.db.QueryRow(ctx, insertSoundtrack, arg.SoundtrackName, arg.Path, arg.Hash) ID int32 `json:"id"`
var id int32 Uuid uuid.UUID `json:"uuid"`
err := row.Scan(&id) }
return id, err
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 const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec
+91 -91
View File
@@ -10,7 +10,7 @@ import (
"time" "time"
) )
const getLastPlayedGames = `-- name: GetLastPlayedGames :many const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -31,7 +31,7 @@ ORDER BY g.last_played DESC
LIMIT $1 LIMIT $1
` `
type GetLastPlayedGamesRow struct { type GetLastPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -40,15 +40,15 @@ type GetLastPlayedGamesRow struct {
} }
// Last played soundtracks (most recently played) // Last played soundtracks (most recently played)
func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLastPlayedGamesRow, error) { func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getLastPlayedGames, limit) rows, err := q.db.Query(ctx, getLastPlayedSoundtracks, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetLastPlayedGamesRow var items []GetLastPlayedSoundtracksRow
for rows.Next() { for rows.Next() {
var i GetLastPlayedGamesRow var i GetLastPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -66,7 +66,59 @@ func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLas
return items, nil return items, nil
} }
const getLeastPlayedGamesWithSongs = `-- name: GetLeastPlayedGamesWithSongs :many const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.soundtrack_name,
s.song_name,
s.path,
s.times_played,
s.file_name
FROM song s
JOIN soundtrack g ON s.soundtrack_id = g.id
WHERE g.deleted IS NULL
ORDER BY s.times_played ASC, s.song_name
LIMIT $1
`
type GetLeastPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name"`
}
// Least played songs with their soundtrack info
func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithSoundtrackRow, error) {
rows, err := q.db.Query(ctx, getLeastPlayedSongsWithSoundtrack, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetLeastPlayedSongsWithSoundtrackRow
for rows.Next() {
var i GetLeastPlayedSongsWithSoundtrackRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
&i.SongName,
&i.Path,
&i.TimesPlayed,
&i.FileName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -88,7 +140,7 @@ ORDER BY g.times_played ASC, g.soundtrack_name
LIMIT $1 LIMIT $1
` `
type GetLeastPlayedGamesWithSongsRow struct { type GetLeastPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -97,15 +149,15 @@ type GetLeastPlayedGamesWithSongsRow struct {
} }
// Least played soundtracks with their songs // Least played soundtracks with their songs
func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedGamesWithSongsRow, error) { func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) {
rows, err := q.db.Query(ctx, getLeastPlayedGamesWithSongs, limit) rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetLeastPlayedGamesWithSongsRow var items []GetLeastPlayedSoundtracksWithSongsRow
for rows.Next() { for rows.Next() {
var i GetLeastPlayedGamesWithSongsRow var i GetLeastPlayedSoundtracksWithSongsRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -123,7 +175,7 @@ func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32)
return items, nil return items, nil
} }
const getLeastPlayedSongsWithGame = `-- name: GetLeastPlayedSongsWithGame :many const getMostPlayedSongsWithSoundtrack = `-- name: GetMostPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -134,11 +186,11 @@ SELECT
FROM song s FROM song s
JOIN soundtrack g ON s.soundtrack_id = g.id JOIN soundtrack g ON s.soundtrack_id = g.id
WHERE g.deleted IS NULL WHERE g.deleted IS NULL
ORDER BY s.times_played ASC, s.song_name ORDER BY s.times_played DESC, s.song_name
LIMIT $1 LIMIT $1
` `
type GetLeastPlayedSongsWithGameRow struct { type GetMostPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"` SongName string `json:"song_name"`
@@ -147,16 +199,16 @@ type GetLeastPlayedSongsWithGameRow struct {
FileName *string `json:"file_name"` FileName *string `json:"file_name"`
} }
// Least played songs with their soundtrack info // Most played songs with their soundtrack info
func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithGameRow, error) { func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithSoundtrackRow, error) {
rows, err := q.db.Query(ctx, getLeastPlayedSongsWithGame, limit) rows, err := q.db.Query(ctx, getMostPlayedSongsWithSoundtrack, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetLeastPlayedSongsWithGameRow var items []GetMostPlayedSongsWithSoundtrackRow
for rows.Next() { for rows.Next() {
var i GetLeastPlayedSongsWithGameRow var i GetMostPlayedSongsWithSoundtrackRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -175,7 +227,7 @@ func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32)
return items, nil return items, nil
} }
const getMostPlayedGamesWithSongs = `-- name: GetMostPlayedGamesWithSongs :many const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -197,7 +249,7 @@ ORDER BY g.times_played DESC, g.soundtrack_name
LIMIT $1 LIMIT $1
` `
type GetMostPlayedGamesWithSongsRow struct { type GetMostPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -206,15 +258,15 @@ type GetMostPlayedGamesWithSongsRow struct {
} }
// Most played soundtracks with their songs // Most played soundtracks with their songs
func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedGamesWithSongsRow, error) { func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) {
rows, err := q.db.Query(ctx, getMostPlayedGamesWithSongs, limit) rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetMostPlayedGamesWithSongsRow var items []GetMostPlayedSoundtracksWithSongsRow
for rows.Next() { for rows.Next() {
var i GetMostPlayedGamesWithSongsRow var i GetMostPlayedSoundtracksWithSongsRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -232,59 +284,7 @@ func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32)
return items, nil return items, nil
} }
const getMostPlayedSongsWithGame = `-- name: GetMostPlayedSongsWithGame :many const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
SELECT
s.soundtrack_id as soundtrack_id,
g.soundtrack_name,
s.song_name,
s.path,
s.times_played,
s.file_name
FROM song s
JOIN soundtrack g ON s.soundtrack_id = g.id
WHERE g.deleted IS NULL
ORDER BY s.times_played DESC, s.song_name
LIMIT $1
`
type GetMostPlayedSongsWithGameRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name"`
}
// Most played songs with their soundtrack info
func (q *Queries) GetMostPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithGameRow, error) {
rows, err := q.db.Query(ctx, getMostPlayedSongsWithGame, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetMostPlayedSongsWithGameRow
for rows.Next() {
var i GetMostPlayedSongsWithGameRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
&i.SongName,
&i.Path,
&i.TimesPlayed,
&i.FileName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getNeverPlayedGames = `-- name: GetNeverPlayedGames :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -304,7 +304,7 @@ GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
ORDER BY g.soundtrack_name ORDER BY g.soundtrack_name
` `
type GetNeverPlayedGamesRow struct { type GetNeverPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -312,16 +312,16 @@ type GetNeverPlayedGamesRow struct {
Songs []byte `json:"songs"` Songs []byte `json:"songs"`
} }
// Games that have never been played (times_played = 0) // Soundtracks that have never been played (times_played = 0)
func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGamesRow, error) { func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getNeverPlayedGames) rows, err := q.db.Query(ctx, getNeverPlayedSoundtracks)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetNeverPlayedGamesRow var items []GetNeverPlayedSoundtracksRow
for rows.Next() { for rows.Next() {
var i GetNeverPlayedGamesRow var i GetNeverPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -339,7 +339,7 @@ func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGame
return items, nil return items, nil
} }
const getOldestPlayedGames = `-- name: GetOldestPlayedGames :many const getOldestPlayedSoundtracks = `-- name: GetOldestPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -360,7 +360,7 @@ ORDER BY g.last_played ASC
LIMIT $1 LIMIT $1
` `
type GetOldestPlayedGamesRow struct { type GetOldestPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -369,15 +369,15 @@ type GetOldestPlayedGamesRow struct {
} }
// Oldest played soundtracks (least recently played, but has been played at least once) // Oldest played soundtracks (least recently played, but has been played at least once)
func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetOldestPlayedGamesRow, error) { func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getOldestPlayedGames, limit) rows, err := q.db.Query(ctx, getOldestPlayedSoundtracks, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetOldestPlayedGamesRow var items []GetOldestPlayedSoundtracksRow
for rows.Next() { for rows.Next() {
var i GetOldestPlayedGamesRow var i GetOldestPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
+99
View File
@@ -0,0 +1,99 @@
package middleware
import (
"net/http"
"sync"
"time"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
// RateLimiter implements a simple IP-based rate limiter
type RateLimiter struct {
visits map[string]*Visit
mu sync.Mutex
limit int // requests per window
window time.Duration // time window
}
type Visit struct {
count int
firstSeen time.Time
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
visits: make(map[string]*Visit),
limit: limit,
window: window,
}
// Start cleanup goroutine
go rl.cleanup()
return rl
}
// cleanup periodically removes old entries
func (rl *RateLimiter) cleanup() {
for {
time.Sleep(rl.window)
rl.mu.Lock()
now := time.Now()
for ip, visit := range rl.visits {
if now.Sub(visit.firstSeen) > rl.window {
delete(rl.visits, ip)
}
}
rl.mu.Unlock()
}
}
// RateLimitMiddleware returns an Echo middleware for rate limiting
func RateLimitMiddleware(rl *RateLimiter) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
ip := c.RealIP()
rl.mu.Lock()
defer rl.mu.Unlock()
visit, exists := rl.visits[ip]
now := time.Now()
if !exists {
// First visit from this IP
rl.visits[ip] = &Visit{
count: 1,
firstSeen: now,
}
return next(c)
}
// Check if window has expired
if now.Sub(visit.firstSeen) > rl.window {
// Reset for new window
visit.count = 1
visit.firstSeen = now
return next(c)
}
// Check if limit exceeded
if visit.count >= rl.limit {
logging.GetLogger().Warn("Rate limit exceeded",
zap.String("ip", ip),
zap.String("path", c.Request().URL.Path))
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "Rate limit exceeded. Please try again later.",
})
}
// Increment count
visit.count++
return next(c)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package middleware
import (
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
"github.com/labstack/echo/v5"
)
// SyncCheckMiddleware blocks requests when syncing is in progress.
// It returns HTTP 423 Locked with a standard message.
// This middleware should be applied to handlers that cannot execute during sync.
func SyncCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress - request blocked")
return c.JSON(http.StatusLocked, "Syncing is in progress")
}
return next(c)
}
}
+10
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@@ -17,6 +18,15 @@ import (
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc { func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error { 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 // Extract token from Authorization header
authHeader := c.Request().Header.Get("Authorization") authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
+2 -54
View File
@@ -31,10 +31,6 @@ func NewMusicHandler() *MusicHandler {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music [get] // @Router /music [get]
func (m *MusicHandler) GetSong(ctx *echo.Context) error { func (m *MusicHandler) GetSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
song := ctx.QueryParam("song") song := ctx.QueryParam("song")
if song == "" { if song == "" {
return ctx.String(http.StatusBadRequest, "song can't be empty") return ctx.String(http.StatusBadRequest, "song can't be empty")
@@ -58,10 +54,6 @@ func (m *MusicHandler) GetSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/soundTest [get] // @Router /music/soundTest [get]
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error { func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetSoundCheckSong() songPath := backend.GetSoundCheckSong()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -80,10 +72,6 @@ func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/reset [get] // @Router /music/reset [get]
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error { func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
backend.Reset() backend.Reset()
return ctx.NoContent(http.StatusOK) return ctx.NoContent(http.StatusOK)
} }
@@ -98,10 +86,6 @@ func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand [get] // @Router /music/rand [get]
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error { func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetRandomSong() songPath := backend.GetRandomSong()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -121,10 +105,6 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/low [get] // @Router /music/rand/low [get]
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error { func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetRandomSongLowChance() songPath := backend.GetRandomSongLowChance()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -144,10 +124,6 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/classic [get] // @Router /music/rand/classic [get]
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error { func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetRandomSongClassic() songPath := backend.GetRandomSongClassic()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -193,10 +169,6 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/next [get] // @Router /music/next [get]
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error { func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetNextSong() songPath := backend.GetNextSong()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -216,10 +188,6 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/previous [get] // @Router /music/previous [get]
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error { func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetPreviousSong() songPath := backend.GetPreviousSong()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -231,7 +199,7 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
// GetAllSoundtracks godoc // GetAllSoundtracks godoc
// @Summary Get all soundtracks // @Summary Get all soundtracks
// @Description Returns a list of all games in order // @Description Returns a list of all soundtracks in order
// @Tags music // @Tags music
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -239,17 +207,13 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/order [get] // @Router /music/all/order [get]
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error { func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
soundtrackList := backend.GetAllSoundtracks() soundtrackList := backend.GetAllSoundtracks()
return ctx.JSON(http.StatusOK, soundtrackList) return ctx.JSON(http.StatusOK, soundtrackList)
} }
// GetAllSoundtracksRandom godoc // GetAllSoundtracksRandom godoc
// @Summary Get all soundtracks random // @Summary Get all soundtracks random
// @Description Returns a list of all games in random order // @Description Returns a list of all soundtracks in random order
// @Tags music // @Tags music
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -257,10 +221,6 @@ func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/random [get] // @Router /music/all/random [get]
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error { func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
soundtrackList := backend.GetAllSoundtracksRandom() soundtrackList := backend.GetAllSoundtracksRandom()
return ctx.JSON(http.StatusOK, soundtrackList) return ctx.JSON(http.StatusOK, soundtrackList)
} }
@@ -277,10 +237,6 @@ func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/played [put] // @Router /music/played [put]
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error { func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
song, err := strconv.Atoi(ctx.QueryParam("song")) song, err := strconv.Atoi(ctx.QueryParam("song"))
if err != nil { if err != nil {
return ctx.JSON(http.StatusBadRequest, err.Error()) return ctx.JSON(http.StatusBadRequest, err.Error())
@@ -299,10 +255,6 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addQue [get] // @Router /music/addQue [get]
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error { func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
backend.AddLatestToQue() backend.AddLatestToQue()
return ctx.NoContent(http.StatusOK) return ctx.NoContent(http.StatusOK)
} }
@@ -316,10 +268,6 @@ func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addPlayed [get] // @Router /music/addPlayed [get]
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error { func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
backend.AddLatestPlayed() backend.AddLatestPlayed()
return ctx.NoContent(http.StatusOK) return ctx.NoContent(http.StatusOK)
} }
+159 -49
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"time"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@@ -50,8 +51,8 @@ func (s *Server) RegisterRoutes() http.Handler {
fileServer := http.FileServer(http.FS(web.Assets)) fileServer := http.FileServer(http.FS(web.Assets))
e.GET("/assets/*", echo.WrapHandler(fileServer)) e.GET("/assets/*", echo.WrapHandler(fileServer))
e.GET("/search", echo.WrapHandler(templ.Handler(web.HelloForm()))) e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindGameWebHandler))) e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
e.Static("/", "/frontend") e.Static("/", "/frontend")
@@ -82,32 +83,32 @@ func (s *Server) RegisterRoutes() http.Handler {
sync := NewSyncHandler() sync := NewSyncHandler()
syncGroup := e.Group("/sync") syncGroup := e.Group("/sync")
syncGroup.GET("", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) syncGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress)) syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
syncGroup.GET("/new", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull)) syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull)) syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetDB)) syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB)))
music := NewMusicHandler() music := NewMusicHandler()
musicGroup := e.Group("/music") musicGroup := e.Group("/music")
musicGroup.GET("", deprecatedMiddleware(music.GetSong)) musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong)))
musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong)) musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong)))
musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic)) musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic)))
musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong)) musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong)))
musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance)) musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance)))
musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic)) musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic)))
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo)) musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs)) musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong)) musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong)))
musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong)) musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong)))
musicGroup.GET("/all", deprecatedMiddleware(music.GetAllSoundtracksRandom)) musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllSoundtracks)) musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks)))
musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllSoundtracksRandom)) musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed)) musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed)))
musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue)) musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue)))
musicGroup.GET("/addPlayed", deprecatedMiddleware(music.AddLatestPlayed)) musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed)))
// ============================================ // ============================================
// API v1 Routes with Token Authentication // API v1 Routes with Token Authentication
@@ -116,53 +117,162 @@ func (s *Server) RegisterRoutes() http.Handler {
// Create /api/v1 group // Create /api/v1 group
apiV1 := e.Group("/api/v1") apiV1 := e.Group("/api/v1")
// Public endpoints - no token required // Create rate limiter for token creation: 10 requests per minute per IP
apiV1.POST("/token", func(c *echo.Context) error { 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) return s.tokenHandler.CreateTokenHandler(c)
}) }, tokenRateLimitMiddleware)
apiV1.DELETE("/token", func(c *echo.Context) error { tokenGroup.DELETE("", func(c *echo.Context) error {
return s.tokenHandler.DeleteTokenHandler(c) 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) return s.tokenHandler.CleanupExpiredSessionsHandler(c)
}) })
// Protected endpoints - require valid token // Protected endpoints - require valid token
// Create token auth middleware with pool access // Create token auth middleware with pool access
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool) tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
// Protected group with token authentication
protectedV1 := apiV1.Group("", tokenAuthMiddleware) protectedV1 := apiV1.Group("", tokenAuthMiddleware)
// Statistics API endpoints (protected by token auth) // Statistics endpoints (protected)
statistics := s.statisticsHandler statisticsHandler := s.statisticsHandler
protectedV1.GET("/statistics/games/most-played", func(c *echo.Context) error { statsGroup := protectedV1.Group("/statistics")
return statistics.GetMostPlayedGames(c) statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/games/least-played", func(c *echo.Context) error { statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
return statistics.GetLeastPlayedGames(c) return statisticsHandler.GetLeastPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/games/never-played", func(c *echo.Context) error { statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
return statistics.GetNeverPlayedGames(c) return statisticsHandler.GetNeverPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/games/last-played", func(c *echo.Context) error { statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
return statistics.GetLastPlayedGames(c) return statisticsHandler.GetLastPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/games/oldest-played", func(c *echo.Context) error { statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
return statistics.GetOldestPlayedGames(c) return statisticsHandler.GetOldestPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error { statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
return statistics.GetMostPlayedSongs(c) return statisticsHandler.GetMostPlayedSongs(c)
}) })
protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error { statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
return statistics.GetLeastPlayedSongs(c) return statisticsHandler.GetLeastPlayedSongs(c)
}) })
protectedV1.GET("/statistics/summary", func(c *echo.Context) error { statsGroup.GET("/summary", func(c *echo.Context) error {
return statistics.GetStatisticsSummary(c) return statisticsHandler.GetStatisticsSummary(c)
}) })
// Future: VGMQ endpoints will be added to protectedV1 group // Version endpoints (protected)
_ = protectedV1 // Use the variable to avoid unused variable error 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() routes := e.Router().Routes()
sort.Slice(routes, func(i, j int) bool { sort.Slice(routes, func(i, j int) bool {
+12 -8
View File
@@ -23,11 +23,6 @@ type Server struct {
} }
var ( 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") musicPath = os.Getenv("MUSIC_PATH")
charactersPath = os.Getenv("CHARACTERS_PATH") charactersPath = os.Getenv("CHARACTERS_PATH")
logLevel = os.Getenv("LOG_LEVEL") logLevel = os.Getenv("LOG_LEVEL")
@@ -37,11 +32,20 @@ var (
// NewServerInstance creates a new Server instance with all dependencies initialized. // NewServerInstance creates a new Server instance with all dependencies initialized.
// Use this for dependency injection and proper lifecycle management. // Use this for dependency injection and proper lifecycle management.
func NewServerInstance() *Server { 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 // Initialize logger
if logLevel == "" { currentLogLevel := logLevel
logLevel = "info" if currentLogLevel == "" {
currentLogLevel = "info"
} }
logging.Init(logLevel, logJSON) logging.Init(currentLogLevel, logJSON)
logger := logging.GetLogger() logger := logging.GetLogger()
+411
View File
@@ -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!")
}
+56 -56
View File
@@ -23,20 +23,20 @@ func NewStatisticsHandler() *StatisticsHandler {
} }
} }
// GetMostPlayedGames returns top N most played games with songs // GetMostPlayedSoundtracks returns top N most played soundtracks with songs
// GET /api/v1/statistics/games/most-played // GET /api/v1/statistics/soundtracks/most-played
// //
// @Summary Get most played games // @Summary Get most played soundtracks
// @Description Returns the top N most played games with their songs // @Description Returns the top N most played soundtracks with their songs
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param limit query int false "Number of results (default: 10)" // @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/most-played [get] // @Router /api/v1/statistics/soundtracks/most-played [get]
func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 // default limit := 10 // default
limitStr := ctx.QueryParam("limit") limitStr := ctx.QueryParam("limit")
if limitStr != "" { if limitStr != "" {
@@ -51,28 +51,28 @@ func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error {
} }
} }
games, err := h.statsBackend.GetMostPlayedGamesWithSongs(int32(limit)) soundtracks, err := h.statsBackend.GetMostPlayedSoundtracksWithSongs(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get most played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get most played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetLeastPlayedGames returns top N least played games with songs // GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
// GET /api/v1/statistics/games/least-played // GET /api/v1/statistics/soundtracks/least-played
// //
// @Summary Get least played games // @Summary Get least played soundtracks
// @Description Returns the top N least played games with their songs // @Description Returns the top N least played soundtracks with their songs
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param limit query int false "Number of results (default: 10)" // @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/least-played [get] // @Router /api/v1/statistics/soundtracks/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 limit := 10
limitStr := ctx.QueryParam("limit") limitStr := ctx.QueryParam("limit")
if limitStr != "" { if limitStr != "" {
@@ -86,19 +86,19 @@ func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error {
} }
} }
games, err := h.statsBackend.GetLeastPlayedGamesWithSongs(int32(limit)) soundtracks, err := h.statsBackend.GetLeastPlayedSoundtracksWithSongs(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get least played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get least played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetMostPlayedSongs returns top N most played songs with game info // GetMostPlayedSongs returns top N most played songs with soundtrack info
// GET /api/v1/statistics/songs/most-played // GET /api/v1/statistics/songs/most-played
// //
// @Summary Get most played songs // @Summary Get most played songs
// @Description Returns the top N most played songs with their game info // @Description Returns the top N most played songs with their soundtrack info
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -121,7 +121,7 @@ func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
} }
} }
songs, err := h.statsBackend.GetMostPlayedSongsWithGame(int32(limit)) songs, err := h.statsBackend.GetMostPlayedSongsWithSoundtrack(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
@@ -129,11 +129,11 @@ func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, songs) return ctx.JSON(http.StatusOK, songs)
} }
// GetLeastPlayedSongs returns top N least played songs with game info // GetLeastPlayedSongs returns top N least played songs with soundtrack info
// GET /api/v1/statistics/songs/least-played // GET /api/v1/statistics/songs/least-played
// //
// @Summary Get least played songs // @Summary Get least played songs
// @Description Returns the top N least played songs with their game info // @Description Returns the top N least played songs with their soundtrack info
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -156,7 +156,7 @@ func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
} }
} }
songs, err := h.statsBackend.GetLeastPlayedSongsWithGame(int32(limit)) songs, err := h.statsBackend.GetLeastPlayedSongsWithSoundtrack(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
@@ -164,40 +164,40 @@ func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, songs) return ctx.JSON(http.StatusOK, songs)
} }
// GetNeverPlayedGames returns games that have never been played // GetNeverPlayedSoundtracks returns soundtracks that have never been played
// GET /api/v1/statistics/games/never-played // GET /api/v1/statistics/soundtracks/never-played
// //
// @Summary Get never played games // @Summary Get never played soundtracks
// @Description Returns all games that have never been played (times_played = 0) // @Description Returns all soundtracks that have never been played (times_played = 0)
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/never-played [get] // @Router /api/v1/statistics/soundtracks/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
games, err := h.statsBackend.GetNeverPlayedGames() soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get never played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get never played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetLastPlayedGames returns most recently played games // GetLastPlayedSoundtracks returns most recently played soundtracks
// GET /api/v1/statistics/games/last-played // GET /api/v1/statistics/soundtracks/last-played
// //
// @Summary Get last played games // @Summary Get last played soundtracks
// @Description Returns the most recently played games // @Description Returns the most recently played soundtracks
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param limit query int false "Number of results (default: 10)" // @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/last-played [get] // @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 limit := 10
limitStr := ctx.QueryParam("limit") limitStr := ctx.QueryParam("limit")
if limitStr != "" { if limitStr != "" {
@@ -211,28 +211,28 @@ func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error {
} }
} }
games, err := h.statsBackend.GetLastPlayedGames(int32(limit)) soundtracks, err := h.statsBackend.GetLastPlayedSoundtracks(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get last played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get last played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetOldestPlayedGames returns least recently played games // GetOldestPlayedSoundtracks returns least recently played soundtracks
// GET /api/v1/statistics/games/oldest-played // GET /api/v1/statistics/soundtracks/oldest-played
// //
// @Summary Get oldest played games // @Summary Get oldest played soundtracks
// @Description Returns the least recently played games (that have been played at least once) // @Description Returns the least recently played soundtracks (that have been played at least once)
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param limit query int false "Number of results (default: 10)" // @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/oldest-played [get] // @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 limit := 10
limitStr := ctx.QueryParam("limit") limitStr := ctx.QueryParam("limit")
if limitStr != "" { if limitStr != "" {
@@ -246,12 +246,12 @@ func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error {
} }
} }
games, err := h.statsBackend.GetOldestPlayedGames(int32(limit)) soundtracks, err := h.statsBackend.GetOldestPlayedSoundtracks(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get oldest played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get oldest played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetStatisticsSummary returns overall statistics // GetStatisticsSummary returns overall statistics
+13 -8
View File
@@ -12,6 +12,7 @@ import (
"music-server/internal/db" "music-server/internal/db"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -91,8 +92,8 @@ func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// After sync with /sync/new, only soundtracks matching filesystem remain // After sync with /sync/new, only soundtracks matching filesystem remain
// testMusic has 3 games // testMusic has 3 soundtracks
require.Equal(t, int64(3), summary.TotalGames) require.Equal(t, int64(3), summary.TotalSoundtracks)
} }
// insertTestData inserts 5 test soundtracks with songs into the database // insertTestData inserts 5 test soundtracks with songs into the database
@@ -122,11 +123,12 @@ func insertTestData(t *testing.T) {
SoundtrackName: st.name, SoundtrackName: st.name,
Path: st.path, Path: st.path,
Hash: "test-hash-" + st.name, Hash: "test-hash-" + st.name,
Uuid: uuid.New(),
}) })
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name) require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
} }
// Get soundtrack IDs // Get soundtrack IDs and UUIDs
soundtrackIDs, err := queries.FindAllSoundtracks(ctx) soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
require.NoError(t, err) require.NoError(t, err)
require.GreaterOrEqual(t, len(soundtrackIDs), 5) require.GreaterOrEqual(t, len(soundtrackIDs), 5)
@@ -134,13 +136,14 @@ func insertTestData(t *testing.T) {
// Insert songs for each soundtrack // Insert songs for each soundtrack
songData := []struct { songData := []struct {
soundtrackID int32 soundtrackID int32
soundtrackUuid uuid.UUID
songs []string songs []string
}{ }{
{soundtrackIDs[0].ID, []string{"Song A", "Song B"}}, {soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}},
{soundtrackIDs[1].ID, []string{"Song C", "Song D"}}, {soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}},
{soundtrackIDs[2].ID, []string{"Song E"}}, {soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}},
{soundtrackIDs[3].ID, []string{"Song F", "Song G", "Song H"}}, {soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}},
{soundtrackIDs[4].ID, []string{"Song I"}}, {soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []string{"Song I"}},
} }
for _, sd := range songData { for _, sd := range songData {
@@ -149,6 +152,8 @@ func insertTestData(t *testing.T) {
SoundtrackID: sd.soundtrackID, SoundtrackID: sd.soundtrackID,
SongName: songName, SongName: songName,
Path: "/path/to/" + songName + ".mp3", Path: "/path/to/" + songName + ".mp3",
Uuid: uuid.New(),
SoundtrackUuid: sd.soundtrackUuid,
}) })
require.NoError(t, err, "Failed to insert song: %s", songName) require.NoError(t, err, "Failed to insert song: %s", songName)
} }
+6 -18
View File
@@ -36,7 +36,7 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
// SyncSoundtracksNewOnlyChanges godoc // SyncSoundtracksNewOnlyChanges godoc
// @Summary Sync soundtracks with only changes // @Summary Sync soundtracks with only changes
// @Description Starts syncing games with only new changes // @Description Starts syncing soundtracks with only new changes
// @Tags sync // @Tags sync
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -44,19 +44,15 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /sync [get] // @Router /sync [get]
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error { func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Warn("Syncing is already in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
logging.GetLogger().Info("Starting sync with only changes") logging.GetLogger().Info("Starting sync with only changes")
backend.Syncing = true backend.Syncing = true
go backend.SyncSoundtracksNewOnlyChanges() go backend.SyncSoundtracksOnlyChanges()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks") return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
} }
// SyncSoundtracksNewFull godoc // SyncSoundtracksNewFull godoc
// @Summary Sync all games fully // @Summary Sync all soundtracks fully
// @Description Starts a full sync of all games // @Description Starts a full sync of all soundtracks
// @Tags sync // @Tags sync
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -64,19 +60,15 @@ func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/full [get] // @Router /sync/full [get]
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error { func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Warn("Syncing is already in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
logging.GetLogger().Info("Starting full sync") logging.GetLogger().Info("Starting full sync")
backend.Syncing = true backend.Syncing = true
go backend.SyncSoundtracksNewFull() go backend.SyncSoundtracksFull()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full") return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
} }
// ResetDB godoc // ResetDB godoc
// @Summary Reset soundtracks database // @Summary Reset soundtracks database
// @Description Resets the games database by deleting all games and songs // @Description Resets the soundtracks database by deleting all soundtracks and songs
// @Tags sync // @Tags sync
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -84,10 +76,6 @@ func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/reset [get] // @Router /sync/reset [get]
func (s *SyncHandler) ResetDB(ctx *echo.Context) error { func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Warn("Cannot reset - syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
logging.GetLogger().Info("Resetting soundtracks database") logging.GetLogger().Info("Resetting soundtracks database")
backend.ResetDB() backend.ResetDB()
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database") return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
+41 -41
View File
@@ -61,7 +61,7 @@ func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
return false return false
} }
// TestSyncPopulatesDatabase verifies that sync populates the database with games // TestSyncPopulatesDatabase verifies that sync populates the database with soundtracks
func TestSyncPopulatesDatabase(t *testing.T) { func TestSyncPopulatesDatabase(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -74,12 +74,12 @@ func TestSyncPopulatesDatabase(t *testing.T) {
// Clear any existing data first // Clear any existing data first
db.TestClearDatabase(t) db.TestClearDatabase(t)
// Before sync - should have no games // Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
beforeCount := len(gamesBefore) beforeCount := len(soundtracksBefore)
t.Logf("Games before sync: %d", beforeCount) t.Logf("Soundtracks before sync: %d", beforeCount)
assert.Equal(t, 0, beforeCount, "Database should be empty after clear") assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
// Run sync // Run sync
@@ -91,14 +91,14 @@ func TestSyncPopulatesDatabase(t *testing.T) {
t.Error("Sync did not complete within timeout") t.Error("Sync did not complete within timeout")
} }
// After sync - should have games // After sync - should have soundtracks
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
afterCount := len(gamesAfter) afterCount := len(soundtracksAfter)
t.Logf("Games after sync: %d", afterCount) t.Logf("Soundtracks after sync: %d", afterCount)
// Should have more games than before (unless database was already populated) // Should have more soundtracks than before (unless database was already populated)
assert.True(t, afterCount > 0, "Database should have games after sync") assert.True(t, afterCount > 0, "Database should have soundtracks after sync")
} }
// TestSyncMakesDifference verifies that sync actually changes the database state // TestSyncMakesDifference verifies that sync actually changes the database state
@@ -111,11 +111,11 @@ func TestSyncMakesDifference(t *testing.T) {
// Clear any existing data first // Clear any existing data first
db.TestClearDatabase(t) db.TestClearDatabase(t)
// Before sync - should have no games // Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync") assert.Equal(t, 0, len(soundtracksBefore), "Should have no soundtracks before sync")
// Run sync // Run sync
resp := MakeTestRequest(t, e, "GET", "/sync/full") resp := MakeTestRequest(t, e, "GET", "/sync/full")
@@ -126,10 +126,10 @@ func TestSyncMakesDifference(t *testing.T) {
t.Error("Sync did not complete within timeout") t.Error("Sync did not complete within timeout")
} }
// After sync - should have games // After sync - should have soundtracks
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, len(gamesAfter) > 0, "Should have games after sync") assert.True(t, len(soundtracksAfter) > 0, "Should have soundtracks after sync")
} }
// TestSyncProgress verifies the sync progress endpoint // TestSyncProgress verifies the sync progress endpoint
@@ -183,8 +183,8 @@ func TestSyncProgress(t *testing.T) {
assert.True(t, foundComplete, "Should have seen completion") assert.True(t, foundComplete, "Should have seen completion")
} }
// TestSyncGamesNewOnlyChanges verifies the incremental sync endpoint // TestSyncSoundtracksNewOnlyChanges verifies the incremental sync endpoint
func TestSyncGamesNewOnlyChanges(t *testing.T) { func TestSyncSoundtracksNewOnlyChanges(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -200,8 +200,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
// Get initial count // Get initial count
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(gamesBefore) beforeCount := len(soundtracksBefore)
// Run incremental sync (should not change count if nothing changed) // Run incremental sync (should not change count if nothing changed)
resp := MakeTestRequest(t, e, "GET", "/sync/new") resp := MakeTestRequest(t, e, "GET", "/sync/new")
@@ -211,16 +211,16 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
// Count should be the same // Count should be the same
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(gamesAfter) afterCount := len(soundtracksAfter)
// Note: This might not be exactly equal due to timing, but should be close // Note: This might not be exactly equal due to timing, but should be close
t.Logf("Games before incremental sync: %d, after: %d", beforeCount, afterCount) t.Logf("Soundtracks before incremental sync: %d, after: %d", beforeCount, afterCount)
} }
// TestResetGames verifies the reset endpoint clears the database // TestResetSoundtracks verifies the reset endpoint clears the database
// RUN THIS LAST // RUN THIS LAST
func TestResetGames(t *testing.T) { func TestResetSoundtracks(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -228,8 +228,8 @@ func TestResetGames(t *testing.T) {
// First ensure we have data // First ensure we have data
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(gamesBefore) beforeCount := len(soundtracksBefore)
if beforeCount == 0 { if beforeCount == 0 {
// Run sync to populate // Run sync to populate
@@ -238,12 +238,12 @@ func TestResetGames(t *testing.T) {
t.Error("Sync did not complete within timeout") t.Error("Sync did not complete within timeout")
return return
} }
gamesBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount = len(gamesBefore) beforeCount = len(soundtracksBefore)
} }
t.Logf("Games before reset: %d", beforeCount) t.Logf("Soundtracks before reset: %d", beforeCount)
assert.True(t, beforeCount > 0, "Should have games to reset") assert.True(t, beforeCount > 0, "Should have soundtracks to reset")
// Call reset // Call reset
resp := MakeTestRequest(t, e, "GET", "/sync/reset") resp := MakeTestRequest(t, e, "GET", "/sync/reset")
@@ -253,16 +253,16 @@ func TestResetGames(t *testing.T) {
// Note: reset might take a moment to propagate // Note: reset might take a moment to propagate
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(gamesAfter) afterCount := len(soundtracksAfter)
t.Logf("Games after reset: %d", afterCount) t.Logf("Soundtracks after reset: %d", afterCount)
assert.Equal(t, 0, afterCount, "Database should be empty after reset") assert.Equal(t, 0, afterCount, "Database should be empty after reset")
} }
// TestSyncGamesNewFull verifies the full sync endpoint // TestSyncSoundtracksNewFull verifies the full sync endpoint
// RUN THIS LAST (before TestResetGames) // RUN THIS LAST (before TestResetSoundtracks)
func TestSyncGamesNewFull(t *testing.T) { func TestSyncSoundtracksNewFull(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -282,8 +282,8 @@ func TestSyncGamesNewFull(t *testing.T) {
// Verify database is populated // Verify database is populated
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
games, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, len(games) > 0, "Database should be populated after full sync") assert.True(t, len(soundtracks) > 0, "Database should be populated after full sync")
t.Logf("Full sync populated %d games", len(games)) t.Logf("Full sync populated %d soundtracks", len(soundtracks))
} }
+27 -3
View File
@@ -4,20 +4,22 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"go.uber.org/zap" "go.uber.org/zap"
) )
// TokenRequest represents a request to generate a new token // TokenRequest represents a request to generate a new token
type TokenRequest struct { type TokenRequest struct {
Password string `json:"password"` // Required: API password
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api" ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
} }
@@ -53,13 +55,14 @@ func (h *TokenHandler) generateToken() (string, error) {
// POST /api/v1/token // POST /api/v1/token
// //
// @Summary Create session 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 // @Tags auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body TokenRequest true "Client type" // @Param request body TokenRequest true "Password and client type"
// @Success 200 {object} TokenResponse // @Success 200 {object} TokenResponse
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/token [post] // @Router /api/v1/token [post]
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error { 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"}) 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 == "" { if req.ClientType == "" {
req.ClientType = "web" req.ClientType = "web"
} }
+18 -18
View File
@@ -16,12 +16,12 @@ import (
// ensureSyncRan ensures that sync has been run before testing music endpoints // ensureSyncRan ensures that sync has been run before testing music endpoints
func ensureSyncRan(t *testing.T, e *echo.Echo) { func ensureSyncRan(t *testing.T, e *echo.Echo) {
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
games, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
if len(games) == 0 { if len(soundtracks) == 0 {
// Run sync // Run sync
t.Log("No games found, running sync first...") t.Log("No soundtracks found, running sync first...")
resp := MakeTestRequest(t, e, "GET", "/sync/full") resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, http.StatusOK, resp.Code)
@@ -32,8 +32,8 @@ func ensureSyncRan(t *testing.T, e *echo.Echo) {
} }
} }
// TestGetAllGames verifies the /music/all/order endpoint // TestGetAllSoundtracks verifies the /music/all/order endpoint
func TestZGetAllGames(t *testing.T) { func TestZGetAllSoundtracks(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -45,15 +45,15 @@ func TestZGetAllGames(t *testing.T) {
resp := MakeTestRequest(t, e, "GET", "/music/all/order") resp := MakeTestRequest(t, e, "GET", "/music/all/order")
assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, http.StatusOK, resp.Code)
var games []string var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &games) err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, games, "Should have games after sync") assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
t.Logf("Found %d games", len(games)) t.Logf("Found %d soundtracks", len(soundtracks))
} }
// TestGetAllGamesRandom verifies the /music/all/random endpoint // TestGetAllSoundtracksRandom verifies the /music/all/random endpoint
func TestZGetAllGamesRandom(t *testing.T) { func TestZGetAllSoundtracksRandom(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -65,17 +65,17 @@ func TestZGetAllGamesRandom(t *testing.T) {
resp := MakeTestRequest(t, e, "GET", "/music/all/random") resp := MakeTestRequest(t, e, "GET", "/music/all/random")
assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, http.StatusOK, resp.Code)
var games []string var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &games) err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, games, "Should have games after sync") assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
// Verify it's shuffled (not in original order) // Verify it's shuffled (not in original order)
// We can't easily verify randomness, but we can check it's the same length // We can't easily verify randomness, but we can check it's the same length
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order") resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
var gamesOrdered []string var soundtracksOrdered []string
json.Unmarshal(resp2.Body.Bytes(), &gamesOrdered) json.Unmarshal(resp2.Body.Bytes(), &soundtracksOrdered)
assert.Equal(t, len(games), len(gamesOrdered), "Random and ordered should have same count") assert.Equal(t, len(soundtracks), len(soundtracksOrdered), "Random and ordered should have same count")
} }
// TestGetRandomSong verifies the /music/rand endpoint // TestGetRandomSong verifies the /music/rand endpoint
@@ -153,7 +153,7 @@ func TestZGetSongInfo(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Note: CurrentlyPlaying might be false if no song is currently set // Note: CurrentlyPlaying might be false if no song is currently set
// Just verify we got a valid response // Just verify we got a valid response
t.Logf("Song info: Game=%s, Song=%s", info.Game, info.Song) t.Logf("Song info: Soundtrack=%s, Song=%s", info.Soundtrack, info.Song)
} }
// TestGetPlayedSongs verifies the /music/list endpoint // TestGetPlayedSongs verifies the /music/list endpoint
+53 -32
View File
@@ -85,7 +85,7 @@ build-run: build
test: build test: build
@echo "Starting test database container..." @echo "Starting test database container..."
@podman-compose -f compose.test.yaml up -d @docker compose -f compose.test.yaml up -d
@sleep 10 @sleep 10
@echo "Running integration tests..." @echo "Running integration tests..."
@just test-integration @just test-integration
@@ -97,54 +97,75 @@ clean:
@echo "Cleaning..." @echo "Cleaning..."
@rm -f main @rm -f main
podman-build: docker-build:
@echo "Building Docker image with podman..." @echo "Building Docker image with docker..."
@podman build -t music-server . @docker build -t music-server .
podman-run: docker-run:
@podman-compose up --build @docker compose up --build
podman-down: docker-down:
@podman-compose down @docker compose down
# Run integration tests with podman # Run integration tests with docker
# Starts a test PostgreSQL container, runs tests, then cleans up # Starts a test PostgreSQL container, runs tests, then cleans up
test-integration: test-integration:
@echo "Cleaning old test database..." @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..." @echo "Starting fresh test database container..."
@podman-compose -f compose.test.yaml up -d @docker compose -f compose.test.yaml up -d
@sleep 10 @sleep 10
@echo "Running integration tests..." @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: test-integration-tc:
@echo "Running integration tests with testcontainers (podman provider)..." @echo "Creating test network (if it doesn't exist)..."
@TESTCONTAINERS_PROVIDER=podman go test -v -timeout 30m . @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 # Stop and remove test database container
test-integration-down: test-integration-down:
@echo "Stopping test database container..." @echo "Stopping test database container..."
@podman-compose -f compose.test.yaml down -v @docker compose -f compose.test.yaml down -v
# Create DB container # Build and run with Docker Compose
docker-run: build-run-docker:
@if docker compose up --build 2>/dev/null; then \ @echo "Building and running with Docker Compose..."
: ; \ @docker compose up --build
else \
echo "Falling back to Docker Compose V1"; \
docker-compose up --build; \
fi
# Shutdown DB container # Shutdown containers
docker-down: stop-docker:
@if docker compose down 2>/dev/null; then \ @echo "Stopping containers..."
: ; \ @docker compose down
else \
echo "Falling back to Docker Compose V1"; \
docker-compose down; \
fi
watch-templ: watch-templ:
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."
+9
View File
@@ -39,3 +39,12 @@ sql:
go_type: go_type:
import: "time" import: "time"
type: "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"
View File
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.