10 Commits
Author SHA1 Message Date
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
Sansan dbef39b828 Merge pull request 'Feature/statistics api' (#26) from feature/statistics-api into develop
Build / build (push) Successful in 51s
Reviewed-on: #26
2026-06-14 11:48:36 +02:00
Sansan 4e5bdc4ee2 Fixed some small bugs after merge 2026-06-14 11:30:58 +02:00
Sansan 0894d65ec5 Merge branch 'develop' into feature/statistics-api
# Conflicts:
#	internal/backend/music.go
#	internal/backend/sync.go
#	internal/server/server.go
#	internal/server/syncHandler.go
#	internal/server/sync_handler_test.go
#	internal/server/test_helpers.go
#	internal/server/zz_music_handler_test.go
2026-06-13 11:51:56 +02:00
Sansan 4033899a68 Merge pull request 'Feature/session token api' (#25) from feature/session-token-api into develop
Build / build (push) Successful in 50s
Reviewed-on: #25
2026-06-13 11:30:08 +02:00
Sansan c6a07e69e7 Fixed some small bugs. Frontend is now included in the docker image 2026-06-13 11:26:52 +02:00
Sansan 6d4a034753 Fix duplicate import in routes.go
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:44:17 +02:00
Sansan 24a9111333 feat: Remove global db.Dbpool with dependency injection (Phase 0)
- Add Database struct in internal/db/database.go with Pool, Ctx, and RunMigrations()
- Update server.go to use Database struct with NewServerInstance()
- Add backend.go with InitBackend(), BackendRepo(), BackendCtx(), BackendPool()
- Update music.go and sync.go to use BackendRepo() and BackendCtx() instead of db.Dbpool/db.Ctx
- Update token_handler.go to accept pool parameter
- Update routes.go to use s.db.Pool for middleware
- Update cmd/main.go to use NewServerInstance() and HTTPServer()
- Update test_helpers.go to initialize backend with test database
- Update test files to use backend.BackendPool() and backend.BackendCtx()

Benefits:
- Easier to mock database for unit tests
- Follows Go best practices (dependency injection)
- Better architecture with explicit dependencies
- RunMigrations() replaces old Migrate_db() function

Note: Global db.Dbpool and db.Ctx still exist in dbHelper.go for backward compatibility
with test_helpers.go, but production code no longer uses them.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:15:38 +02:00
Sansan 6cc014ffa3 feat: Implement Session Token System with /api/v1 base path
- Add migration 000004 for sessions table and performance indexes
- Create session.sql queries for CRUD operations
- Generate session repository code with sqlc
- Create token auth middleware for Echo framework
- Create token handler with create/delete/cleanup endpoints
- Add /api/v1 router with token authentication infrastructure
- Update dbHelper.go to use Up() instead of Migrate(2)
- Update server.go to initialize token handler
- Existing endpoints remain functional (to be deprecated)

New endpoints:
- POST /api/v1/token - Create new session token
- DELETE /api/v1/token - Invalidate token
- POST /api/v1/token/cleanup - Remove expired sessions

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:15:38 +02:00
38 changed files with 3152 additions and 1069 deletions
+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
+13 -12
View File
@@ -1,26 +1,30 @@
# Stage 1: Build backend
FROM golang:1.25-alpine as build_go
RUN apk add --no-cache curl
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY *.go ./
RUN find . -name "*_test.go" -delete && \
find . -name "integration_test.go" -delete
RUN go install github.com/a-h/templ/cmd/templ@latest
RUN templ generate
RUN go build -o main cmd/main.go
# Stage 2, distribution container
# Stage 2: Final image
FROM golang:1.25-alpine
EXPOSE 8080
VOLUME /sorted
VOLUME /frontend
VOLUME /characters
COPY --from=build_go /app/main .
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/
ENV PORT 8080
ENV DB_HOST ""
ENV DB_PORT ""
@@ -30,7 +34,4 @@ ENV DB_NAME ""
ENV MUSIC_PATH ""
ENV CHARACTERS_PATH ""
COPY --from=build_go /app/main .
COPY ./songs/ ./songs/
CMD ./main
+682 -45
View File
@@ -23,6 +23,539 @@ var doc = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/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/songs/least-played": {
"get": {
"description": "Returns the top N least played songs with their game info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get least played songs",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/songs/most-played": {
"get": {
"description": "Returns the top N most played songs with their game info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get most played songs",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/summary": {
"get": {
"description": "Returns overall statistics about the music library",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get statistics summary",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/backend.StatisticsSummary"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/token": {
"post": {
"description": "Returns a new session token for API access",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"auth"
],
"summary": "Create session token",
"parameters": [
{
"description": "Client type",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/server.TokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.TokenResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"description": "Deletes the current session token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"auth"
],
"summary": "Invalidate session token",
"parameters": [
{
"type": "string",
"description": "Bearer token",
"name": "Authorization",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/token/cleanup": {
"post": {
"description": "Removes all expired session tokens from the database",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"auth"
],
"summary": "Cleanup expired sessions",
"parameters": [
{
"type": "string",
"description": "Bearer token",
"name": "Authorization",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/character": {
"get": {
"description": "Returns the image for a specific character",
@@ -81,29 +614,6 @@ var doc = `{
}
}
},
"/dbtest": {
"get": {
"description": "Tests the database connection",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"database"
],
"summary": "Test database connection",
"responses": {
"200": {
"description": "TestedDB",
"schema": {
"type": "string"
}
}
}
}
},
"/download": {
"get": {
"description": "Checks for the latest version of the application",
@@ -314,7 +824,7 @@ var doc = `{
},
"/music/all/order": {
"get": {
"description": "Returns a list of all games in order",
"description": "Returns a list of all soundtracks in order",
"consumes": [
"application/json"
],
@@ -324,7 +834,7 @@ var doc = `{
"tags": [
"music"
],
"summary": "Get all games",
"summary": "Get all soundtracks",
"responses": {
"200": {
"description": "OK",
@@ -347,7 +857,7 @@ var doc = `{
},
"/music/all/random": {
"get": {
"description": "Returns a list of all games in random order",
"description": "Returns a list of all soundtracks in random order",
"consumes": [
"application/json"
],
@@ -357,7 +867,7 @@ var doc = `{
"tags": [
"music"
],
"summary": "Get all games random",
"summary": "Get all soundtracks random",
"responses": {
"200": {
"description": "OK",
@@ -687,7 +1197,7 @@ var doc = `{
},
"/sync": {
"get": {
"description": "Starts syncing games with only new changes",
"description": "Starts syncing soundtracks with only new changes",
"consumes": [
"application/json"
],
@@ -697,10 +1207,10 @@ var doc = `{
"tags": [
"sync"
],
"summary": "Sync games with only changes",
"summary": "Sync soundtracks with only changes",
"responses": {
"200": {
"description": "Start syncing games",
"description": "Start syncing soundtracks",
"schema": {
"type": "string"
}
@@ -716,7 +1226,7 @@ var doc = `{
},
"/sync/full": {
"get": {
"description": "Starts a full sync of all games",
"description": "Starts a full sync of all soundtracks",
"consumes": [
"application/json"
],
@@ -726,10 +1236,10 @@ var doc = `{
"tags": [
"sync"
],
"summary": "Sync all games fully",
"summary": "Sync all soundtracks fully",
"responses": {
"200": {
"description": "Start syncing games full",
"description": "Start syncing soundtracks full",
"schema": {
"type": "string"
}
@@ -769,7 +1279,7 @@ var doc = `{
},
"/sync/reset": {
"get": {
"description": "Resets the games database by deleting all games and songs",
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
"consumes": [
"application/json"
],
@@ -779,10 +1289,10 @@ var doc = `{
"tags": [
"sync"
],
"summary": "Reset games database",
"summary": "Reset soundtracks database",
"responses": {
"200": {
"description": "Games and songs are deleted from the database",
"description": "Soundtracks and songs are deleted from the database",
"schema": {
"type": "string"
}
@@ -798,7 +1308,7 @@ var doc = `{
},
"/version": {
"get": {
"description": "get string by ID",
"description": "get latest version info",
"consumes": [
"application/json"
],
@@ -806,9 +1316,9 @@ var doc = `{
"application/json"
],
"tags": [
"accounts"
"version"
],
"summary": "Getting the version of the backend",
"summary": "Getting the latest version of the backend",
"responses": {
"200": {
"description": "OK",
@@ -824,27 +1334,154 @@ var doc = `{
}
}
}
},
"/version/history": {
"get": {
"description": "get version history",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"version"
],
"summary": "Getting the version history of the backend",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.VersionData"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
}
},
"definitions": {
"backend.SoundtrackWithSongs": {
"type": "object",
"properties": {
"soundtrack_id": {
"type": "integer"
},
"soundtrack_last_played": {
"type": "string"
},
"soundtrack_name": {
"type": "string"
},
"soundtrack_played": {
"type": "integer"
},
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
}
},
"backend.SongInfoForStats": {
"type": "object",
"properties": {
"file_name": {
"type": "string"
},
"soundtrack_id": {
"type": "integer"
},
"soundtrack_name": {
"type": "string"
},
"path": {
"type": "string"
},
"song_name": {
"type": "string"
},
"times_played": {
"type": "integer"
}
}
},
"backend.StatisticsSummary": {
"type": "object",
"properties": {
"avg_soundtrack_plays": {
"type": "number"
},
"max_soundtrack_plays": {
"type": "integer"
},
"min_soundtrack_plays": {
"type": "integer"
},
"never_played_soundtracks": {
"type": "integer"
},
"played_soundtracks": {
"type": "integer"
},
"total_soundtrack_plays": {
"type": "integer"
},
"total_soundtracks": {
"type": "integer"
}
}
},
"backend.VersionData": {
"type": "object",
"properties": {
"changelog": {
"type": "string",
"example": "account name"
},
"history": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.VersionData"
}
"type": "string"
},
"example": [
"[\"Initial release\"",
"\"Bug fixes\"]"
]
},
"version": {
"type": "string",
"example": "1.0.0"
}
}
},
"server.TokenRequest": {
"type": "object",
"properties": {
"client_type": {
"description": "Optional: \"web\", \"mobile\", \"api\"",
"type": "string"
}
}
},
"server.TokenResponse": {
"type": "object",
"properties": {
"client_type": {
"type": "string"
},
"expires_at": {
"type": "string"
},
"token": {
"type": "string"
}
}
}
}
}`
+682 -45
View File
@@ -4,6 +4,539 @@
"contact": {}
},
"paths": {
"/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/songs/least-played": {
"get": {
"description": "Returns the top N least played songs with their soundtrack info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get least played songs",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/songs/most-played": {
"get": {
"description": "Returns the top N most played songs with their soundtrack info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get most played songs",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/summary": {
"get": {
"description": "Returns overall statistics about the music library",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get statistics summary",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/backend.StatisticsSummary"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/token": {
"post": {
"description": "Returns a new session token for API access",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"auth"
],
"summary": "Create session token",
"parameters": [
{
"description": "Client type",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/server.TokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.TokenResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"description": "Deletes the current session token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"auth"
],
"summary": "Invalidate session token",
"parameters": [
{
"type": "string",
"description": "Bearer token",
"name": "Authorization",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/token/cleanup": {
"post": {
"description": "Removes all expired session tokens from the database",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"auth"
],
"summary": "Cleanup expired sessions",
"parameters": [
{
"type": "string",
"description": "Bearer token",
"name": "Authorization",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/character": {
"get": {
"description": "Returns the image for a specific character",
@@ -62,29 +595,6 @@
}
}
},
"/dbtest": {
"get": {
"description": "Tests the database connection",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"database"
],
"summary": "Test database connection",
"responses": {
"200": {
"description": "TestedDB",
"schema": {
"type": "string"
}
}
}
}
},
"/download": {
"get": {
"description": "Checks for the latest version of the application",
@@ -295,7 +805,7 @@
},
"/music/all/order": {
"get": {
"description": "Returns a list of all games in order",
"description": "Returns a list of all soundtracks in order",
"consumes": [
"application/json"
],
@@ -305,7 +815,7 @@
"tags": [
"music"
],
"summary": "Get all games",
"summary": "Get all soundtracks",
"responses": {
"200": {
"description": "OK",
@@ -328,7 +838,7 @@
},
"/music/all/random": {
"get": {
"description": "Returns a list of all games in random order",
"description": "Returns a list of all soundtracks in random order",
"consumes": [
"application/json"
],
@@ -338,7 +848,7 @@
"tags": [
"music"
],
"summary": "Get all games random",
"summary": "Get all soundtracks random",
"responses": {
"200": {
"description": "OK",
@@ -668,7 +1178,7 @@
},
"/sync": {
"get": {
"description": "Starts syncing games with only new changes",
"description": "Starts syncing soundtracks with only new changes",
"consumes": [
"application/json"
],
@@ -678,10 +1188,10 @@
"tags": [
"sync"
],
"summary": "Sync games with only changes",
"summary": "Sync soundtracks with only changes",
"responses": {
"200": {
"description": "Start syncing games",
"description": "Start syncing soundtracks",
"schema": {
"type": "string"
}
@@ -697,7 +1207,7 @@
},
"/sync/full": {
"get": {
"description": "Starts a full sync of all games",
"description": "Starts a full sync of all soundtracks",
"consumes": [
"application/json"
],
@@ -707,10 +1217,10 @@
"tags": [
"sync"
],
"summary": "Sync all games fully",
"summary": "Sync all soundtracks fully",
"responses": {
"200": {
"description": "Start syncing games full",
"description": "Start syncing soundtracks full",
"schema": {
"type": "string"
}
@@ -750,7 +1260,7 @@
},
"/sync/reset": {
"get": {
"description": "Resets the games database by deleting all games and songs",
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
"consumes": [
"application/json"
],
@@ -760,10 +1270,10 @@
"tags": [
"sync"
],
"summary": "Reset games database",
"summary": "Reset soundtracks database",
"responses": {
"200": {
"description": "Games and songs are deleted from the database",
"description": "Soundtracks and songs are deleted from the database",
"schema": {
"type": "string"
}
@@ -779,7 +1289,7 @@
},
"/version": {
"get": {
"description": "get string by ID",
"description": "get latest version info",
"consumes": [
"application/json"
],
@@ -787,9 +1297,9 @@
"application/json"
],
"tags": [
"accounts"
"version"
],
"summary": "Getting the version of the backend",
"summary": "Getting the latest version of the backend",
"responses": {
"200": {
"description": "OK",
@@ -805,27 +1315,154 @@
}
}
}
},
"/version/history": {
"get": {
"description": "get version history",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"version"
],
"summary": "Getting the version history of the backend",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.VersionData"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
}
},
"definitions": {
"backend.SoundtrackWithSongs": {
"type": "object",
"properties": {
"soundtrack_id": {
"type": "integer"
},
"soundtrack_last_played": {
"type": "string"
},
"soundtrack_name": {
"type": "string"
},
"soundtrack_played": {
"type": "integer"
},
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
}
},
"backend.SongInfoForStats": {
"type": "object",
"properties": {
"file_name": {
"type": "string"
},
"soundtrack_id": {
"type": "integer"
},
"soundtrack_name": {
"type": "string"
},
"path": {
"type": "string"
},
"song_name": {
"type": "string"
},
"times_played": {
"type": "integer"
}
}
},
"backend.StatisticsSummary": {
"type": "object",
"properties": {
"avg_soundtrack_plays": {
"type": "number"
},
"max_soundtrack_plays": {
"type": "integer"
},
"min_soundtrack_plays": {
"type": "integer"
},
"never_played_soundtracks": {
"type": "integer"
},
"played_soundtracks": {
"type": "integer"
},
"total_soundtrack_plays": {
"type": "integer"
},
"total_soundtracks": {
"type": "integer"
}
}
},
"backend.VersionData": {
"type": "object",
"properties": {
"changelog": {
"type": "string",
"example": "account name"
},
"history": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.VersionData"
}
"type": "string"
},
"example": [
"[\"Initial release\"",
"\"Bug fixes\"]"
]
},
"version": {
"type": "string",
"example": "1.0.0"
}
}
},
"server.TokenRequest": {
"type": "object",
"properties": {
"client_type": {
"description": "Optional: \"web\", \"mobile\", \"api\"",
"type": "string"
}
}
},
"server.TokenResponse": {
"type": "object",
"properties": {
"client_type": {
"type": "string"
},
"expires_at": {
"type": "string"
},
"token": {
"type": "string"
}
}
}
}
}
+448 -29
View File
@@ -1,20 +1,433 @@
definitions:
backend.GameWithSongs:
properties:
game_id:
type: integer
game_last_played:
type: string
game_name:
type: string
game_played:
type: integer
songs:
items:
$ref: '#/definitions/backend.SongInfoForStats'
type: array
type: object
backend.SongInfoForStats:
properties:
file_name:
type: string
game_id:
type: integer
game_name:
type: string
path:
type: string
song_name:
type: string
times_played:
type: integer
type: object
backend.StatisticsSummary:
properties:
avg_game_plays:
type: number
max_game_plays:
type: integer
min_game_plays:
type: integer
never_played_games:
type: integer
played_games:
type: integer
total_game_plays:
type: integer
total_games:
type: integer
type: object
backend.VersionData:
properties:
changelog:
example: account name
type: string
history:
example:
- '["Initial release"'
- '"Bug fixes"]'
items:
$ref: '#/definitions/backend.VersionData'
type: string
type: array
version:
example: 1.0.0
type: string
type: object
server.TokenRequest:
properties:
client_type:
description: 'Optional: "web", "mobile", "api"'
type: string
type: object
server.TokenResponse:
properties:
client_type:
type: string
expires_at:
type: string
token:
type: string
type: object
info:
contact: {}
paths:
/api/v1/statistics/games/last-played:
get:
consumes:
- application/json
description: Returns the most recently played games
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get last played games
tags:
- statistics
/api/v1/statistics/games/least-played:
get:
consumes:
- application/json
description: Returns the top N least played games with their songs
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get least played games
tags:
- statistics
/api/v1/statistics/games/most-played:
get:
consumes:
- application/json
description: Returns the top N most played games with their songs
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get most played games
tags:
- statistics
/api/v1/statistics/games/never-played:
get:
consumes:
- application/json
description: Returns all games that have never been played (times_played = 0)
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get never played games
tags:
- statistics
/api/v1/statistics/games/oldest-played:
get:
consumes:
- application/json
description: Returns the least recently played games (that have been played
at least once)
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get oldest played games
tags:
- statistics
/api/v1/statistics/songs/least-played:
get:
consumes:
- application/json
description: Returns the top N least played songs with their game info
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.SongInfoForStats'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get least played songs
tags:
- statistics
/api/v1/statistics/songs/most-played:
get:
consumes:
- application/json
description: Returns the top N most played songs with their game info
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.SongInfoForStats'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get most played songs
tags:
- statistics
/api/v1/statistics/summary:
get:
consumes:
- application/json
description: Returns overall statistics about the music library
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/backend.StatisticsSummary'
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get statistics summary
tags:
- statistics
/api/v1/token:
delete:
consumes:
- application/json
description: Deletes the current session token
parameters:
- description: Bearer token
in: header
name: Authorization
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Invalidate session token
tags:
- auth
post:
consumes:
- application/json
description: Returns a new session token for API access
parameters:
- description: Client type
in: body
name: request
required: true
schema:
$ref: '#/definitions/server.TokenRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/server.TokenResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Create session token
tags:
- auth
/api/v1/token/cleanup:
post:
consumes:
- application/json
description: Removes all expired session tokens from the database
parameters:
- description: Bearer token
in: header
name: Authorization
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Cleanup expired sessions
tags:
- auth
/character:
get:
consumes:
@@ -53,21 +466,6 @@ paths:
summary: Get list of characters
tags:
- characters
/dbtest:
get:
consumes:
- application/json
description: Tests the database connection
produces:
- application/json
responses:
"200":
description: TestedDB
schema:
type: string
summary: Test database connection
tags:
- database
/download:
get:
consumes:
@@ -223,7 +621,7 @@ paths:
description: Syncing is in progress
schema:
type: string
summary: Get all games
summary: Get all soundtracks
tags:
- music
/music/all/random:
@@ -245,7 +643,7 @@ paths:
description: Syncing is in progress
schema:
type: string
summary: Get all games random
summary: Get all soundtracks random
tags:
- music
/music/info:
@@ -459,14 +857,14 @@ paths:
- application/json
responses:
"200":
description: Start syncing games
description: Start syncing soundtracks
schema:
type: string
"423":
description: Syncing is in progress
schema:
type: string
summary: Sync games with only changes
summary: Sync soundtracks with only changes
tags:
- sync
/sync/full:
@@ -478,7 +876,7 @@ paths:
- application/json
responses:
"200":
description: Start syncing games full
description: Start syncing soundtracks full
schema:
type: string
"423":
@@ -513,21 +911,21 @@ paths:
- application/json
responses:
"200":
description: Games and songs are deleted from the database
description: Soundtracks and songs are deleted from the database
schema:
type: string
"423":
description: Syncing is in progress
schema:
type: string
summary: Reset games database
summary: Reset soundtracks database
tags:
- sync
/version:
get:
consumes:
- application/json
description: get string by ID
description: get latest version info
produces:
- application/json
responses:
@@ -539,7 +937,28 @@ paths:
description: Not Found
schema:
type: string
summary: Getting the version of the backend
summary: Getting the latest version of the backend
tags:
- accounts
- version
/version/history:
get:
consumes:
- application/json
description: get version history
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.VersionData'
type: array
"404":
description: Not Found
schema:
type: string
summary: Getting the version history of the backend
tags:
- version
swagger: "2.0"
+65 -10
View File
@@ -1,5 +1,33 @@
/* Pure CSS styles for Music Search */
:root {
/* Light mode colors (default) */
--bg-primary: #f3f4f6;
--bg-secondary: #e5e7eb;
--bg-tertiary: #dcfce7;
--text-primary: #000;
--text-secondary: #374151;
--border-primary: #9ca3af;
--border-focus: #6b7280;
--accent-primary: #f97316;
--accent-hover: #ea580c;
--shadow-color: rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
/* Dark mode colors matching frontend */
--bg-primary: #555;
--bg-secondary: #333;
--bg-tertiary: #2a2a2a;
--text-primary: #fff;
--text-secondary: #ff9c00;
--border-primary: #666;
--border-focus: #ff9c00;
--accent-primary: #ff9c00;
--accent-hover: #e68a00;
--shadow-color: rgba(0, 0, 0, 0.3);
}
* {
box-sizing: border-box;
margin: 0;
@@ -10,7 +38,9 @@ html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.5;
background-color: #f3f4f6;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
main {
@@ -29,15 +59,15 @@ main {
max-width: 600px;
font-size: 1.5rem;
padding: 0.5rem;
border: 1px solid #9ca3af;
border: 1px solid var(--border-primary);
border-radius: 0.5rem;
background-color: #e5e7eb;
color: #000;
background-color: var(--bg-secondary);
color: var(--text-primary);
}
#search_term:focus {
outline: none;
border-color: #6b7280;
border-color: var(--border-focus);
}
#clear {
@@ -45,23 +75,48 @@ main {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
background-color: #f97316;
color: #fff;
background-color: var(--accent-primary);
color: var(--text-primary);
cursor: pointer;
margin-left: 1rem;
}
#clear:hover {
background-color: #ea580c;
background-color: var(--accent-hover);
}
#games-container {
font-size: 1.5rem;
}
.game-text {
color: var(--text-primary);
word-break: break-word;
}
/* Dark mode toggle */
#dark-mode-toggle {
position: fixed;
top: 1rem;
right: 1rem;
font-size: 1.2rem;
padding: 0.4rem 0.8rem;
border: none;
border-radius: 0.5rem;
background-color: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
z-index: 1000;
transition: all 0.3s ease;
}
#dark-mode-toggle:hover {
background-color: var(--border-primary);
}
/* Game result cards */
.bg-green-100 {
background-color: #dcfce7;
background-color: var(--bg-tertiary);
}
.p-4 {
@@ -69,7 +124,7 @@ main {
}
.shadow-md {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 6px -1px var(--shadow-color), 0 2px 4px -2px var(--shadow-color);
}
.rounded-lg {
-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)
}
-32
View File
@@ -1,32 +0,0 @@
package web
templ HelloForm() {
@Base() {
<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="#games-container"/>
<button type="button" id="clear" name="clear">Clear</button>
</div>
<div id="games-container"></div>
<script>
document.addEventListener('readystatechange', () => {
if (document.readyState == 'complete') {
htmx.ajax('POST', '/find', '#games-container');
document.getElementById("search_term").focus();
}
});
document.getElementById("clear").addEventListener("click", function (event) {
document.getElementById("search_term").value = "";
htmx.ajax('POST', '/find', '#games-container');
document.getElementById("search_term").focus();
});
</script>
}
}
templ FoundGames(games []string) {
for _, game := range games {
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
<p>{ game }</p>
</div>
}
}
+403
View File
@@ -0,0 +1,403 @@
package web
import (
"log"
"music-server/internal/backend"
"net/http"
"regexp"
"sort"
"strings"
"sync"
"unicode"
)
var soundtracks_added []string
// Precomputed data for optimization
type SoundtrackData struct {
Original string
Cleaned string
Abbreviation string
}
var (
precomputedSoundtracks []SoundtrackData
precomputedOnce sync.Once
regexCache = make(map[string]*regexp.Regexp)
regexCacheMutex sync.Mutex
)
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
}
search_term := r.FormValue("search_term")
search(search_term)
component := FoundSoundtracks(soundtracks_added)
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
// Precompute cleaned search term once
cleanedSearchTerm := clean_term(searchText)
// Use precomputed data for efficiency
soundtrackData := getPrecomputedSoundtracks()
seen := make(map[string]bool) // O(1) duplicate checking
for _, data := range soundtrackData {
// Check exact match (case-insensitive contains)
if is_match_exact(searchText, data.Original) && !seen[data.Original] {
soundtracks_added = append(soundtracks_added, data.Original)
seen[data.Original] = true
}
}
for _, data := range soundtrackData {
// Check contains match with cleaned terms
if !seen[data.Original] && is_match_contains(cleanedSearchTerm, data.Cleaned) {
soundtracks_added = append(soundtracks_added, data.Original)
seen[data.Original] = true
}
}
for _, data := range soundtrackData {
// Check regex match with cleaned terms
if !seen[data.Original] && is_match_regex_cached(cleanedSearchTerm, data.Cleaned) {
soundtracks_added = append(soundtracks_added, data.Original)
seen[data.Original] = true
}
}
}
func is_match_exact(search_term string, soundtrack_name string) bool {
search_term = strings.ToLower(search_term)
soundtrack_name = strings.ToLower(soundtrack_name)
if search_term == "" {
return true
} else if strings.Contains(soundtrack_name, search_term) {
return true
} else {
return false
}
}
func is_match_contains(search_term string, soundtrack_name string) bool {
if search_term == "" {
return true
} else if strings.Contains(soundtrack_name, search_term) {
return true
} else {
return false
}
}
func is_match_regex(search_term string, soundtrack_name string) bool {
if search_term == "" {
return true
} else if compile_regex(search_term).MatchString(soundtrack_name) {
return true
} else {
return false
}
}
func compile_regex(search_term string) *regexp.Regexp {
regText := ".*"
for _, letter := range search_term {
regText += string(letter) + ".*"
}
r, _ := regexp.Compile(regText)
return r
}
// is_match_regex_cached uses cached compiled regex patterns for better performance
func is_match_regex_cached(search_term string, soundtrack_name string) bool {
if search_term == "" {
return true
}
// Check cache first
regexCacheMutex.Lock()
re, exists := regexCache[search_term]
if !exists {
re = compile_regex(search_term)
regexCache[search_term] = re
}
regexCacheMutex.Unlock()
return re.MatchString(soundtrack_name)
}
func clean_term(term string) string {
term = strings.ReplaceAll(term, " ", "")
term = strings.ReplaceAll(term, "é", "e")
term = strings.ReplaceAll(term, "+", "plus")
term = strings.ReplaceAll(term, "&", "and")
term = strings.ReplaceAll(term, "'n", "and")
return strings.ToLower(term)
}
// toLower converts a string to lowercase.
func toLower(s string) string {
return strings.Map(unicode.ToLower, s)
}
// levenshteinWithThreshold calculates Levenshtein distance with early termination and space optimization.
// Uses O(min(n,m)) space instead of O(n*m) and stops early if threshold is exceeded.
func levenshteinWithThreshold(s, t string, threshold int) int {
if threshold < 0 {
threshold = 2 // default threshold
}
m, n := len(s), len(t)
// Quick checks for early termination
if m == 0 {
return n
}
if n == 0 {
return m
}
if abs(m-n) > threshold {
return threshold + 1 // Can't be within threshold
}
// Use the shorter string for the row to minimize space
if m < n {
s, t = t, s
m, n = n, m
}
// Space optimization: only store two rows
prevRow := make([]int, n+1)
currRow := make([]int, n+1)
// Initialize first row
for j := 0; j <= n; j++ {
prevRow[j] = j
}
for i := 1; i <= m; i++ {
currRow[0] = i
minInRow := currRow[0]
for j := 1; j <= n; j++ {
if s[i-1] == t[j-1] {
currRow[j] = prevRow[j-1]
} else {
currRow[j] = min3(prevRow[j], currRow[j-1], prevRow[j-1]) + 1
}
if currRow[j] < minInRow {
minInRow = currRow[j]
}
}
// Early termination: if minimum in current row exceeds threshold
if minInRow > threshold {
return threshold + 1
}
// Swap rows for next iteration
prevRow, currRow = currRow, prevRow
}
return prevRow[n]
}
// Helper functions for optimized Levenshtein
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// precomputeSoundtrackData initializes cleaned terms and abbreviations for all soundtracks
func precomputeSoundtrackData() {
precomputedOnce.Do(func() {
soundtracks := backend.GetAllSoundtracks()
precomputedSoundtracks = make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks {
cleaned := clean_term(soundtrack)
precomputedSoundtracks[i] = SoundtrackData{
Original: soundtrack,
Cleaned: cleaned,
Abbreviation: extractAbbreviation(soundtrack),
}
}
})
}
// getPrecomputedSoundtracks ensures precomputed data is available and returns it
func getPrecomputedSoundtracks() []SoundtrackData {
precomputeSoundtrackData()
return precomputedSoundtracks
}
// levenshtein maintains backward compatibility by calling the optimized version
func levenshtein(s, t string) int {
return levenshteinWithThreshold(s, t, -1) // -1 uses default threshold
}
// extractAbbreviation extracts the first letters of each word in a string.
func extractAbbreviation(s string) string {
words := strings.Fields(s)
abbr := ""
for _, word := range words {
if len(word) > 0 {
abbr += string(word[0])
}
}
return toLower(abbr)
}
// fuzzyFindSubstring checks if the query fuzzy-matches any substring of the item.
func fuzzyFindSubstring(query, item string, threshold int) (bool, int) {
query = toLower(query)
item = toLower(item)
queryLen := len(query)
itemLen := len(item)
if queryLen > itemLen {
return false, 0
}
// Check for exact substring match first (weight: 100)
if strings.Contains(item, query) {
return true, 100
}
// Check for fuzzy substring match using optimized Levenshtein
for i := 0; i <= itemLen-queryLen; i++ {
substring := item[i : i+queryLen]
distance := levenshteinWithThreshold(query, substring, threshold)
if distance <= threshold {
// Weight: higher for matches at the start of the string
weight := 50 - i // Higher weight for earlier matches
return true, weight
}
}
return false, 0
}
// fuzzyFind checks if the query matches the item (substring or abbreviation).
func fuzzyFind(query string, item string, threshold int) (bool, int) {
query = toLower(query)
item = toLower(item)
// Check for substring fuzzy match
if matched, weight := fuzzyFindSubstring(query, item, threshold); matched {
return true, weight
}
// Check for abbreviation match using optimized Levenshtein
abbr := extractAbbreviation(item)
distance := levenshteinWithThreshold(query, abbr, threshold)
if distance <= threshold {
// Weight: higher for exact abbreviation matches
weight := 100 - distance*10 // Higher weight for exact matches
return true, weight
}
return false, 0
}
// getAdaptiveThreshold returns a threshold based on query length
func getAdaptiveThreshold(query string) int {
threshold := 2 // Base threshold for minor typos
queryLen := len(query)
if queryLen > 6 {
// For longer queries, allow more tolerance
threshold = queryLen / 3
if threshold < 2 {
threshold = 2
} else if threshold > 4 {
threshold = 4 // Cap at 4 for very long queries
}
}
return threshold
}
// fuzzySearch performs fuzzy search on soundtracks using the cleaned terms for consistency
func fuzzySearch(searchText string) []string {
query := clean_term(searchText)
threshold := getAdaptiveThreshold(query)
// Use precomputed data for efficiency
soundtrackData := getPrecomputedSoundtracks()
type match struct {
item string
weight int
}
var matches []match
seen := make(map[string]bool) // O(1) duplicate checking
for _, data := range soundtrackData {
if matched, weight := fuzzyFind(query, data.Cleaned, threshold); matched {
if !seen[data.Original] {
matches = append(matches, match{data.Original, weight})
seen[data.Original] = true
}
}
}
// Sort matches by weight (descending)
sort.Slice(matches, func(i, j int) bool {
return matches[i].weight > matches[j].weight
})
// Extract just the soundtrack names in order
result := make([]string, len(matches))
for i, m := range matches {
result[i] = m.item
}
return result
}
// FindSoundtrackFuzzyWebHandler handles fuzzy search requests
func FindSoundtrackFuzzyWebHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
search_term := r.FormValue("search_term")
results := fuzzySearch(search_term)
component := FoundSoundtracks(results)
err = component.Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Fatalf("Error rendering in FindSoundtrackFuzzyWebHandler: %e", err)
}
}
+81
View File
@@ -0,0 +1,81 @@
package web
templ SearchForm() {
@Base() {
<button id="dark-mode-toggle">🌙</button>
<div id="search-container">
<input id="search_term" name="search_term" type="text" hx-post="/findfuzzy" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
<div class="radio-group" style="display: inline-block; margin-left: 10px;">
<label><input type="radio" name="search_type" value="fuzzy" checked> Fuzzy</label>
<label><input type="radio" name="search_type" value="normal"> Normal</label>
</div>
<button type="button" id="clear" name="clear">Clear</button>
</div>
<div id="soundtracks-container"></div>
<script>
// Get current search type from radio buttons
function getSearchType() {
return document.querySelector('input[name="search_type"]:checked').value;
}
// Get endpoint based on search type
function getSearchEndpoint() {
return getSearchType() === 'fuzzy' ? '/findfuzzy' : '/find';
}
// Update search input endpoint
function updateSearchEndpoint() {
const endpoint = getSearchEndpoint();
document.getElementById('search_term').setAttribute('hx-post', endpoint);
}
document.addEventListener('readystatechange', () => {
if (document.readyState == 'complete') {
// Initialize with fuzzy search (default)
htmlx.ajax('POST', '/findfuzzy', '#soundtracks-container');
document.getElementById("search_term").focus();
// Add event listeners for radio buttons
document.querySelectorAll('input[name="search_type"]').forEach(radio => {
radio.addEventListener('change', updateSearchEndpoint);
});
// Initialize dark mode from localStorage (default to dark)
const savedTheme = localStorage.getItem('theme') || 'dark';
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('dark-mode-toggle').textContent = '☀️';
}
}
});
// Dark mode toggle functionality
document.getElementById("dark-mode-toggle").addEventListener("click", function() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
// Update toggle button text
this.textContent = newTheme === 'dark' ? '☀️' : '🌙';
});
document.getElementById("clear").addEventListener("click", function (event) {
document.getElementById("search_term").value = "";
// Use current search type endpoint
htmlx.ajax('POST', getSearchEndpoint(), '#soundtracks-container');
document.getElementById("search_term").focus();
});
</script>
}
}
templ FoundSoundtracks(soundtracks []string) {
for _, soundtrack := range soundtracks {
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
<p class="soundtrack-text">{ soundtrack }</p>
</div>
}
}
+50 -50
View File
@@ -12,8 +12,8 @@ import (
)
type SongInfo struct {
Game string `json:"Game"`
GamePlayed int32 `json:"GamePlayed"`
Soundtrack string `json:"Soundtrack"`
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
Song string `json:"Song"`
SongPlayed int32 `json:"SongPlayed"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
@@ -22,7 +22,7 @@ type SongInfo struct {
var currentSong = -1
var gamesNew []repository.Soundtrack
var soundtracksNew []repository.Soundtrack
var songQueNew []repository.Song
@@ -37,12 +37,12 @@ func initRepo() {
}
}
func getAllGames() []repository.Soundtrack {
if len(gamesNew) == 0 {
func getAllSoundtracks() []repository.Soundtrack {
if len(soundtracksNew) == 0 {
initRepo()
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
}
return gamesNew
return soundtracksNew
}
@@ -59,7 +59,7 @@ func Reset() {
songQueNew = nil
currentSong = -1
initRepo()
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
}
func AddLatestToQue() {
@@ -92,34 +92,34 @@ func SetPlayed(songNumber int) {
}
func GetRandomSong() string {
getAllGames()
if len(gamesNew) == 0 {
getAllSoundtracks()
if len(soundtracksNew) == 0 {
return ""
}
song := getSongFromList(gamesNew)
song := getSongFromList(soundtracksNew)
lastFetchedNew = song
return song.Path
}
func GetRandomSongLowChance() string {
getAllGames()
getAllSoundtracks()
var listOfGames []repository.Soundtrack
var listOfSoundtracks []repository.Soundtrack
var averagePlayed = getAveragePlayed()
for _, data := range gamesNew {
for _, data := range soundtracksNew {
timesToAdd := averagePlayed - data.TimesPlayed
if timesToAdd <= 0 {
listOfGames = append(listOfGames, data)
listOfSoundtracks = append(listOfSoundtracks, data)
} else {
for i := int32(0); i < timesToAdd; i++ {
listOfGames = append(listOfGames, data)
listOfSoundtracks = append(listOfSoundtracks, data)
}
}
}
song := getSongFromList(listOfGames)
song := getSongFromList(listOfSoundtracks)
lastFetchedNew = song
return song.Path
@@ -127,11 +127,11 @@ func GetRandomSongLowChance() string {
}
func GetRandomSongClassic() string {
getAllGames()
getAllSoundtracks()
var listOfAllSongs []repository.Song
for _, game := range gamesNew {
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID)
for _, soundtrack := range soundtracksNew {
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
listOfAllSongs = append(listOfAllSongs, songList...)
}
@@ -139,25 +139,25 @@ func GetRandomSongClassic() string {
var song repository.Song
for !songFound {
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
gameData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
soundtrackData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
if err != nil {
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName),
zap.String("game", gameData.SoundtrackName),
zap.String("soundtrack", soundtrackData.SoundtrackName),
zap.String("filename", *song.FileName))
continue
}
//Check if file exists and open
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
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName),
zap.String("game", gameData.SoundtrackName),
zap.String("soundtrack", soundtrackData.SoundtrackName),
zap.String("filename", *song.FileName))
} else {
songFound = true
@@ -177,11 +177,11 @@ func GetSongInfo() SongInfo {
}
var currentSongData = songQueNew[currentSong]
currentGameData := getCurrentGame(currentSongData)
currentSoundtrackData := getCurrentSoundtrack(currentSongData)
return SongInfo{
Game: currentGameData.SoundtrackName,
GamePlayed: currentGameData.TimesPlayed,
Soundtrack: currentSoundtrackData.SoundtrackName,
SoundtrackPlayed: currentSoundtrackData.TimesPlayed,
Song: currentSongData.SongName,
SongPlayed: currentSongData.TimesPlayed,
CurrentlyPlaying: true,
@@ -193,10 +193,10 @@ func GetPlayedSongs() []SongInfo {
var songList []SongInfo
for i, song := range songQueNew {
gameData := getCurrentGame(song)
soundtrackData := getCurrentSoundtrack(song)
songList = append(songList, SongInfo{
Game: gameData.SoundtrackName,
GamePlayed: gameData.TimesPlayed,
Soundtrack: soundtrackData.SoundtrackName,
SoundtrackPlayed: soundtrackData.TimesPlayed,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == currentSong,
@@ -218,21 +218,21 @@ func GetSong(song string) string {
}
func GetAllSoundtracks() []string {
getAllGames()
getAllSoundtracks()
var jsonArray []string
for _, game := range gamesNew {
jsonArray = append(jsonArray, game.SoundtrackName)
for _, soundtrack := range soundtracksNew {
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
}
return jsonArray
}
func GetAllSoundtracksRandom() []string {
getAllGames()
getAllSoundtracks()
var jsonArray []string
for _, game := range gamesNew {
jsonArray = append(jsonArray, game.SoundtrackName)
for _, soundtrack := range soundtracksNew {
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
}
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
return jsonArray
@@ -266,12 +266,12 @@ func GetPreviousSong() string {
}
}
func getSongFromList(games []repository.Soundtrack) repository.Song {
func getSongFromList(soundtracks []repository.Soundtrack) repository.Song {
songFound := false
var song repository.Song
for !songFound {
game := getRandomGame(games)
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID)
soundtrack := getRandomSoundtrack(soundtracks)
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
if len(songs) == 0 {
continue
}
@@ -280,12 +280,12 @@ func getSongFromList(games []repository.Soundtrack) repository.Song {
//Check if file exists and open
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
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName),
zap.String("game", game.SoundtrackName),
zap.String("soundtrack", soundtrack.SoundtrackName),
zap.Any("filename", song.FileName))
} else {
songFound = true
@@ -299,24 +299,24 @@ func getSongFromList(games []repository.Soundtrack) repository.Song {
return song
}
func getCurrentGame(currentSongData repository.Song) repository.Soundtrack {
for _, game := range gamesNew {
if game.ID == currentSongData.SoundtrackID {
return game
func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
for _, soundtrack := range soundtracksNew {
if soundtrack.ID == currentSongData.SoundtrackID {
return soundtrack
}
}
return repository.Soundtrack{}
}
func getAveragePlayed() int32 {
getAllGames()
getAllSoundtracks()
var sum int32
for _, data := range gamesNew {
for _, data := range soundtracksNew {
sum += data.TimesPlayed
}
return sum / int32(len(gamesNew))
return sum / int32(len(soundtracksNew))
}
func getRandomGame(listOfGames []repository.Soundtrack) repository.Soundtrack {
return listOfGames[rand.Intn(len(listOfGames))]
func getRandomSoundtrack(listOfSoundtracks []repository.Soundtrack) repository.Soundtrack {
return listOfSoundtracks[rand.Intn(len(listOfSoundtracks))]
}
+70 -72
View File
@@ -9,17 +9,17 @@ import (
// Test the average calculation logic directly without database access
func TestCalculateAverage(t *testing.T) {
games := []repository.Game{
{GameName: "Game1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30},
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
}
var sum int32
for _, data := range games {
for _, data := range soundtracks {
sum += data.TimesPlayed
}
result := sum / int32(len(games))
result := sum / int32(len(soundtracks))
expected := int32(20)
if result != expected {
@@ -28,9 +28,9 @@ func TestCalculateAverage(t *testing.T) {
}
func TestCalculateAverageEmpty(t *testing.T) {
games := []repository.Game{}
soundtracks := []repository.Soundtrack{}
if len(games) == 0 {
if len(soundtracks) == 0 {
result := int32(0)
expected := int32(0)
if result != expected {
@@ -40,10 +40,10 @@ func TestCalculateAverageEmpty(t *testing.T) {
}
var sum int32
for _, data := range games {
for _, data := range soundtracks {
sum += data.TimesPlayed
}
result := sum / int32(len(games))
result := sum / int32(len(soundtracks))
expected := int32(0)
if result != expected {
@@ -52,152 +52,150 @@ func TestCalculateAverageEmpty(t *testing.T) {
}
func TestCalculateAverageSingle(t *testing.T) {
games := []repository.Game{
{GameName: "Game1", TimesPlayed: 42},
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Soundtrack1", TimesPlayed: 42},
}
var sum int32
for _, data := range games {
for _, data := range soundtracks {
sum += data.TimesPlayed
}
result := sum / int32(len(games))
result := sum / int32(len(soundtracks))
expected := int32(42)
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) {
games := []repository.Game{
{GameName: "Game1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30},
func TestGetRandomSoundtrack(t *testing.T) {
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
}
// Set seed for reproducible tests
rand.Seed(42)
result := games[rand.Intn(len(games))]
result := soundtracks[rand.Intn(len(soundtracks))]
if result.GameName == "" {
t.Error("random game selection returned empty game")
if result.SoundtrackName == "" {
t.Error("random soundtrack selection returned empty soundtrack")
}
found := false
for _, g := range games {
if g.GameName == result.GameName {
for _, s := range soundtracks {
if s.SoundtrackName == result.SoundtrackName {
found = true
break
}
}
if !found {
t.Errorf("random game selection returned game not in list: %v", result.GameName)
t.Errorf("random soundtrack selection returned soundtrack not in list: %v", result.SoundtrackName)
}
}
func TestFindGameByID(t *testing.T) {
games := []repository.Game{
{ID: 1, GameName: "Game1", TimesPlayed: 10},
{ID: 2, GameName: "Game2", TimesPlayed: 20},
{ID: 3, GameName: "Game3", TimesPlayed: 30},
func TestFindSoundtrackByID(t *testing.T) {
soundtracks := []repository.Soundtrack{
{ID: 1, SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{ID: 3, SoundtrackName: "Soundtrack3", TimesPlayed: 30},
}
tests := []struct {
name string
games []repository.Game
gameID int32
expected repository.Game
name string
soundtracks []repository.Soundtrack
soundtrackID int32
expected repository.Soundtrack
}{
{
name: "existing game",
games: games,
gameID: 2,
expected: repository.Game{ID: 2, GameName: "Game2", TimesPlayed: 20},
name: "existing soundtrack",
soundtracks: soundtracks,
soundtrackID: 2,
expected: repository.Soundtrack{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
},
{
name: "non-existing game",
games: games,
gameID: 99,
expected: repository.Game{},
name: "non-existing soundtrack",
soundtracks: soundtracks,
soundtrackID: 99,
expected: repository.Soundtrack{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var result repository.Game
for _, game := range tt.games {
if game.ID == tt.gameID {
result = game
var result repository.Soundtrack
for _, s := range tt.soundtracks {
if s.ID == tt.soundtrackID {
result = s
break
}
}
if result.ID != tt.expected.ID || result.GameName != tt.expected.GameName {
t.Errorf("findGameByID() = %v, want %v", result, tt.expected)
if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName {
t.Errorf("findSoundtrackByID() = %v, want %v", result, tt.expected)
}
})
}
}
func TestExtractGameNames(t *testing.T) {
games := []repository.Game{
{GameName: "Game1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30},
func TestExtractSoundtrackNames(t *testing.T) {
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
}
var result []string
for _, game := range games {
result = append(result, game.GameName)
for _, s := range soundtracks {
result = append(result, s.SoundtrackName)
}
expected := []string{"Game1", "Game2", "Game3"}
expected := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
if len(result) != len(expected) {
t.Errorf("extractGameNames() length = %d, want %d", len(result), len(expected))
t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected))
return
}
for i, v := range result {
if v != expected[i] {
t.Errorf("extractGameNames()[%d] = %v, want %v", i, v, expected[i])
t.Errorf("extractSoundtrackNames()[%d] = %v, want %v", i, v, expected[i])
}
}
}
func TestShuffleGameNames(t *testing.T) {
games := []string{"Game1", "Game2", "Game3"}
func TestShuffleSoundtrackNames(t *testing.T) {
soundtracks := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
// 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
original := make([]string, len(games))
copy(original, games)
original := make([]string, len(soundtracks))
copy(original, soundtracks)
// Simple shuffle implementation for testing
for i := range games {
for i := range soundtracks {
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) {
t.Errorf("shuffleGameNames() changed length from %d to %d", len(original), len(games))
if len(soundtracks) != len(original) {
t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(soundtracks))
return
}
// Check all original elements are still present
for _, orig := range original {
found := false
for _, g := range games {
if g == orig {
for _, s := range soundtracks {
if s == orig {
found = true
break
}
}
if !found {
t.Errorf("shuffleGameNames() lost element: %v", orig)
t.Errorf("shuffleSoundtrackNames() lost element: %v", orig)
}
}
}
+108 -108
View File
@@ -9,34 +9,34 @@ import (
"go.uber.org/zap"
)
// GameWithSongs represents a game with its songs for statistics
type GameWithSongs struct {
SoundtrackID int32 `json:"game_id"`
SoundtrackName string `json:"game_name"`
SoundtrackPlayed int32 `json:"game_played"`
SoundtrackLastPlayed *time.Time `json:"game_last_played,omitempty"`
Songs []SongInfoForStats `json:"songs"`
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
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 {
SoundtrackID int32 `json:"game_id"`
SoundtrackName string `json:"game_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name,omitempty"`
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,omitempty"`
}
// StatisticsSummary holds overall statistics
type StatisticsSummary struct {
TotalGames int64 `json:"total_games"`
PlayedGames int64 `json:"played_games"`
NeverPlayedGames int64 `json:"never_played_games"`
TotalGamePlays int64 `json:"total_game_plays"`
AvgGamePlays float64 `json:"avg_game_plays"`
MaxGamePlays int64 `json:"max_game_plays"`
MinGamePlays int64 `json:"min_game_plays"`
TotalSoundtracks int64 `json:"total_soundtracks"`
PlayedSoundtracks int64 `json:"played_soundtracks"`
NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"`
TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"`
AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"`
MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"`
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
}
// StatisticsHandler manages statistics operations
@@ -49,19 +49,19 @@ func NewStatisticsHandler() *StatisticsHandler {
return &StatisticsHandler{}
}
// GetMostPlayedGamesWithSongs returns the top N most played games with their songs
func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) {
// GetMostPlayedSoundtracksWithSongs returns the top N most played soundtracks with their songs
func (h *StatisticsHandler) GetMostPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
// Get raw results
rows, err := queries.GetMostPlayedGamesWithSongs(ctx, limit)
rows, err := queries.GetMostPlayedSoundtracksWithSongs(ctx, limit)
if err != nil {
return nil, err
}
// Convert to GameWithSongs
var result []GameWithSongs
// Convert to SoundtrackWithSongs
var result []SoundtrackWithSongs
for _, row := range rows {
var songs []SongInfoForStats
if row.Songs != nil {
@@ -71,28 +71,28 @@ func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWith
songs = make([]SongInfoForStats, 0)
}
}
result = append(result, GameWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
Songs: songs,
})
}
return result, nil
}
// GetLeastPlayedGamesWithSongs returns the top N least played games with their songs
func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) {
// GetLeastPlayedSoundtracksWithSongs returns the top N least played soundtracks with their songs
func (h *StatisticsHandler) GetLeastPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetLeastPlayedGamesWithSongs(ctx, limit)
rows, err := queries.GetLeastPlayedSoundtracksWithSongs(ctx, limit)
if err != nil {
return nil, err
}
var result []GameWithSongs
var result []SoundtrackWithSongs
for _, row := range rows {
var songs []SongInfoForStats
if row.Songs != nil {
@@ -100,76 +100,76 @@ func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWit
songs = make([]SongInfoForStats, 0)
}
}
result = append(result, GameWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
Songs: songs,
})
}
return result, nil
}
// GetMostPlayedSongsWithGame returns the top N most played songs with their game info
func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) {
// GetMostPlayedSongsWithSoundtrack returns the top N most played songs with their soundtrack info
func (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetMostPlayedSongsWithGame(ctx, limit)
rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
if err != nil {
return nil, err
}
var result []SongInfoForStats
for _, row := range rows {
result = append(result, SongInfoForStats{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SongName: row.SongName,
Path: row.Path,
TimesPlayed: row.TimesPlayed,
FileName: row.FileName,
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SongName: row.SongName,
Path: row.Path,
TimesPlayed: row.TimesPlayed,
FileName: row.FileName,
})
}
return result, nil
}
// GetLeastPlayedSongsWithGame returns the top N least played songs with their game info
func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) {
// GetLeastPlayedSongsWithSoundtrack returns the top N least played songs with their soundtrack info
func (h *StatisticsHandler) GetLeastPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetLeastPlayedSongsWithGame(ctx, limit)
rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit)
if err != nil {
return nil, err
}
var result []SongInfoForStats
for _, row := range rows {
result = append(result, SongInfoForStats{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SongName: row.SongName,
Path: row.Path,
TimesPlayed: row.TimesPlayed,
FileName: row.FileName,
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SongName: row.SongName,
Path: row.Path,
TimesPlayed: row.TimesPlayed,
FileName: row.FileName,
})
}
return result, nil
}
// GetNeverPlayedGames returns games that have never been played
func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetNeverPlayedGames(ctx)
rows, err := queries.GetNeverPlayedSoundtracks(ctx)
if err != nil {
return nil, err
}
var result []GameWithSongs
var result []SoundtrackWithSongs
for _, row := range rows {
var songs []SongInfoForStats
if row.Songs != nil {
@@ -177,28 +177,28 @@ func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
songs = make([]SongInfoForStats, 0)
}
}
result = append(result, GameWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: nil,
Songs: songs,
Songs: songs,
})
}
return result, nil
}
// GetLastPlayedGames returns the most recently played games
func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, error) {
// GetLastPlayedSoundtracks returns the most recently played soundtracks
func (h *StatisticsHandler) GetLastPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetLastPlayedGames(ctx, limit)
rows, err := queries.GetLastPlayedSoundtracks(ctx, limit)
if err != nil {
return nil, err
}
var result []GameWithSongs
var result []SoundtrackWithSongs
for _, row := range rows {
var songs []SongInfoForStats
if row.Songs != nil {
@@ -206,28 +206,28 @@ func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, er
songs = make([]SongInfoForStats, 0)
}
}
result = append(result, GameWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
Songs: songs,
})
}
return result, nil
}
// GetOldestPlayedGames returns the least recently played games
func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs, error) {
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetOldestPlayedGames(ctx, limit)
rows, err := queries.GetOldestPlayedSoundtracks(ctx, limit)
if err != nil {
return nil, err
}
var result []GameWithSongs
var result []SoundtrackWithSongs
for _, row := range rows {
var songs []SongInfoForStats
if row.Songs != nil {
@@ -235,12 +235,12 @@ func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs,
songs = make([]SongInfoForStats, 0)
}
}
result = append(result, GameWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
Songs: songs,
})
}
return result, nil
@@ -250,20 +250,20 @@ func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs,
func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
queries := BackendRepo()
ctx := BackendCtx()
row, err := queries.GetStatisticsSummary(ctx)
if err != nil {
return nil, err
}
return &StatisticsSummary{
TotalGames: int64(row.TotalSoundtracks),
PlayedGames: int64(row.PlayedSoundtracks),
NeverPlayedGames: int64(row.NeverPlayedSoundtracks),
TotalGamePlays: int64(row.TotalSoundtrackPlays),
AvgGamePlays: float64(row.AvgSoundtrackPlays),
MaxGamePlays: int64(row.MaxSoundtrackPlays),
MinGamePlays: int64(row.MinSoundtrackPlays),
TotalSoundtracks: int64(row.TotalSoundtracks),
PlayedSoundtracks: int64(row.PlayedSoundtracks),
NeverPlayedSoundtracks: int64(row.NeverPlayedSoundtracks),
TotalSoundtrackPlays: int64(row.TotalSoundtrackPlays),
AvgSoundtrackPlays: float64(row.AvgSoundtrackPlays),
MaxSoundtrackPlays: int64(row.MaxSoundtrackPlays),
MinSoundtrackPlays: int64(row.MinSoundtrackPlays),
}, nil
}
+133 -168
View File
@@ -30,14 +30,14 @@ var start time.Time
var totalTime time.Duration
var timeSpent time.Duration
var allGames []repository.Soundtrack
var gamesBeforeSync []repository.Soundtrack
var gamesAfterSync []repository.Soundtrack
var gamesAdded []string
var gamesReAdded []string
var gamesChangedTitle map[string]string
var gamesChangedContent []string
var gamesRemoved []string
var allSoundtracks []repository.Soundtrack
var soundtracksBeforeSync []repository.Soundtrack
var soundtracksAfterSync []repository.Soundtrack
var soundtracksAdded []string
var soundtracksReAdded []string
var soundtracksChangedTitle map[string]string
var soundtracksChangedContent []string
var soundtracksRemoved []string
var catchedErrors []string
type brokenSong struct {
@@ -50,13 +50,13 @@ var pool *ants.Pool
var poolSong *ants.Pool
type SyncResponse struct {
GamesAdded []string `json:"games_added"`
GamesReAdded []string `json:"games_re_added"`
GamesChangedTitle map[string]string `json:"games_changed_title"`
GamesChangedContent []string `json:"games_changed_content"`
GamesRemoved []string `json:"games_removed"`
CatchedErrors []string `json:"catched_errors"`
TotalTime string `json:"total_time"`
SoundtracksAdded []string `json:"soundtracks_added"`
SoundtracksReAdded []string `json:"soundtracks_re_added"`
SoundtracksChangedTitle map[string]string `json:"soundtracks_changed_title"`
SoundtracksChangedContent []string `json:"soundtracks_changed_content"`
SoundtracksRemoved []string `json:"soundtracks_removed"`
CatchedErrors []string `json:"catched_errors"`
TotalTime string `json:"total_time"`
}
type ProgressResponse struct {
@@ -64,24 +64,24 @@ type ProgressResponse struct {
TimeSpent string `json:"time_spent"`
}
type GameStatus int
type SoundtrackStatus int
const (
NotChanged GameStatus = iota
NotChanged SoundtrackStatus = iota
TitleChanged
GameChanged
NewGame
SoundtrackChanged
NewSoundtrack
)
var statusName = map[GameStatus]string{
NotChanged: "Not changed",
TitleChanged: "Title changed",
GameChanged: "Game changed",
NewGame: "New game",
var statusName = map[SoundtrackStatus]string{
NotChanged: "Not changed",
TitleChanged: "Title changed",
SoundtrackChanged: "Soundtrack changed",
NewSoundtrack: "New soundtrack",
}
func (gs GameStatus) String() string {
return statusName[gs]
func (ss SoundtrackStatus) String() string {
return statusName[ss]
}
func ResetDB() {
@@ -107,54 +107,54 @@ func SyncProgress() ProgressResponse {
func SyncResult() SyncResponse {
logging.GetLogger().Info("Sync completed",
zap.Int("games_before", len(gamesBeforeSync)),
zap.Int("games_after", len(gamesAfterSync)))
zap.Int("soundtracks_before", len(soundtracksBeforeSync)),
zap.Int("soundtracks_after", len(soundtracksAfterSync)))
if len(gamesAdded) > 0 {
logging.GetLogger().Debug("Games added", zap.Strings("games", gamesAdded))
if len(soundtracksAdded) > 0 {
logging.GetLogger().Debug("Soundtracks added", zap.Strings("soundtracks", soundtracksAdded))
}
if len(gamesReAdded) > 0 {
logging.GetLogger().Debug("Games readded", zap.Strings("games", gamesReAdded))
if len(soundtracksReAdded) > 0 {
logging.GetLogger().Debug("Soundtracks readded", zap.Strings("soundtracks", soundtracksReAdded))
}
if len(gamesChangedTitle) > 0 {
logging.GetLogger().Debug("Games with changed title", zap.Any("changes", gamesChangedTitle))
if len(soundtracksChangedTitle) > 0 {
logging.GetLogger().Debug("Soundtracks with changed title", zap.Any("changes", soundtracksChangedTitle))
}
if len(gamesChangedContent) > 0 {
logging.GetLogger().Debug("Games with changed content", zap.Strings("games", gamesChangedContent))
if len(soundtracksChangedContent) > 0 {
logging.GetLogger().Debug("Soundtracks with changed content", zap.Strings("soundtracks", soundtracksChangedContent))
}
var gamesRemovedTemp []string
for _, beforeGame := range gamesBeforeSync {
var soundtracksRemovedTemp []string
for _, beforeSoundtrack := range soundtracksBeforeSync {
var found = false
for _, afterGame := range gamesAfterSync {
if beforeGame.SoundtrackName == afterGame.SoundtrackName {
for _, afterSoundtrack := range soundtracksAfterSync {
if beforeSoundtrack.SoundtrackName == afterSoundtrack.SoundtrackName {
found = true
break
}
}
if !found {
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.SoundtrackName)
soundtracksRemovedTemp = append(soundtracksRemovedTemp, beforeSoundtrack.SoundtrackName)
}
}
for _, game := range gamesRemovedTemp {
for _, soundtrack := range soundtracksRemovedTemp {
var found bool = false
for key := range gamesChangedTitle {
if game == key {
for key := range soundtracksChangedTitle {
if soundtrack == key {
found = true
break
}
}
if !found {
gamesRemoved = append(gamesRemoved, game)
soundtracksRemoved = append(soundtracksRemoved, soundtrack)
}
}
if len(gamesRemoved) > 0 {
logging.GetLogger().Debug("Games removed", zap.Strings("games", gamesRemoved))
if len(soundtracksRemoved) > 0 {
logging.GetLogger().Debug("Soundtracks removed", zap.Strings("soundtracks", soundtracksRemoved))
}
if len(catchedErrors) > 0 {
@@ -165,29 +165,27 @@ func SyncResult() SyncResponse {
logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000")))
return SyncResponse{
GamesAdded: gamesAdded,
GamesReAdded: gamesReAdded,
GamesChangedTitle: gamesChangedTitle,
GamesChangedContent: gamesChangedContent,
GamesRemoved: gamesRemoved,
CatchedErrors: catchedErrors,
TotalTime: out.Format("15:04:05"),
SoundtracksAdded: soundtracksAdded,
SoundtracksReAdded: soundtracksReAdded,
SoundtracksChangedTitle: soundtracksChangedTitle,
SoundtracksChangedContent: soundtracksChangedContent,
SoundtracksRemoved: soundtracksRemoved,
CatchedErrors: catchedErrors,
TotalTime: out.Format("15:04:05"),
}
}
func SyncSoundtracksNewFull() {
syncGamesNew(true)
func SyncSoundtracksFull() {
syncSoundtracks(true)
Reset()
}
func SyncSoundtracksNewOnlyChanges() {
syncGamesNew(false)
func SyncSoundtracksOnlyChanges() {
syncSoundtracks(false)
Reset()
}
func syncGamesNew(full bool) {
Syncing = true
func syncSoundtracks(full bool) {
musicPath := os.Getenv("MUSIC_PATH")
fmt.Printf("dir: %s\n", musicPath)
logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath))
@@ -199,23 +197,23 @@ func syncGamesNew(full bool) {
initRepo()
start = time.Now()
foldersToSkip := []string{".sync", "dist", "old", "characters"}
foldersToSkip := []string{".sync", "characters", "dist", "old"}
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
var err error
gamesAdded = nil
gamesReAdded = nil
gamesChangedTitle = nil
gamesChangedContent = nil
gamesRemoved = nil
soundtracksAdded = nil
soundtracksReAdded = nil
soundtracksChangedTitle = nil
soundtracksChangedContent = nil
soundtracksRemoved = nil
catchedErrors = nil
brokenSongs = nil
gamesBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
soundtracksBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
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, "")
err = repo.SetSoundtrackDeletionDate(BackendCtx())
handleError("SetSoundtrackDeletionDate", err, "")
@@ -224,7 +222,6 @@ func syncGamesNew(full bool) {
if err != nil {
logging.GetLogger().Fatal("Failed to read music directory", zap.String("path", musicPath), zap.String("error", err.Error()))
}
pool, _ = ants.NewPool(10, ants.WithPreAlloc(true))
poolSong, _ = ants.NewPool(10, ants.WithPreAlloc(true))
defer pool.Release()
@@ -236,13 +233,13 @@ func syncGamesNew(full bool) {
for _, dir := range directories {
pool.Submit(func() {
defer syncWg.Done()
syncGameNew(dir, foldersToSkip, musicPath, full)
syncSoundtrack(dir, foldersToSkip, musicPath, full)
})
}
syncWg.Wait()
checkBrokenSongsNew()
checkBrokenSongs()
gamesAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
soundtracksAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
handleError("FindAllSoundtracks After", err, "")
finished := time.Now()
@@ -253,7 +250,7 @@ func syncGamesNew(full bool) {
Syncing = false
}
func checkBrokenSongsNew() {
func checkBrokenSongs() {
allSongs, err := repo.FetchAllSongs(BackendCtx())
handleError("FetchAllSongs", err, "")
var brokenWg sync.WaitGroup
@@ -264,7 +261,7 @@ func checkBrokenSongsNew() {
for _, song := range allSongs {
poolBroken.Submit(func() {
defer brokenWg.Done()
checkBrokenSongNew(song)
checkBrokenSong(song)
})
}
brokenWg.Wait()
@@ -274,7 +271,7 @@ func checkBrokenSongsNew() {
}
}
func checkBrokenSongNew(song repository.Song) {
func checkBrokenSong(song repository.Song) {
//Check if file exists and open
openFile, err := os.Open(song.Path)
if err != nil {
@@ -289,118 +286,86 @@ 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()) {
logging.GetLogger().Debug("Syncing game", zap.String("game", file.Name()))
gameDir := baseDir + file.Name() + "/"
dirHash := getHashForDir(gameDir)
logging.GetLogger().Debug("Syncing soundtrack", zap.String("soundtrack", file.Name()))
soundtrackDir := baseDir + file.Name() + "/"
dirHash := getHashForDir(soundtrackDir)
var status GameStatus = NewGame
var oldGame repository.Soundtrack
var status SoundtrackStatus = NewSoundtrack
var oldSoundtrack repository.Soundtrack
var id int32 = -1
//fmt.Printf("Games before: %d\n", len(gamesBeforeSync))
//fmt.Printf("Soundtracks before: %d\n", len(soundtracksBeforeSync))
for _, currentGame := range allGames {
oldGame = currentGame
//fmt.Printf("%s | %s\n", oldGame.SoundtrackName, oldGame.Hash)
if oldGame.SoundtrackName == file.Name() && oldGame.Hash == dirHash {
for _, currentSoundtrack := range allSoundtracks {
oldSoundtrack = currentSoundtrack
//fmt.Printf("%s | %s\n", oldSoundtrack.SoundtrackName, oldSoundtrack.Hash)
if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash == dirHash {
status = NotChanged
id = oldGame.ID
//fmt.Printf("Game not changed\n")
id = oldSoundtrack.ID
//fmt.Printf("Soundtrack not changed\n")
break
} else if oldGame.SoundtrackName == file.Name() && oldGame.Hash != dirHash {
status = GameChanged
id = oldGame.ID
//fmt.Printf("Game changed\n")
} else if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash != dirHash {
status = SoundtrackChanged
id = oldSoundtrack.ID
//fmt.Printf("Soundtrack changed\n")
break
} else if oldGame.SoundtrackName != file.Name() && oldGame.Hash == dirHash {
} else if oldSoundtrack.SoundtrackName != file.Name() && oldSoundtrack.Hash == dirHash {
status = TitleChanged
id = oldGame.ID
id = oldSoundtrack.ID
//fmt.Printf("SoundtrackName changed\n")
break
}
}
if full {
if full && status != NewSoundtrack {
status = TitleChanged
}
entries, err := os.ReadDir(gameDir)
entries, err := os.ReadDir(soundtrackDir)
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 {
case NewGame:
if id != -1 {
for _, entry := range entries {
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.String("game_dir", gameDir))
fileName := gameDir + "/." + strconv.Itoa(int(id)) + ".id"
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",
case NewSoundtrack:
id = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
logging.GetLogger().Debug("New soundtrack detected",
zap.Int32("id", id),
zap.String("game", file.Name()),
zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash),
zap.String("status", status.String()))
gamesAdded = append(gamesAdded, file.Name())
newCheckSongs(entries, gameDir, id)
case GameChanged:
logging.GetLogger().Debug("Game changed",
soundtracksAdded = append(soundtracksAdded, file.Name())
checkSongs(entries, soundtrackDir, id)
case SoundtrackChanged:
logging.GetLogger().Debug("Soundtrack changed",
zap.Int32("id", id),
zap.String("game", file.Name()),
zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash),
zap.String("status", status.String()))
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
handleError("UpdateSoundtrackHash", err, "")
gamesChangedContent = append(gamesChangedContent, file.Name())
newCheckSongs(entries, gameDir, id)
soundtracksChangedContent = append(soundtracksChangedContent, file.Name())
checkSongs(entries, soundtrackDir, id)
case TitleChanged:
logging.GetLogger().Debug("Game title changed",
logging.GetLogger().Debug("Soundtrack title changed",
zap.Int32("id", id),
zap.String("oldName", oldGame.SoundtrackName),
zap.String("oldName", oldSoundtrack.SoundtrackName),
zap.String("newName", file.Name()),
zap.String("hash", dirHash),
zap.String("status", status.String()))
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: gameDir, ID: id})
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id})
handleError("UpdateSoundtrackName", err, "")
newCheckSongs(entries, gameDir, id)
if gamesChangedTitle == nil {
gamesChangedTitle = make(map[string]string)
checkSongs(entries, soundtrackDir, id)
if soundtracksChangedTitle == nil {
soundtracksChangedTitle = make(map[string]string)
}
gamesChangedTitle[oldGame.SoundtrackName] = file.Name()
soundtracksChangedTitle[oldSoundtrack.SoundtrackName] = file.Name()
case NotChanged:
var found bool = false
for _, beforeGame := range gamesBeforeSync {
if dirHash == beforeGame.Hash {
for _, beforeSoundtrack := range soundtracksBeforeSync {
if dirHash == beforeSoundtrack.Hash {
found = true
logging.GetLogger().Debug("Game not changed",
logging.GetLogger().Debug("Soundtrack not changed",
zap.Int32("id", id),
zap.String("newName", file.Name()),
zap.String("hash", dirHash),
@@ -408,9 +373,9 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
}
}
if !found {
newCheckSongs(entries, gameDir, id)
gamesReAdded = append(gamesReAdded, file.Name())
logging.GetLogger().Debug("Game added again",
checkSongs(entries, soundtrackDir, id)
soundtracksReAdded = append(soundtracksReAdded, file.Name())
logging.GetLogger().Debug("Soundtrack added again",
zap.Int32("id", id),
zap.String("newName", file.Name()),
zap.String("hash", dirHash),
@@ -418,9 +383,9 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
}
}
logging.GetLogger().Debug("Game sync status",
logging.GetLogger().Debug("Soundtrack sync status",
zap.Int32("id", id),
zap.String("game", file.Name()),
zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash),
zap.String("status", status.String()))
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
@@ -433,24 +398,24 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
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 {
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash})
handleError("InsertSoundtrack", err, "")
if err != nil {
logging.GetLogger().Warn("ID collision detected, resetting sequence")
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
logging.GetLogger().Debug("Resetting game ID sequence")
logging.GetLogger().Debug("Resetting soundtrack ID sequence")
_, err = repo.ResetSoundtrackIdSeq(BackendCtx())
handleError("ResetSoundtrackIdSeq", err, "")
id = insertGameNew(name, path, hash)
id = insertSoundtrack(name, path, hash)
}
}
return id
}
func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 {
//hasher := md5.New()
var numberOfSongs int32
numberOfFiles := len(entries)
@@ -460,7 +425,7 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
for _, entry := range entries {
poolSong.Submit(func() {
defer songWg.Done()
if newCheckSong(entry, gameDir, id) {
if checkSong(entry, soundtrackDir, id) {
numberOfSongs++
}
})
@@ -469,7 +434,7 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
return numberOfSongs
}
func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool {
fileInfo, err := entry.Info()
if err != nil {
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
@@ -477,7 +442,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
}
if isSong(fileInfo) {
path := gameDir + entry.Name()
path := soundtrackDir + entry.Name()
songHash := getHashForFile(path)
//numberOfSongs++
@@ -493,7 +458,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
}
}
logging.GetLogger().Debug("Song changed",
zap.Int32("game_id", id),
zap.Int32("soundtrack_id", id),
zap.String("path", path),
zap.String("song_name", songName),
zap.String("song_hash", songHash))
@@ -551,8 +516,8 @@ func handleError(funcName string, err error, msg string) {
}
}
func getHashForDir(gameDir string) string {
directory, _ := directory_checksum.ScanDirectory(gameDir, afero.NewOsFs())
func getHashForDir(soundtrackDir string) string {
directory, _ := directory_checksum.ScanDirectory(soundtrackDir, afero.NewOsFs())
hash, _ := directory.ComputeDirectoryChecksums()
return hash
@@ -573,7 +538,7 @@ func getHashForFile(path string) string {
return hex.EncodeToString(hasher.Sum(nil))
}
func getIdFromFileNew(file os.FileInfo) int32 {
func getIdFromFile(file os.FileInfo) int32 {
name := file.Name()
if !file.IsDir() && strings.HasSuffix(name, ".id") {
name = strings.Replace(name, ".id", "", 1)
+18 -18
View File
@@ -9,10 +9,10 @@ import (
func TestContains(t *testing.T) {
tests := []struct {
name string
slice []string
search string
expected bool
name string
slice []string
search string
expected bool
}{
{
name: "element exists",
@@ -155,9 +155,9 @@ func TestGetIdFromFileNew(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getIdFromFileNew(tt.fileInfo)
result := getIdFromFile(tt.fileInfo)
if result != tt.expected {
t.Errorf("getIdFromFileNew() = %v, want %v", result, tt.expected)
t.Errorf("getIdFromFile() = %v, want %v", result, tt.expected)
}
})
}
@@ -172,10 +172,10 @@ type mockFileInfoForSong struct {
func (m *mockFileInfoForSong) Name() string { return m.name }
func (m *mockFileInfoForSong) Size() int64 { return m.size }
func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForSong) IsDir() bool { return m.isDir }
func (m *mockFileInfoForSong) Sys() interface{} { return nil }
func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForSong) IsDir() bool { return m.isDir }
func (m *mockFileInfoForSong) Sys() interface{} { return nil }
type mockFileInfoForCover struct {
name string
@@ -185,10 +185,10 @@ type mockFileInfoForCover struct {
func (m *mockFileInfoForCover) Name() string { return m.name }
func (m *mockFileInfoForCover) Size() int64 { return m.size }
func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForCover) IsDir() bool { return m.isDir }
func (m *mockFileInfoForCover) Sys() interface{} { return nil }
func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForCover) IsDir() bool { return m.isDir }
func (m *mockFileInfoForCover) Sys() interface{} { return nil }
type mockFileInfoForId struct {
name string
@@ -198,7 +198,7 @@ type mockFileInfoForId struct {
func (m *mockFileInfoForId) Name() string { return m.name }
func (m *mockFileInfoForId) Size() int64 { return m.size }
func (m *mockFileInfoForId) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForId) IsDir() bool { return m.isDir }
func (m *mockFileInfoForId) Sys() interface{} { return nil }
func (m *mockFileInfoForId) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForId) IsDir() bool { return m.isDir }
func (m *mockFileInfoForId) Sys() interface{} { return nil }
+21
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"time"
"music-server/internal/logging"
@@ -59,6 +60,26 @@ func (db *Database) Close() {
}
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func (db *Database) Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
err := db.Pool.Ping(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = err.Error()
return stats
}
stats["status"] = "up"
return stats
}
// RunMigrations runs all pending database migrations to the latest version.
// Uses the existing pool to extract connection details.
func (db *Database) RunMigrations() error {
+3 -3
View File
@@ -56,10 +56,10 @@ func CloseDb() {
Dbpool.Close()
}
func ResetGameIdSeq() {
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);")
func ResetSoundtrackIdSeq() {
_, err := Dbpool.Query(Ctx, "SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);")
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()))
}
}
+19 -10
View File
@@ -1,7 +1,6 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
@@ -80,9 +79,9 @@ func TestMigrationsStepByStep(t *testing.T) {
}
for _, s := range songs {
_, err := db.Exec(`INSERT INTO song (game_id, song_name, path)
VALUES ($1, $2, $3)`,
s.gameID, s.name, s.path)
_, err := db.Exec(`INSERT INTO song (game_id, song_name, path, hash)
VALUES ($1, $2, $3, $4)`,
s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name))
require.NoError(t, err, "Failed to insert song %s", s.name)
}
@@ -95,9 +94,9 @@ func TestMigrationsStepByStep(t *testing.T) {
var songCount int
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
require.NoError(t, err)
require.Equal(t, 8, songCount, "Expected 8 songs")
require.Equal(t, 9, songCount, "Expected 9 songs")
t.Log("✓ Manually inserted 5 games with 8 songs")
t.Log("✓ Manually inserted 5 games with 9 songs")
})
// Step 3: Apply migration 5 (rename game→soundtrack)
@@ -126,7 +125,7 @@ func TestMigrationsStepByStep(t *testing.T) {
var songCount int
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
require.NoError(t, err)
require.Equal(t, 8, songCount, "Expected 8 songs after migration")
require.Equal(t, 9, songCount, "Expected 9 songs after migration")
// Verify data integrity: soundtrack_name values
rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id")
@@ -215,13 +214,18 @@ func applyMigrations(t *testing.T, host, port, user, password, dbname string, st
require.NoError(t, err)
m, err := migrate.NewWithDatabaseInstance(
"file://internal/db/migrations",
"file://migrations",
"postgres", driver)
require.NoError(t, err)
// Get current version
version, _, err := m.Version()
require.NoError(t, err)
if err != nil && err != migrate.ErrNilVersion {
require.NoError(t, err)
}
if err == migrate.ErrNilVersion {
version = 0
}
t.Logf("Current migration version: %d", version)
// Apply exactly 'steps' migrations
@@ -237,6 +241,11 @@ func applyMigrations(t *testing.T, host, port, user, password, dbname string, st
// Get new version
newVersion, _, err := m.Version()
require.NoError(t, err)
if err != nil && err != migrate.ErrNilVersion {
require.NoError(t, err)
}
if err == migrate.ErrNilVersion {
newVersion = 0
}
t.Logf("Migration version after applying %d steps: %d", steps, newVersion)
}
@@ -13,7 +13,6 @@ ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id;
-- Update song primary key
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
ALTER TABLE song ADD PRIMARY KEY (soundtrack_id, path);
ALTER TABLE song RENAME CONSTRAINT song_pkey TO song_pkey_soundtrack;
-- Update song_list table references
ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name;
+10 -10
View File
@@ -1,5 +1,5 @@
-- Most played soundtracks with their songs
-- name: GetMostPlayedGamesWithSongs :many
-- name: GetMostPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -21,7 +21,7 @@ ORDER BY g.times_played DESC, g.soundtrack_name
LIMIT $1;
-- Least played soundtracks with their songs
-- name: GetLeastPlayedGamesWithSongs :many
-- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -43,7 +43,7 @@ ORDER BY g.times_played ASC, g.soundtrack_name
LIMIT $1;
-- Most played songs with their soundtrack info
-- name: GetMostPlayedSongsWithGame :many
-- name: GetMostPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.soundtrack_name,
@@ -58,7 +58,7 @@ ORDER BY s.times_played DESC, s.song_name
LIMIT $1;
-- Least played songs with their soundtrack info
-- name: GetLeastPlayedSongsWithGame :many
-- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.soundtrack_name,
@@ -72,8 +72,8 @@ WHERE g.deleted IS NULL
ORDER BY s.times_played ASC, s.song_name
LIMIT $1;
-- Games that have never been played (times_played = 0)
-- name: GetNeverPlayedGames :many
-- Soundtracks that have never been played (times_played = 0)
-- name: GetNeverPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -93,7 +93,7 @@ GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
ORDER BY g.soundtrack_name;
-- Last played soundtracks (most recently played)
-- name: GetLastPlayedGames :many
-- name: GetLastPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -114,7 +114,7 @@ ORDER BY g.last_played DESC
LIMIT $1;
-- Oldest played soundtracks (least recently played, but has been played at least once)
-- name: GetOldestPlayedGames :many
-- name: GetOldestPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -138,8 +138,8 @@ LIMIT $1;
-- name: GetStatisticsSummary :one
SELECT
COUNT(*) as total_soundtracks,
SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks,
SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_soundtracks,
COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint as played_soundtracks,
COALESCE(SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END), 0)::bigint as never_played_soundtracks,
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
+10 -20
View File
@@ -10,14 +10,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type IDMigrationStatus struct {
TableName string `json:"table_name"`
TotalRows int32 `json:"total_rows"`
MigratedRows int32 `json:"migrated_rows"`
Completed bool `json:"completed"`
StartedAt *time.Time `json:"started_at"`
}
type Session struct {
Token string `json:"token"`
IpAddress string `json:"ip_address"`
@@ -35,7 +27,6 @@ type Song struct {
Hash string `json:"hash"`
FileName *string `json:"file_name"`
ID pgtype.Int4 `json:"id"`
Uuid pgtype.UUID `json:"uuid"`
}
type SongList struct {
@@ -47,17 +38,16 @@ type SongList struct {
}
type Soundtrack struct {
ID int32 `json:"id"`
SoundtrackName string `json:"soundtrack_name"`
Added time.Time `json:"added"`
Deleted *time.Time `json:"deleted"`
LastChanged *time.Time `json:"last_changed"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played"`
NumberOfSongs int32 `json:"number_of_songs"`
Hash string `json:"hash"`
Uuid pgtype.UUID `json:"uuid"`
ID int32 `json:"id"`
SoundtrackName string `json:"soundtrack_name"`
Added time.Time `json:"added"`
Deleted *time.Time `json:"deleted"`
LastChanged *time.Time `json:"last_changed"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played"`
NumberOfSongs int32 `json:"number_of_songs"`
Hash string `json:"hash"`
}
type Vgmq struct {
+4 -8
View File
@@ -110,7 +110,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int
}
const fetchAllSongs = `-- name: FetchAllSongs :many
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song
`
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
@@ -130,7 +130,6 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
&i.Hash,
&i.FileName,
&i.ID,
&i.Uuid,
); err != nil {
return nil, err
}
@@ -143,7 +142,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
}
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id
FROM song
WHERE soundtrack_id = $1
`
@@ -165,7 +164,6 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
&i.Hash,
&i.FileName,
&i.ID,
&i.Uuid,
); err != nil {
return nil, err
}
@@ -178,7 +176,7 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
}
const getSongById = `-- name: GetSongById :one
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE id = $1
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE id = $1
`
func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) {
@@ -192,13 +190,12 @@ func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error)
&i.Hash,
&i.FileName,
&i.ID,
&i.Uuid,
)
return i, err
}
const getSongWithHash = `-- name: GetSongWithHash :one
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE hash = $1
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE hash = $1
`
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
@@ -212,7 +209,6 @@ func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error
&i.Hash,
&i.FileName,
&i.ID,
&i.Uuid,
)
return i, err
}
+3 -6
View File
@@ -28,7 +28,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
}
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
FROM soundtrack
WHERE deleted IS NULL
ORDER BY soundtrack_name
@@ -54,7 +54,6 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.LastPlayed,
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
); err != nil {
return nil, err
}
@@ -67,7 +66,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
}
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
FROM soundtrack
ORDER BY soundtrack_name
`
@@ -92,7 +91,6 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.LastPlayed,
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
); err != nil {
return nil, err
}
@@ -116,7 +114,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
}
const getSoundtrackById = `-- name: GetSoundtrackById :one
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
FROM soundtrack
WHERE id = $1
AND deleted IS NULL
@@ -136,7 +134,6 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.LastPlayed,
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
)
return i, err
}
+45 -45
View File
@@ -10,7 +10,7 @@ import (
"time"
)
const getLastPlayedGames = `-- name: GetLastPlayedGames :many
const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -31,7 +31,7 @@ ORDER BY g.last_played DESC
LIMIT $1
`
type GetLastPlayedGamesRow struct {
type GetLastPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -40,15 +40,15 @@ type GetLastPlayedGamesRow struct {
}
// Last played soundtracks (most recently played)
func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLastPlayedGamesRow, error) {
rows, err := q.db.Query(ctx, getLastPlayedGames, limit)
func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getLastPlayedSoundtracks, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetLastPlayedGamesRow
var items []GetLastPlayedSoundtracksRow
for rows.Next() {
var i GetLastPlayedGamesRow
var i GetLastPlayedSoundtracksRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
@@ -66,7 +66,7 @@ func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLas
return items, nil
}
const getLeastPlayedGamesWithSongs = `-- name: GetLeastPlayedGamesWithSongs :many
const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -88,7 +88,7 @@ ORDER BY g.times_played ASC, g.soundtrack_name
LIMIT $1
`
type GetLeastPlayedGamesWithSongsRow struct {
type GetLeastPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -97,15 +97,15 @@ type GetLeastPlayedGamesWithSongsRow struct {
}
// Least played soundtracks with their songs
func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedGamesWithSongsRow, error) {
rows, err := q.db.Query(ctx, getLeastPlayedGamesWithSongs, limit)
func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) {
rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetLeastPlayedGamesWithSongsRow
var items []GetLeastPlayedSoundtracksWithSongsRow
for rows.Next() {
var i GetLeastPlayedGamesWithSongsRow
var i GetLeastPlayedSoundtracksWithSongsRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
@@ -123,7 +123,7 @@ func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32)
return items, nil
}
const getLeastPlayedSongsWithGame = `-- name: GetLeastPlayedSongsWithGame :many
const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.soundtrack_name,
@@ -138,7 +138,7 @@ ORDER BY s.times_played ASC, s.song_name
LIMIT $1
`
type GetLeastPlayedSongsWithGameRow struct {
type GetLeastPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
@@ -148,15 +148,15 @@ type GetLeastPlayedSongsWithGameRow struct {
}
// Least played songs with their soundtrack info
func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithGameRow, error) {
rows, err := q.db.Query(ctx, getLeastPlayedSongsWithGame, limit)
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 []GetLeastPlayedSongsWithGameRow
var items []GetLeastPlayedSongsWithSoundtrackRow
for rows.Next() {
var i GetLeastPlayedSongsWithGameRow
var i GetLeastPlayedSongsWithSoundtrackRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
@@ -175,7 +175,7 @@ func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32)
return items, nil
}
const getMostPlayedGamesWithSongs = `-- name: GetMostPlayedGamesWithSongs :many
const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -197,7 +197,7 @@ ORDER BY g.times_played DESC, g.soundtrack_name
LIMIT $1
`
type GetMostPlayedGamesWithSongsRow struct {
type GetMostPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -206,15 +206,15 @@ type GetMostPlayedGamesWithSongsRow struct {
}
// Most played soundtracks with their songs
func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedGamesWithSongsRow, error) {
rows, err := q.db.Query(ctx, getMostPlayedGamesWithSongs, limit)
func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) {
rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetMostPlayedGamesWithSongsRow
var items []GetMostPlayedSoundtracksWithSongsRow
for rows.Next() {
var i GetMostPlayedGamesWithSongsRow
var i GetMostPlayedSoundtracksWithSongsRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
@@ -232,7 +232,7 @@ func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32)
return items, nil
}
const getMostPlayedSongsWithGame = `-- name: GetMostPlayedSongsWithGame :many
const getMostPlayedSongsWithSoundtrack = `-- name: GetMostPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.soundtrack_name,
@@ -247,7 +247,7 @@ ORDER BY s.times_played DESC, s.song_name
LIMIT $1
`
type GetMostPlayedSongsWithGameRow struct {
type GetMostPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
@@ -257,15 +257,15 @@ type GetMostPlayedSongsWithGameRow struct {
}
// 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)
func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithSoundtrackRow, error) {
rows, err := q.db.Query(ctx, getMostPlayedSongsWithSoundtrack, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetMostPlayedSongsWithGameRow
var items []GetMostPlayedSongsWithSoundtrackRow
for rows.Next() {
var i GetMostPlayedSongsWithGameRow
var i GetMostPlayedSongsWithSoundtrackRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
@@ -284,7 +284,7 @@ func (q *Queries) GetMostPlayedSongsWithGame(ctx context.Context, limit int32) (
return items, nil
}
const getNeverPlayedGames = `-- name: GetNeverPlayedGames :many
const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -304,7 +304,7 @@ GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
ORDER BY g.soundtrack_name
`
type GetNeverPlayedGamesRow struct {
type GetNeverPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -312,16 +312,16 @@ type GetNeverPlayedGamesRow struct {
Songs []byte `json:"songs"`
}
// Games that have never been played (times_played = 0)
func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGamesRow, error) {
rows, err := q.db.Query(ctx, getNeverPlayedGames)
// Soundtracks that have never been played (times_played = 0)
func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getNeverPlayedSoundtracks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetNeverPlayedGamesRow
var items []GetNeverPlayedSoundtracksRow
for rows.Next() {
var i GetNeverPlayedGamesRow
var i GetNeverPlayedSoundtracksRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
@@ -339,7 +339,7 @@ func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGame
return items, nil
}
const getOldestPlayedGames = `-- name: GetOldestPlayedGames :many
const getOldestPlayedSoundtracks = `-- name: GetOldestPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.soundtrack_name,
@@ -360,7 +360,7 @@ ORDER BY g.last_played ASC
LIMIT $1
`
type GetOldestPlayedGamesRow struct {
type GetOldestPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
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)
func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetOldestPlayedGamesRow, error) {
rows, err := q.db.Query(ctx, getOldestPlayedGames, limit)
func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getOldestPlayedSoundtracks, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetOldestPlayedGamesRow
var items []GetOldestPlayedSoundtracksRow
for rows.Next() {
var i GetOldestPlayedGamesRow
var i GetOldestPlayedSoundtracksRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackName,
@@ -398,8 +398,8 @@ func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetO
const getStatisticsSummary = `-- name: GetStatisticsSummary :one
SELECT
COUNT(*) as total_soundtracks,
SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks,
SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_soundtracks,
COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint as played_soundtracks,
COALESCE(SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END), 0)::bigint as never_played_soundtracks,
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
+25 -9
View File
@@ -54,8 +54,19 @@ func TestSetupDB(t *testing.T) {
t.Fatalf("Failed to initialize test database: %v", err)
}
// Clean up any existing schema to ensure clean state
ctx := context.Background()
_, err = TestDatabase.Pool.Exec(ctx, "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;")
if err != nil {
t.Logf("Warning: Could not clean schema: %v", err)
// Continue anyway, migrations might still work
}
// Run migrations
if err := TestDatabase.RunMigrations(); err != nil {
// Clean up on failure to prevent nil pointer issues in other tests
TestDatabase.Close()
TestDatabase = nil
t.Fatalf("Failed to run migrations: %v", err)
}
})
@@ -97,10 +108,11 @@ func createTestDatabase(host, port, dbname, user, password string) {
// "closed pool" errors when tests run sequentially
func TestTearDownDB(t *testing.T) {
// CloseDb() // Disabled to prevent pool closure between sequential tests
if TestDatabase != nil {
TestDatabase.Close()
TestDatabase = nil
}
// Note: We also don't nil TestDatabase to allow reuse across tests
// if TestDatabase != nil {
// TestDatabase.Close()
// TestDatabase = nil
// }
}
// TestClearDatabase clears all data from the test database
@@ -112,10 +124,13 @@ func TestClearDatabase(t *testing.T) {
// Clear all tables in reverse order to respect foreign keys
// Note: This assumes the tables exist and have the expected structure
// After migration 000005, game table was renamed to soundtrack
tables := []string{
"song_list",
"song",
"game",
"soundtrack",
"vgmq",
"sessions",
}
ctx := context.Background()
@@ -126,9 +141,10 @@ func TestClearDatabase(t *testing.T) {
}
}
// Reset sequences
_, err := TestDatabase.Pool.Exec(ctx, "SELECT setval('game_id_seq', 1, false)")
if err != nil {
t.Logf("Failed to reset game_id_seq: %v", err)
// Reset sequences (renamed from game_id_seq to soundtrack_id_seq in migration 000005)
var seqErr error
_, seqErr = TestDatabase.Pool.Exec(ctx, "SELECT setval('soundtrack_id_seq', 1, false)")
if seqErr != nil {
t.Logf("Failed to reset soundtrack_id_seq: %v", seqErr)
}
}
+8 -1
View File
@@ -2,6 +2,7 @@ package server
import (
"net/http"
"os"
"github.com/labstack/echo/v5"
"music-server/internal/backend"
@@ -38,5 +39,11 @@ func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
// @Router /character [get]
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
character := ctx.QueryParam("name")
return ctx.File(backend.GetCharacter(character))
characterPath := backend.GetCharacter(character)
file, err := os.Open(characterPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "image/png", file)
}
+4 -3
View File
@@ -8,10 +8,11 @@ import (
)
type HealthHandler struct {
db *db.Database
}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
func NewHealthHandler(database *db.Database) *HealthHandler {
return &HealthHandler{db: database}
}
// HealthCheck godoc
@@ -24,5 +25,5 @@ func NewHealthHandler() *HealthHandler {
// @Success 200 {string} string "OK"
// @Router /health [get]
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, db.Health())
return ctx.JSON(http.StatusOK, h.db.Health())
}
+1 -6
View File
@@ -5,18 +5,13 @@ import (
"net/http"
"testing"
"music-server/internal/db"
"github.com/stretchr/testify/assert"
)
// TestHealthCheck verifies the health endpoint returns database status
func TestHealthCheck(t *testing.T) {
// Setup database
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// No explicit teardown - handled by StartTestServer's sync.Once
resp := MakeTestRequest(t, e, "GET", "/health")
assert.Equal(t, http.StatusOK, resp.Code)
+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)
}
}
+3 -55
View File
@@ -31,10 +31,6 @@ func NewMusicHandler() *MusicHandler {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music [get]
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")
if song == "" {
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"
// @Router /music/soundTest [get]
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()
file, err := os.Open(songPath)
if err != nil {
@@ -80,10 +72,6 @@ func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/reset [get]
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()
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"
// @Router /music/rand [get]
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()
file, err := os.Open(songPath)
if err != nil {
@@ -121,10 +105,6 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/low [get]
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()
file, err := os.Open(songPath)
if err != nil {
@@ -144,10 +124,6 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/classic [get]
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()
file, err := os.Open(songPath)
if err != nil {
@@ -193,10 +169,6 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/next [get]
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()
file, err := os.Open(songPath)
if err != nil {
@@ -216,10 +188,6 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/previous [get]
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()
file, err := os.Open(songPath)
if err != nil {
@@ -231,7 +199,7 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
// GetAllSoundtracks godoc
// @Summary Get all soundtracks
// @Description Returns a list of all games in order
// @Description Returns a list of all soundtracks in order
// @Tags music
// @Accept json
// @Produce json
@@ -239,17 +207,13 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/order [get]
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()
return ctx.JSON(http.StatusOK, soundtrackList)
}
// GetAllSoundtracksRandom godoc
// @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
// @Accept json
// @Produce json
@@ -257,10 +221,6 @@ func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/random [get]
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()
return ctx.JSON(http.StatusOK, soundtrackList)
}
@@ -277,15 +237,11 @@ func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/played [put]
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"))
if err != nil {
return ctx.JSON(http.StatusBadRequest, err.Error())
}
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
backend.SetPlayed(song)
return ctx.NoContent(http.StatusOK)
}
@@ -299,10 +255,6 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addQue [get]
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()
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"
// @Router /music/addPlayed [get]
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()
return ctx.NoContent(http.StatusOK)
}
+36 -35
View File
@@ -50,8 +50,9 @@ func (s *Server) RegisterRoutes() http.Handler {
fileServer := http.FileServer(http.FS(web.Assets))
e.GET("/assets/*", echo.WrapHandler(fileServer))
e.GET("/search", echo.WrapHandler(templ.Handler(web.HelloForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindGameWebHandler)))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
e.POST("/findfuzzy", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackFuzzyWebHandler)))
e.Static("/", "/frontend")
@@ -63,7 +64,7 @@ func (s *Server) RegisterRoutes() http.Handler {
// ============================================
deprecatedMiddleware := middleware.DeprecationMiddleware
health := NewHealthHandler()
health := NewHealthHandler(s.db)
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
version := NewVersionHandler()
@@ -82,40 +83,40 @@ func (s *Server) RegisterRoutes() http.Handler {
sync := NewSyncHandler()
syncGroup := e.Group("/sync")
syncGroup.GET("", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
syncGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
syncGroup.GET("/new", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
syncGroup.GET("/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull))
syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull))
syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetDB))
syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB)))
music := NewMusicHandler()
musicGroup := e.Group("/music")
musicGroup.GET("", deprecatedMiddleware(music.GetSong))
musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong))
musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic))
musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong))
musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance))
musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic))
musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong)))
musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong)))
musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic)))
musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong)))
musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance)))
musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic)))
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong))
musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong))
musicGroup.GET("/all", deprecatedMiddleware(music.GetAllSoundtracksRandom))
musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllSoundtracks))
musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllSoundtracksRandom))
musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed))
musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue))
musicGroup.GET("/addPlayed", deprecatedMiddleware(music.AddLatestPlayed))
musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong)))
musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong)))
musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks)))
musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed)))
musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue)))
musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed)))
// ============================================
// API v1 Routes with Token Authentication
// ============================================
// Create /api/v1 group
apiV1 := e.Group("/api/v1")
// Public endpoints - no token required
apiV1.POST("/token", func(c *echo.Context) error {
return s.tokenHandler.CreateTokenHandler(c)
@@ -136,20 +137,20 @@ func (s *Server) RegisterRoutes() http.Handler {
// Statistics API endpoints (protected by token auth)
statistics := s.statisticsHandler
protectedV1.GET("/statistics/games/most-played", func(c *echo.Context) error {
return statistics.GetMostPlayedGames(c)
protectedV1.GET("/statistics/soundtracks/most-played", func(c *echo.Context) error {
return statistics.GetMostPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/games/least-played", func(c *echo.Context) error {
return statistics.GetLeastPlayedGames(c)
protectedV1.GET("/statistics/soundtracks/least-played", func(c *echo.Context) error {
return statistics.GetLeastPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/games/never-played", func(c *echo.Context) error {
return statistics.GetNeverPlayedGames(c)
protectedV1.GET("/statistics/soundtracks/never-played", func(c *echo.Context) error {
return statistics.GetNeverPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/games/last-played", func(c *echo.Context) error {
return statistics.GetLastPlayedGames(c)
protectedV1.GET("/statistics/soundtracks/last-played", func(c *echo.Context) error {
return statistics.GetLastPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/games/oldest-played", func(c *echo.Context) error {
return statistics.GetOldestPlayedGames(c)
protectedV1.GET("/statistics/soundtracks/oldest-played", func(c *echo.Context) error {
return statistics.GetOldestPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error {
return statistics.GetMostPlayedSongs(c)
+62 -62
View File
@@ -23,20 +23,20 @@ func NewStatisticsHandler() *StatisticsHandler {
}
}
// GetMostPlayedGames returns top N most played games with songs
// GET /api/v1/statistics/games/most-played
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
// GET /api/v1/statistics/soundtracks/most-played
//
// @Summary Get most played games
// @Description Returns the top N most played games with their songs
// @Summary Get most played soundtracks
// @Description Returns the top N most played soundtracks with their songs
// @Tags statistics
// @Accept json
// @Produce json
// @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 500 {object} map[string]string
// @Router /api/v1/statistics/games/most-played [get]
func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error {
// @Router /api/v1/statistics/soundtracks/most-played [get]
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 // default
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
@@ -50,29 +50,29 @@ func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error {
limit = 100
}
}
games, err := h.statsBackend.GetMostPlayedGamesWithSongs(int32(limit))
soundtracks, err := h.statsBackend.GetMostPlayedSoundtracksWithSongs(int32(limit))
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.StatusOK, games)
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetLeastPlayedGames returns top N least played games with songs
// GET /api/v1/statistics/games/least-played
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
// GET /api/v1/statistics/soundtracks/least-played
//
// @Summary Get least played games
// @Description Returns the top N least played games with their songs
// @Summary Get least played soundtracks
// @Description Returns the top N least played soundtracks with their songs
// @Tags statistics
// @Accept json
// @Produce json
// @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 500 {object} map[string]string
// @Router /api/v1/statistics/games/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error {
// @Router /api/v1/statistics/soundtracks/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
@@ -85,20 +85,20 @@ func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error {
limit = 100
}
}
games, err := h.statsBackend.GetLeastPlayedGamesWithSongs(int32(limit))
soundtracks, err := h.statsBackend.GetLeastPlayedSoundtracksWithSongs(int32(limit))
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.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
//
// @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
// @Accept json
// @Produce json
@@ -120,8 +120,8 @@ func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
limit = 100
}
}
songs, err := h.statsBackend.GetMostPlayedSongsWithGame(int32(limit))
songs, err := h.statsBackend.GetMostPlayedSongsWithSoundtrack(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
@@ -129,11 +129,11 @@ func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
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
//
// @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
// @Accept json
// @Produce json
@@ -155,8 +155,8 @@ func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
limit = 100
}
}
songs, err := h.statsBackend.GetLeastPlayedSongsWithGame(int32(limit))
songs, err := h.statsBackend.GetLeastPlayedSongsWithSoundtrack(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
@@ -164,40 +164,40 @@ func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, songs)
}
// GetNeverPlayedGames returns games that have never been played
// GET /api/v1/statistics/games/never-played
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
// GET /api/v1/statistics/soundtracks/never-played
//
// @Summary Get never played games
// @Description Returns all games that have never been played (times_played = 0)
// @Summary Get never played soundtracks
// @Description Returns all soundtracks that have never been played (times_played = 0)
// @Tags statistics
// @Accept json
// @Produce json
// @Success 200 {array} backend.GameWithSongs
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedGames(ctx *echo.Context) error {
games, err := h.statsBackend.GetNeverPlayedGames()
// @Router /api/v1/statistics/soundtracks/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
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.StatusOK, games)
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetLastPlayedGames returns most recently played games
// GET /api/v1/statistics/games/last-played
// GetLastPlayedSoundtracks returns most recently played soundtracks
// GET /api/v1/statistics/soundtracks/last-played
//
// @Summary Get last played games
// @Description Returns the most recently played games
// @Summary Get last played soundtracks
// @Description Returns the most recently played soundtracks
// @Tags statistics
// @Accept json
// @Produce json
// @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 500 {object} map[string]string
// @Router /api/v1/statistics/games/last-played [get]
func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error {
// @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
@@ -210,29 +210,29 @@ func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error {
limit = 100
}
}
games, err := h.statsBackend.GetLastPlayedGames(int32(limit))
soundtracks, err := h.statsBackend.GetLastPlayedSoundtracks(int32(limit))
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.StatusOK, games)
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetOldestPlayedGames returns least recently played games
// GET /api/v1/statistics/games/oldest-played
// GetOldestPlayedSoundtracks returns least recently played soundtracks
// GET /api/v1/statistics/soundtracks/oldest-played
//
// @Summary Get oldest played games
// @Description Returns the least recently played games (that have been played at least once)
// @Summary Get oldest played soundtracks
// @Description Returns the least recently played soundtracks (that have been played at least once)
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error {
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
limit := 10
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
@@ -245,13 +245,13 @@ func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error {
limit = 100
}
}
games, err := h.statsBackend.GetOldestPlayedGames(int32(limit))
soundtracks, err := h.statsBackend.GetOldestPlayedSoundtracks(int32(limit))
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.StatusOK, games)
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetStatisticsSummary returns overall statistics
+10 -5
View File
@@ -73,6 +73,11 @@ func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
// Verify data via statistics endpoint
req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -85,9 +90,9 @@ func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
err := json.Unmarshal(rec.Body.Bytes(), &summary)
require.NoError(t, err)
// We inserted 5 soundtracks, so total should be at least 5
// (there might be existing data)
require.GreaterOrEqual(t, summary.TotalGames, int64(5))
// After sync with /sync/new, only soundtracks matching filesystem remain
// testMusic has 3 soundtracks
require.Equal(t, int64(3), summary.TotalSoundtracks)
}
// insertTestData inserts 5 test soundtracks with songs into the database
@@ -115,8 +120,8 @@ func insertTestData(t *testing.T) {
for _, st := range soundtracks {
_, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{
SoundtrackName: st.name,
Path: st.path,
Hash: "test-hash-" + st.name,
Path: st.path,
Hash: "test-hash-" + st.name,
})
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
}
+8 -18
View File
@@ -36,7 +36,7 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
// SyncSoundtracksNewOnlyChanges godoc
// @Summary Sync soundtracks with only changes
// @Description Starts syncing games with only new changes
// @Description Starts syncing soundtracks with only new changes
// @Tags sync
// @Accept json
// @Produce json
@@ -44,18 +44,15 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync [get]
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")
go backend.SyncSoundtracksNewOnlyChanges()
backend.Syncing = true
go backend.SyncSoundtracksOnlyChanges()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
}
// SyncSoundtracksNewFull godoc
// @Summary Sync all games fully
// @Description Starts a full sync of all games
// @Summary Sync all soundtracks fully
// @Description Starts a full sync of all soundtracks
// @Tags sync
// @Accept json
// @Produce json
@@ -63,18 +60,15 @@ func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/full [get]
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")
go backend.SyncSoundtracksNewFull()
backend.Syncing = true
go backend.SyncSoundtracksFull()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
}
// ResetDB godoc
// @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
// @Accept json
// @Produce json
@@ -82,10 +76,6 @@ func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/reset [get]
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")
backend.ResetDB()
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
+42 -42
View File
@@ -28,7 +28,7 @@ func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
if err == nil && progress.Progress != "" {
// Successfully parsed as ProgressResponse with non-empty progress
t.Logf("Sync progress: %s%%", progress.Progress)
if progress.Progress == "100" {
if progress.Progress == "100" {
t.Log("Sync completed!")
// Wait for Syncing flag to be updated
for j := 0; j < 50; j++ {
@@ -61,7 +61,7 @@ func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
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) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
@@ -74,12 +74,12 @@ func TestSyncPopulatesDatabase(t *testing.T) {
// Clear any existing data first
db.TestClearDatabase(t)
// Before sync - should have no games
// Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool())
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
beforeCount := len(gamesBefore)
t.Logf("Games before sync: %d", beforeCount)
beforeCount := len(soundtracksBefore)
t.Logf("Soundtracks before sync: %d", beforeCount)
assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
// Run sync
@@ -91,14 +91,14 @@ func TestSyncPopulatesDatabase(t *testing.T) {
t.Error("Sync did not complete within timeout")
}
// After sync - should have games
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
// After sync - should have soundtracks
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
afterCount := len(gamesAfter)
t.Logf("Games after sync: %d", afterCount)
afterCount := len(soundtracksAfter)
t.Logf("Soundtracks after sync: %d", afterCount)
// Should have more games than before (unless database was already populated)
assert.True(t, afterCount > 0, "Database should have games after sync")
// Should have more soundtracks than before (unless database was already populated)
assert.True(t, afterCount > 0, "Database should have soundtracks after sync")
}
// TestSyncMakesDifference verifies that sync actually changes the database state
@@ -111,11 +111,11 @@ func TestSyncMakesDifference(t *testing.T) {
// Clear any existing data first
db.TestClearDatabase(t)
// Before sync - should have no games
// Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool())
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
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
resp := MakeTestRequest(t, e, "GET", "/sync/full")
@@ -126,10 +126,10 @@ func TestSyncMakesDifference(t *testing.T) {
t.Error("Sync did not complete within timeout")
}
// After sync - should have games
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
// After sync - should have soundtracks
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
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
@@ -183,8 +183,8 @@ func TestSyncProgress(t *testing.T) {
assert.True(t, foundComplete, "Should have seen completion")
}
// TestSyncGamesNewOnlyChanges verifies the incremental sync endpoint
func TestSyncGamesNewOnlyChanges(t *testing.T) {
// TestSyncSoundtracksNewOnlyChanges verifies the incremental sync endpoint
func TestSyncSoundtracksNewOnlyChanges(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
@@ -200,8 +200,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
// Get initial count
repo := repository.New(backend.BackendPool())
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(gamesBefore)
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(soundtracksBefore)
// Run incremental sync (should not change count if nothing changed)
resp := MakeTestRequest(t, e, "GET", "/sync/new")
@@ -211,16 +211,16 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
time.Sleep(2 * time.Second)
// Count should be the same
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(gamesAfter)
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(soundtracksAfter)
// 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
func TestResetGames(t *testing.T) {
func TestResetSoundtracks(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
@@ -228,8 +228,8 @@ func TestResetGames(t *testing.T) {
// First ensure we have data
repo := repository.New(backend.BackendPool())
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(gamesBefore)
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(soundtracksBefore)
if beforeCount == 0 {
// Run sync to populate
@@ -238,12 +238,12 @@ func TestResetGames(t *testing.T) {
t.Error("Sync did not complete within timeout")
return
}
gamesBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount = len(gamesBefore)
soundtracksBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount = len(soundtracksBefore)
}
t.Logf("Games before reset: %d", beforeCount)
assert.True(t, beforeCount > 0, "Should have games to reset")
t.Logf("Soundtracks before reset: %d", beforeCount)
assert.True(t, beforeCount > 0, "Should have soundtracks to reset")
// Call 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
time.Sleep(1 * time.Second)
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(gamesAfter)
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
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")
}
// TestSyncGamesNewFull verifies the full sync endpoint
// RUN THIS LAST (before TestResetGames)
func TestSyncGamesNewFull(t *testing.T) {
// TestSyncSoundtracksNewFull verifies the full sync endpoint
// RUN THIS LAST (before TestResetSoundtracks)
func TestSyncSoundtracksNewFull(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
@@ -282,8 +282,8 @@ func TestSyncGamesNewFull(t *testing.T) {
// Verify database is populated
repo := repository.New(backend.BackendPool())
games, err := repo.FindAllSoundtracks(backend.BackendCtx())
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
assert.True(t, len(games) > 0, "Database should be populated after full sync")
t.Logf("Full sync populated %d games", len(games))
assert.True(t, len(soundtracks) > 0, "Database should be populated after full sync")
t.Logf("Full sync populated %d soundtracks", len(soundtracks))
}
+4 -3
View File
@@ -50,7 +50,7 @@ func StartTestServer(t *testing.T) *echo.Echo {
// Initialize database for tests
db.TestSetupDB(t)
// Initialize backend with test database pool
// This ensures BackendRepo() and BackendCtx() are available
if db.TestDatabase != nil && db.TestDatabase.Pool != nil {
@@ -59,8 +59,9 @@ func StartTestServer(t *testing.T) *echo.Echo {
// Create a Server instance and get its routes
s := &Server{
db: db.TestDatabase,
tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
db: db.TestDatabase,
tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
statisticsHandler: NewStatisticsHandler(),
}
handler := s.RegisterRoutes()
+18 -18
View File
@@ -16,12 +16,12 @@ import (
// ensureSyncRan ensures that sync has been run before testing music endpoints
func ensureSyncRan(t *testing.T, e *echo.Echo) {
repo := repository.New(backend.BackendPool())
games, err := repo.FindAllSoundtracks(backend.BackendCtx())
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
if len(games) == 0 {
if len(soundtracks) == 0 {
// 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")
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
func TestZGetAllGames(t *testing.T) {
// TestGetAllSoundtracks verifies the /music/all/order endpoint
func TestZGetAllSoundtracks(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
@@ -45,15 +45,15 @@ func TestZGetAllGames(t *testing.T) {
resp := MakeTestRequest(t, e, "GET", "/music/all/order")
assert.Equal(t, http.StatusOK, resp.Code)
var games []string
err := json.Unmarshal(resp.Body.Bytes(), &games)
var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err)
assert.NotEmpty(t, games, "Should have games after sync")
t.Logf("Found %d games", len(games))
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
t.Logf("Found %d soundtracks", len(soundtracks))
}
// TestGetAllGamesRandom verifies the /music/all/random endpoint
func TestZGetAllGamesRandom(t *testing.T) {
// TestGetAllSoundtracksRandom verifies the /music/all/random endpoint
func TestZGetAllSoundtracksRandom(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
@@ -65,17 +65,17 @@ func TestZGetAllGamesRandom(t *testing.T) {
resp := MakeTestRequest(t, e, "GET", "/music/all/random")
assert.Equal(t, http.StatusOK, resp.Code)
var games []string
err := json.Unmarshal(resp.Body.Bytes(), &games)
var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
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)
// We can't easily verify randomness, but we can check it's the same length
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
var gamesOrdered []string
json.Unmarshal(resp2.Body.Bytes(), &gamesOrdered)
assert.Equal(t, len(games), len(gamesOrdered), "Random and ordered should have same count")
var soundtracksOrdered []string
json.Unmarshal(resp2.Body.Bytes(), &soundtracksOrdered)
assert.Equal(t, len(soundtracks), len(soundtracksOrdered), "Random and ordered should have same count")
}
// TestGetRandomSong verifies the /music/rand endpoint
@@ -153,7 +153,7 @@ func TestZGetSongInfo(t *testing.T) {
assert.NoError(t, err)
// Note: CurrentlyPlaying might be false if no song is currently set
// 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
+10 -3
View File
@@ -84,8 +84,13 @@ build-run: build
@go run cmd/main.go
test: build
@echo "Testing..."
@go test ./... -v
@echo "Starting test database container..."
@podman-compose -f compose.test.yaml up -d
@sleep 10
@echo "Running integration tests..."
@just test-integration
@echo "Stopping test database container..."
@just test-integration-down
# Clean the binary
clean:
@@ -105,7 +110,9 @@ podman-down:
# Run integration tests with podman
# Starts a test PostgreSQL container, runs tests, then cleans up
test-integration:
@echo "Starting test database container..."
@echo "Cleaning old test database..."
@podman-compose -f compose.test.yaml down -v
@echo "Starting fresh test database container..."
@podman-compose -f compose.test.yaml up -d
@sleep 10
@echo "Running integration tests..."