21 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
SansanandMistral Vibe b0418b4f38 feat: Add id column to song table and prep for UUID migration
- Add id serial4 PK to song table (was composite PK)
- Update queries to use soundtrack_id + path
- Add UUID columns to soundtrack and song (nullable)
- Add migration tracking table

TODO: Run sqlc generate, then create backfill migration (000008)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:36:12 +02:00
Sansan 176848bb6d feat: Add deprecation notice for global Dbpool and Ctx variables
- Enhanced TODO comment to clearly mark Dbpool and Ctx as DEPRECATED
- Direct developers to use Database struct from database.go instead
- Migration test already includes manual data insertion (5 games, 8 songs)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:36:12 +02:00
SansanandMistral Vibe fb387901cf test: Add migration test with manual data insertion
- TestMigrationsStepByStep: tests incremental migration workflow
  - Step 1: Apply first 4 migrations (creates game, song tables)
  - Step 2: Manually insert 5 games with 8 songs
  - Step 3: Apply migration 5 (rename game→soundtrack)
  - Step 4: Verify data preserved in soundtrack table
- Helper functions: cleanupDB, createTestDB, applyMigrations
- Tests data integrity through full migration cycle

Requires: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD env vars
Run: migrate -path internal/db/migrations -database "postgres://user:pass@host:port/db?sslmode=disable" up N

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:36:12 +02:00
Sansan 0f29c33b1a test: Add statistics test with manual data insertion
- TestStatisticsEndpoints: tests /api/v1/statistics/summary endpoint
- TestPartialMigrationThenSyncThenComplete: tests migration + sync workflow
- insertTestData: helper to insert 5 soundtracks with 8 songs
- getTestToken: helper to get auth token for tests
- Updated other test files to use FindAllSoundtracks instead of FindAllGames

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:36:12 +02:00
Sansan cec408187d feat: Rename game to soundtrack throughout codebase
- Database migration: rename game table to soundtrack
- Rename game_name to soundtrack_name, game_id to soundtrack_id
- Update all SQL queries in soundtrack.sql, song.sql, song_list.sql, statistics.sql
- Regenerate sqlc code (soundtrack.sql.go, song.sql.go, etc.)
- Update backend: music.go, sync.go, statistics.go
- Update server: musicHandler.go, syncHandler.go, routes.go
- Update frontend: hello.go
- Keep URL paths as /games for backward compatibility

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:36:12 +02:00
Sansan c60f40d7e3 feat: Complete DI cleanup - migrate test helpers to Database struct
- Update internal/db/test_helpers.go to use Database struct instead of globals
- Update internal/server/test_helpers.go to use TestDatabase.Pool
- Add TODO comment to old Dbpool/Ctx globals in dbHelper.go
- Remove db.Testf() usage from production code (kept for deprecated /dbtest endpoint)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:36:12 +02:00
Sansan 2f407f6eef feat: Add deprecation middleware for legacy endpoints
- Create middleware/deprecation.go with DeprecationMiddleware
- Adds Warning and Deprecation headers to old endpoints
- Apply middleware to all non-/api/v1 routes:
  /version, /dbtest, /health, /character*, /download*, /sync/*,
  /music/*
- Message: 'Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead.'

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:36:12 +02:00
Sansan 4c2db11cc5 feat: Implement Statistics API with 8 endpoints under /api/v1/statistics/
- Add statistics.sql with 8 SQL queries for play count statistics
- Generate repository code via sqlc
- Add backend/statistics.go with business logic
- Add server/statistics_handler.go with Echo handlers
- Register protected routes under /api/v1/statistics/ with token auth
- Endpoints: games/most-played, games/least-played, games/never-played,
  games/last-played, games/oldest-played, songs/most-played,
  songs/least-played, summary

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:33:29 +02:00
Sansan 06cbad708d 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:33:29 +02:00
Sansan 89e884fae9 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:33:29 +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
Sansan 8f8b555ea5 Refactor handlers and update changelog for 5.0.0-Beta
Build / build (push) Successful in 48s
- Split IndexHandler into HealthHandler, VersionHandler, and CharacterHandler
- Rename index.go to version.go in backend
- Change VersionData.Changelog from string to []string
- Add changelog entries for issues #16-#23
- Remove TestDB function and related code
- Fix import ordering in several files

Closes #21, #22
References #16, #17, #18, #19, #20, #23

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:08:06 +02:00
49 changed files with 3479 additions and 1372 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 FROM golang:1.25-alpine as build_go
RUN apk add --no-cache curl RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY cmd/ ./cmd/
COPY . . 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 go install github.com/a-h/templ/cmd/templ@latest
RUN templ generate RUN templ generate
RUN go build -o main cmd/main.go RUN go build -o main cmd/main.go
# Stage 2, distribution container # Stage 2: Final image
FROM golang:1.25-alpine FROM golang:1.25-alpine
EXPOSE 8080 EXPOSE 8080
VOLUME /sorted VOLUME /sorted
VOLUME /frontend
VOLUME /characters 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 PORT 8080
ENV DB_HOST "" ENV DB_HOST ""
ENV DB_PORT "" ENV DB_PORT ""
@@ -30,7 +34,4 @@ ENV DB_NAME ""
ENV MUSIC_PATH "" ENV MUSIC_PATH ""
ENV CHARACTERS_PATH "" ENV CHARACTERS_PATH ""
COPY --from=build_go /app/main .
COPY ./songs/ ./songs/
CMD ./main CMD ./main
+682 -45
View File
@@ -23,6 +23,539 @@ var doc = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "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": { "/character": {
"get": { "get": {
"description": "Returns the image for a specific character", "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": { "/download": {
"get": { "get": {
"description": "Checks for the latest version of the application", "description": "Checks for the latest version of the application",
@@ -314,7 +824,7 @@ var doc = `{
}, },
"/music/all/order": { "/music/all/order": {
"get": { "get": {
"description": "Returns a list of all games in order", "description": "Returns a list of all soundtracks in order",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -324,7 +834,7 @@ var doc = `{
"tags": [ "tags": [
"music" "music"
], ],
"summary": "Get all games", "summary": "Get all soundtracks",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@@ -347,7 +857,7 @@ var doc = `{
}, },
"/music/all/random": { "/music/all/random": {
"get": { "get": {
"description": "Returns a list of all games in random order", "description": "Returns a list of all soundtracks in random order",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -357,7 +867,7 @@ var doc = `{
"tags": [ "tags": [
"music" "music"
], ],
"summary": "Get all games random", "summary": "Get all soundtracks random",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@@ -687,7 +1197,7 @@ var doc = `{
}, },
"/sync": { "/sync": {
"get": { "get": {
"description": "Starts syncing games with only new changes", "description": "Starts syncing soundtracks with only new changes",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -697,10 +1207,10 @@ var doc = `{
"tags": [ "tags": [
"sync" "sync"
], ],
"summary": "Sync games with only changes", "summary": "Sync soundtracks with only changes",
"responses": { "responses": {
"200": { "200": {
"description": "Start syncing games", "description": "Start syncing soundtracks",
"schema": { "schema": {
"type": "string" "type": "string"
} }
@@ -716,7 +1226,7 @@ var doc = `{
}, },
"/sync/full": { "/sync/full": {
"get": { "get": {
"description": "Starts a full sync of all games", "description": "Starts a full sync of all soundtracks",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -726,10 +1236,10 @@ var doc = `{
"tags": [ "tags": [
"sync" "sync"
], ],
"summary": "Sync all games fully", "summary": "Sync all soundtracks fully",
"responses": { "responses": {
"200": { "200": {
"description": "Start syncing games full", "description": "Start syncing soundtracks full",
"schema": { "schema": {
"type": "string" "type": "string"
} }
@@ -769,7 +1279,7 @@ var doc = `{
}, },
"/sync/reset": { "/sync/reset": {
"get": { "get": {
"description": "Resets the games database by deleting all games and songs", "description": "Resets the soundtracks database by deleting all soundtracks and songs",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -779,10 +1289,10 @@ var doc = `{
"tags": [ "tags": [
"sync" "sync"
], ],
"summary": "Reset games database", "summary": "Reset soundtracks database",
"responses": { "responses": {
"200": { "200": {
"description": "Games and songs are deleted from the database", "description": "Soundtracks and songs are deleted from the database",
"schema": { "schema": {
"type": "string" "type": "string"
} }
@@ -798,7 +1308,7 @@ var doc = `{
}, },
"/version": { "/version": {
"get": { "get": {
"description": "get string by ID", "description": "get latest version info",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -806,9 +1316,9 @@ var doc = `{
"application/json" "application/json"
], ],
"tags": [ "tags": [
"accounts" "version"
], ],
"summary": "Getting the version of the backend", "summary": "Getting the latest version of the backend",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "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": { "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": { "backend.VersionData": {
"type": "object", "type": "object",
"properties": { "properties": {
"changelog": { "changelog": {
"type": "string",
"example": "account name"
},
"history": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/backend.VersionData" "type": "string"
} },
"example": [
"[\"Initial release\"",
"\"Bug fixes\"]"
]
}, },
"version": { "version": {
"type": "string", "type": "string",
"example": "1.0.0" "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": {} "contact": {}
}, },
"paths": { "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": { "/character": {
"get": { "get": {
"description": "Returns the image for a specific character", "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": { "/download": {
"get": { "get": {
"description": "Checks for the latest version of the application", "description": "Checks for the latest version of the application",
@@ -295,7 +805,7 @@
}, },
"/music/all/order": { "/music/all/order": {
"get": { "get": {
"description": "Returns a list of all games in order", "description": "Returns a list of all soundtracks in order",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -305,7 +815,7 @@
"tags": [ "tags": [
"music" "music"
], ],
"summary": "Get all games", "summary": "Get all soundtracks",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@@ -328,7 +838,7 @@
}, },
"/music/all/random": { "/music/all/random": {
"get": { "get": {
"description": "Returns a list of all games in random order", "description": "Returns a list of all soundtracks in random order",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -338,7 +848,7 @@
"tags": [ "tags": [
"music" "music"
], ],
"summary": "Get all games random", "summary": "Get all soundtracks random",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@@ -668,7 +1178,7 @@
}, },
"/sync": { "/sync": {
"get": { "get": {
"description": "Starts syncing games with only new changes", "description": "Starts syncing soundtracks with only new changes",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -678,10 +1188,10 @@
"tags": [ "tags": [
"sync" "sync"
], ],
"summary": "Sync games with only changes", "summary": "Sync soundtracks with only changes",
"responses": { "responses": {
"200": { "200": {
"description": "Start syncing games", "description": "Start syncing soundtracks",
"schema": { "schema": {
"type": "string" "type": "string"
} }
@@ -697,7 +1207,7 @@
}, },
"/sync/full": { "/sync/full": {
"get": { "get": {
"description": "Starts a full sync of all games", "description": "Starts a full sync of all soundtracks",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -707,10 +1217,10 @@
"tags": [ "tags": [
"sync" "sync"
], ],
"summary": "Sync all games fully", "summary": "Sync all soundtracks fully",
"responses": { "responses": {
"200": { "200": {
"description": "Start syncing games full", "description": "Start syncing soundtracks full",
"schema": { "schema": {
"type": "string" "type": "string"
} }
@@ -750,7 +1260,7 @@
}, },
"/sync/reset": { "/sync/reset": {
"get": { "get": {
"description": "Resets the games database by deleting all games and songs", "description": "Resets the soundtracks database by deleting all soundtracks and songs",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -760,10 +1270,10 @@
"tags": [ "tags": [
"sync" "sync"
], ],
"summary": "Reset games database", "summary": "Reset soundtracks database",
"responses": { "responses": {
"200": { "200": {
"description": "Games and songs are deleted from the database", "description": "Soundtracks and songs are deleted from the database",
"schema": { "schema": {
"type": "string" "type": "string"
} }
@@ -779,7 +1289,7 @@
}, },
"/version": { "/version": {
"get": { "get": {
"description": "get string by ID", "description": "get latest version info",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -787,9 +1297,9 @@
"application/json" "application/json"
], ],
"tags": [ "tags": [
"accounts" "version"
], ],
"summary": "Getting the version of the backend", "summary": "Getting the latest version of the backend",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "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": { "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": { "backend.VersionData": {
"type": "object", "type": "object",
"properties": { "properties": {
"changelog": { "changelog": {
"type": "string",
"example": "account name"
},
"history": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/backend.VersionData" "type": "string"
} },
"example": [
"[\"Initial release\"",
"\"Bug fixes\"]"
]
}, },
"version": { "version": {
"type": "string", "type": "string",
"example": "1.0.0" "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: 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: backend.VersionData:
properties: properties:
changelog: changelog:
example: account name example:
type: string - '["Initial release"'
history: - '"Bug fixes"]'
items: items:
$ref: '#/definitions/backend.VersionData' type: string
type: array type: array
version: version:
example: 1.0.0 example: 1.0.0
type: string type: string
type: object 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: info:
contact: {} contact: {}
paths: paths:
/api/v1/statistics/games/last-played:
get:
consumes:
- application/json
description: Returns the most recently played games
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get last played games
tags:
- statistics
/api/v1/statistics/games/least-played:
get:
consumes:
- application/json
description: Returns the top N least played games with their songs
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get least played games
tags:
- statistics
/api/v1/statistics/games/most-played:
get:
consumes:
- application/json
description: Returns the top N most played games with their songs
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get most played games
tags:
- statistics
/api/v1/statistics/games/never-played:
get:
consumes:
- application/json
description: Returns all games that have never been played (times_played = 0)
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get never played games
tags:
- statistics
/api/v1/statistics/games/oldest-played:
get:
consumes:
- application/json
description: Returns the least recently played games (that have been played
at least once)
parameters:
- description: 'Number of results (default: 10)'
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.GameWithSongs'
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get oldest played games
tags:
- statistics
/api/v1/statistics/songs/least-played:
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: /character:
get: get:
consumes: consumes:
@@ -53,21 +466,6 @@ paths:
summary: Get list of characters summary: Get list of characters
tags: tags:
- characters - 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: /download:
get: get:
consumes: consumes:
@@ -223,7 +621,7 @@ paths:
description: Syncing is in progress description: Syncing is in progress
schema: schema:
type: string type: string
summary: Get all games summary: Get all soundtracks
tags: tags:
- music - music
/music/all/random: /music/all/random:
@@ -245,7 +643,7 @@ paths:
description: Syncing is in progress description: Syncing is in progress
schema: schema:
type: string type: string
summary: Get all games random summary: Get all soundtracks random
tags: tags:
- music - music
/music/info: /music/info:
@@ -459,14 +857,14 @@ paths:
- application/json - application/json
responses: responses:
"200": "200":
description: Start syncing games description: Start syncing soundtracks
schema: schema:
type: string type: string
"423": "423":
description: Syncing is in progress description: Syncing is in progress
schema: schema:
type: string type: string
summary: Sync games with only changes summary: Sync soundtracks with only changes
tags: tags:
- sync - sync
/sync/full: /sync/full:
@@ -478,7 +876,7 @@ paths:
- application/json - application/json
responses: responses:
"200": "200":
description: Start syncing games full description: Start syncing soundtracks full
schema: schema:
type: string type: string
"423": "423":
@@ -513,21 +911,21 @@ paths:
- application/json - application/json
responses: responses:
"200": "200":
description: Games and songs are deleted from the database description: Soundtracks and songs are deleted from the database
schema: schema:
type: string type: string
"423": "423":
description: Syncing is in progress description: Syncing is in progress
schema: schema:
type: string type: string
summary: Reset games database summary: Reset soundtracks database
tags: tags:
- sync - sync
/version: /version:
get: get:
consumes: consumes:
- application/json - application/json
description: get string by ID description: get latest version info
produces: produces:
- application/json - application/json
responses: responses:
@@ -539,7 +937,28 @@ paths:
description: Not Found description: Not Found
schema: schema:
type: string type: string
summary: Getting the version of the backend summary: Getting the latest version of the backend
tags: 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" swagger: "2.0"
+65 -10
View File
@@ -1,5 +1,33 @@
/* Pure CSS styles for Music Search */ /* 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; box-sizing: border-box;
margin: 0; margin: 0;
@@ -10,7 +38,9 @@ html, body {
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.5; 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 { main {
@@ -29,15 +59,15 @@ main {
max-width: 600px; max-width: 600px;
font-size: 1.5rem; font-size: 1.5rem;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #9ca3af; border: 1px solid var(--border-primary);
border-radius: 0.5rem; border-radius: 0.5rem;
background-color: #e5e7eb; background-color: var(--bg-secondary);
color: #000; color: var(--text-primary);
} }
#search_term:focus { #search_term:focus {
outline: none; outline: none;
border-color: #6b7280; border-color: var(--border-focus);
} }
#clear { #clear {
@@ -45,23 +75,48 @@ main {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 0.5rem; border-radius: 0.5rem;
background-color: #f97316; background-color: var(--accent-primary);
color: #fff; color: var(--text-primary);
cursor: pointer; cursor: pointer;
margin-left: 1rem; margin-left: 1rem;
} }
#clear:hover { #clear:hover {
background-color: #ea580c; background-color: var(--accent-hover);
} }
#games-container { #games-container {
font-size: 1.5rem; 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 */ /* Game result cards */
.bg-green-100 { .bg-green-100 {
background-color: #dcfce7; background-color: var(--bg-tertiary);
} }
.p-4 { .p-4 {
@@ -69,7 +124,7 @@ main {
} }
.shadow-md { .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 { .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>
}
}
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"os" "os"
"strings" "strings"
"music-server/internal/logging"
"go.uber.org/zap" "go.uber.org/zap"
"music-server/internal/logging"
) )
func GetCharacterList() []string { func GetCharacterList() []string {
@@ -30,10 +30,10 @@ func GetCharacterList() []string {
func GetCharacter(character string) string { func GetCharacter(character string) string {
charactersPath := os.Getenv("CHARACTERS_PATH") charactersPath := os.Getenv("CHARACTERS_PATH")
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath))
// Clean the path - remove trailing slashes and then add one for consistency // Clean the path - remove trailing slashes and then add one for consistency
charactersPath = strings.TrimSuffix(charactersPath, "/") charactersPath = strings.TrimSuffix(charactersPath, "/")
charactersPath += "/" charactersPath += "/"
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath+character))
return charactersPath + character return charactersPath + character
} }
-95
View File
@@ -1,95 +0,0 @@
package backend
import (
"music-server/internal/db"
)
func TestDB() {
db.Testf()
}
type VersionData struct {
Version string `json:"version" example:"1.0.0"`
Changelog string `json:"changelog" example:"account name"`
History []VersionData `json:"history"`
}
func GetVersionHistory() VersionData {
data := VersionData{Version: "4.5.0",
Changelog: "#1 - Created request to check newest version of the app\n" +
"#2 - Added request to download the newest version of the app\n" +
"#3 - Added request to check progress during sync\n" +
"#4 - Now blocking all request while sync is in progress\n" +
"#5 - Implemented ants for thread pooling\n" +
"#6 - Changed the sync request to now only start the sync",
History: []VersionData{
{
Version: "4.0.0",
Changelog: "Changed framework from gin to Echo\n" +
"Reorganized the code\n" +
"Implemented sqlc\n" +
"Added support to send character images from the server\n" +
"Added function to create a new database of no one exists",
},
{
Version: "3.2",
Changelog: "Upgraded Go version and the version of all dependencies. Fixed som more bugs.",
},
{
Version: "3.1",
Changelog: "Fixed some bugs with songs not found made the application crash. Now checking if song exists and if not, remove song from DB and find another one. Frontend is now decoupled from the backend.",
},
{
Version: "3.0",
Changelog: "Changed routing framework from mux to Gin. Swagger doc is now included in the application. A fronted can now be hosted from the application.",
},
{
Version: "2.3.0",
Changelog: "Images should not be included in the database, removes songs where the path doesn't work.",
},
{
Version: "2.2.0",
Changelog: "Changed the structure of the whole application, should be no changes to functionality.",
},
{
Version: "2.1.4",
Changelog: "Game list should now be sorted, a new endpoint with the game list in random order have been added.",
},
{
Version: "2.1.3",
Changelog: "Added a check to see if song exists before returning it, if not a new song will be picked up.",
},
{
Version: "2.1.2",
Changelog: "Added test server to swagger file.",
},
{
Version: "2.1.1",
Changelog: "Fixed bug where wrong song was showed as currently played.",
},
{
Version: "2.1.0",
Changelog: "Added /addQue to add the last received song to the songQue. " +
"Changed /rand and /rand/low to not add song to the que. " +
"Changed /next to not call /rand when the end of the que is reached, instead the last song in the que will be resent.",
},
{
Version: "2.0.3",
Changelog: "Another small change that should fix the caching problem.",
},
{
Version: "2.0.2",
Changelog: "Hopefully fixed the caching problem with random.",
},
{
Version: "2.0.1",
Changelog: "Fixed CORS",
},
{
Version: "2.0.0",
Changelog: "Rebuilt the application in Go.",
},
},
}
return data
}
+50 -50
View File
@@ -12,8 +12,8 @@ import (
) )
type SongInfo struct { type SongInfo struct {
Game string `json:"Game"` Soundtrack string `json:"Soundtrack"`
GamePlayed int32 `json:"GamePlayed"` SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
Song string `json:"Song"` Song string `json:"Song"`
SongPlayed int32 `json:"SongPlayed"` SongPlayed int32 `json:"SongPlayed"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"` CurrentlyPlaying bool `json:"CurrentlyPlaying"`
@@ -22,7 +22,7 @@ type SongInfo struct {
var currentSong = -1 var currentSong = -1
var gamesNew []repository.Soundtrack var soundtracksNew []repository.Soundtrack
var songQueNew []repository.Song var songQueNew []repository.Song
@@ -37,12 +37,12 @@ func initRepo() {
} }
} }
func getAllGames() []repository.Soundtrack { func getAllSoundtracks() []repository.Soundtrack {
if len(gamesNew) == 0 { if len(soundtracksNew) == 0 {
initRepo() initRepo()
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx()) soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
} }
return gamesNew return soundtracksNew
} }
@@ -59,7 +59,7 @@ func Reset() {
songQueNew = nil songQueNew = nil
currentSong = -1 currentSong = -1
initRepo() initRepo()
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx()) soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
} }
func AddLatestToQue() { func AddLatestToQue() {
@@ -92,34 +92,34 @@ func SetPlayed(songNumber int) {
} }
func GetRandomSong() string { func GetRandomSong() string {
getAllGames() getAllSoundtracks()
if len(gamesNew) == 0 { if len(soundtracksNew) == 0 {
return "" return ""
} }
song := getSongFromList(gamesNew) song := getSongFromList(soundtracksNew)
lastFetchedNew = song lastFetchedNew = song
return song.Path return song.Path
} }
func GetRandomSongLowChance() string { func GetRandomSongLowChance() string {
getAllGames() getAllSoundtracks()
var listOfGames []repository.Soundtrack var listOfSoundtracks []repository.Soundtrack
var averagePlayed = getAveragePlayed() var averagePlayed = getAveragePlayed()
for _, data := range gamesNew { for _, data := range soundtracksNew {
timesToAdd := averagePlayed - data.TimesPlayed timesToAdd := averagePlayed - data.TimesPlayed
if timesToAdd <= 0 { if timesToAdd <= 0 {
listOfGames = append(listOfGames, data) listOfSoundtracks = append(listOfSoundtracks, data)
} else { } else {
for i := int32(0); i < timesToAdd; i++ { for i := int32(0); i < timesToAdd; i++ {
listOfGames = append(listOfGames, data) listOfSoundtracks = append(listOfSoundtracks, data)
} }
} }
} }
song := getSongFromList(listOfGames) song := getSongFromList(listOfSoundtracks)
lastFetchedNew = song lastFetchedNew = song
return song.Path return song.Path
@@ -127,11 +127,11 @@ func GetRandomSongLowChance() string {
} }
func GetRandomSongClassic() string { func GetRandomSongClassic() string {
getAllGames() getAllSoundtracks()
var listOfAllSongs []repository.Song var listOfAllSongs []repository.Song
for _, game := range gamesNew { for _, soundtrack := range soundtracksNew {
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID) songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
listOfAllSongs = append(listOfAllSongs, songList...) listOfAllSongs = append(listOfAllSongs, songList...)
} }
@@ -139,25 +139,25 @@ func GetRandomSongClassic() string {
var song repository.Song var song repository.Song
for !songFound { for !songFound {
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))] song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
gameData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID) soundtrackData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
if err != nil { if err != nil {
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", gameData.SoundtrackName), zap.String("soundtrack", soundtrackData.SoundtrackName),
zap.String("filename", *song.FileName)) zap.String("filename", *song.FileName))
continue continue
} }
//Check if file exists and open //Check if file exists and open
openFile, err := os.Open(song.Path) openFile, err := os.Open(song.Path)
if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) { if err != nil || (song.FileName != nil && soundtrackData.Path+*song.FileName != song.Path) {
//File not found //File not found
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", gameData.SoundtrackName), zap.String("soundtrack", soundtrackData.SoundtrackName),
zap.String("filename", *song.FileName)) zap.String("filename", *song.FileName))
} else { } else {
songFound = true songFound = true
@@ -177,11 +177,11 @@ func GetSongInfo() SongInfo {
} }
var currentSongData = songQueNew[currentSong] var currentSongData = songQueNew[currentSong]
currentGameData := getCurrentGame(currentSongData) currentSoundtrackData := getCurrentSoundtrack(currentSongData)
return SongInfo{ return SongInfo{
Game: currentGameData.SoundtrackName, Soundtrack: currentSoundtrackData.SoundtrackName,
GamePlayed: currentGameData.TimesPlayed, SoundtrackPlayed: currentSoundtrackData.TimesPlayed,
Song: currentSongData.SongName, Song: currentSongData.SongName,
SongPlayed: currentSongData.TimesPlayed, SongPlayed: currentSongData.TimesPlayed,
CurrentlyPlaying: true, CurrentlyPlaying: true,
@@ -193,10 +193,10 @@ func GetPlayedSongs() []SongInfo {
var songList []SongInfo var songList []SongInfo
for i, song := range songQueNew { for i, song := range songQueNew {
gameData := getCurrentGame(song) soundtrackData := getCurrentSoundtrack(song)
songList = append(songList, SongInfo{ songList = append(songList, SongInfo{
Game: gameData.SoundtrackName, Soundtrack: soundtrackData.SoundtrackName,
GamePlayed: gameData.TimesPlayed, SoundtrackPlayed: soundtrackData.TimesPlayed,
Song: song.SongName, Song: song.SongName,
SongPlayed: song.TimesPlayed, SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == currentSong, CurrentlyPlaying: i == currentSong,
@@ -218,21 +218,21 @@ func GetSong(song string) string {
} }
func GetAllSoundtracks() []string { func GetAllSoundtracks() []string {
getAllGames() getAllSoundtracks()
var jsonArray []string var jsonArray []string
for _, game := range gamesNew { for _, soundtrack := range soundtracksNew {
jsonArray = append(jsonArray, game.SoundtrackName) jsonArray = append(jsonArray, soundtrack.SoundtrackName)
} }
return jsonArray return jsonArray
} }
func GetAllSoundtracksRandom() []string { func GetAllSoundtracksRandom() []string {
getAllGames() getAllSoundtracks()
var jsonArray []string var jsonArray []string
for _, game := range gamesNew { for _, soundtrack := range soundtracksNew {
jsonArray = append(jsonArray, game.SoundtrackName) jsonArray = append(jsonArray, soundtrack.SoundtrackName)
} }
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] }) rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
return jsonArray return jsonArray
@@ -266,12 +266,12 @@ func GetPreviousSong() string {
} }
} }
func getSongFromList(games []repository.Soundtrack) repository.Song { func getSongFromList(soundtracks []repository.Soundtrack) repository.Song {
songFound := false songFound := false
var song repository.Song var song repository.Song
for !songFound { for !songFound {
game := getRandomGame(games) soundtrack := getRandomSoundtrack(soundtracks)
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID) songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
if len(songs) == 0 { if len(songs) == 0 {
continue continue
} }
@@ -280,12 +280,12 @@ func getSongFromList(games []repository.Soundtrack) repository.Song {
//Check if file exists and open //Check if file exists and open
openFile, err := os.Open(song.Path) openFile, err := os.Open(song.Path)
if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) { if err != nil || (song.FileName != nil && soundtrack.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
//File not found //File not found
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", game.SoundtrackName), zap.String("soundtrack", soundtrack.SoundtrackName),
zap.Any("filename", song.FileName)) zap.Any("filename", song.FileName))
} else { } else {
songFound = true songFound = true
@@ -299,24 +299,24 @@ func getSongFromList(games []repository.Soundtrack) repository.Song {
return song return song
} }
func getCurrentGame(currentSongData repository.Song) repository.Soundtrack { func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
for _, game := range gamesNew { for _, soundtrack := range soundtracksNew {
if game.ID == currentSongData.SoundtrackID { if soundtrack.ID == currentSongData.SoundtrackID {
return game return soundtrack
} }
} }
return repository.Soundtrack{} return repository.Soundtrack{}
} }
func getAveragePlayed() int32 { func getAveragePlayed() int32 {
getAllGames() getAllSoundtracks()
var sum int32 var sum int32
for _, data := range gamesNew { for _, data := range soundtracksNew {
sum += data.TimesPlayed sum += data.TimesPlayed
} }
return sum / int32(len(gamesNew)) return sum / int32(len(soundtracksNew))
} }
func getRandomGame(listOfGames []repository.Soundtrack) repository.Soundtrack { func getRandomSoundtrack(listOfSoundtracks []repository.Soundtrack) repository.Soundtrack {
return listOfGames[rand.Intn(len(listOfGames))] return listOfSoundtracks[rand.Intn(len(listOfSoundtracks))]
} }
+70 -72
View File
@@ -9,17 +9,17 @@ import (
// Test the average calculation logic directly without database access // Test the average calculation logic directly without database access
func TestCalculateAverage(t *testing.T) { func TestCalculateAverage(t *testing.T) {
games := []repository.Game{ soundtracks := []repository.Soundtrack{
{GameName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Soundtrack3", TimesPlayed: 30},
} }
var sum int32 var sum int32
for _, data := range games { for _, data := range soundtracks {
sum += data.TimesPlayed sum += data.TimesPlayed
} }
result := sum / int32(len(games)) result := sum / int32(len(soundtracks))
expected := int32(20) expected := int32(20)
if result != expected { if result != expected {
@@ -28,9 +28,9 @@ func TestCalculateAverage(t *testing.T) {
} }
func TestCalculateAverageEmpty(t *testing.T) { func TestCalculateAverageEmpty(t *testing.T) {
games := []repository.Game{} soundtracks := []repository.Soundtrack{}
if len(games) == 0 { if len(soundtracks) == 0 {
result := int32(0) result := int32(0)
expected := int32(0) expected := int32(0)
if result != expected { if result != expected {
@@ -40,10 +40,10 @@ func TestCalculateAverageEmpty(t *testing.T) {
} }
var sum int32 var sum int32
for _, data := range games { for _, data := range soundtracks {
sum += data.TimesPlayed sum += data.TimesPlayed
} }
result := sum / int32(len(games)) result := sum / int32(len(soundtracks))
expected := int32(0) expected := int32(0)
if result != expected { if result != expected {
@@ -52,152 +52,150 @@ func TestCalculateAverageEmpty(t *testing.T) {
} }
func TestCalculateAverageSingle(t *testing.T) { func TestCalculateAverageSingle(t *testing.T) {
games := []repository.Game{ soundtracks := []repository.Soundtrack{
{GameName: "Game1", TimesPlayed: 42}, {SoundtrackName: "Soundtrack1", TimesPlayed: 42},
} }
var sum int32 var sum int32
for _, data := range games { for _, data := range soundtracks {
sum += data.TimesPlayed sum += data.TimesPlayed
} }
result := sum / int32(len(games)) result := sum / int32(len(soundtracks))
expected := int32(42) expected := int32(42)
if result != expected { if result != expected {
t.Errorf("Average calculation with single game = %v, want %v", result, expected) t.Errorf("Average calculation with single soundtrack = %v, want %v", result, expected)
} }
} }
func TestGetRandomGame(t *testing.T) { func TestGetRandomSoundtrack(t *testing.T) {
games := []repository.Game{ soundtracks := []repository.Soundtrack{
{GameName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Soundtrack3", TimesPlayed: 30},
} }
// Set seed for reproducible tests // Set seed for reproducible tests
rand.Seed(42) rand.Seed(42)
result := games[rand.Intn(len(games))] result := soundtracks[rand.Intn(len(soundtracks))]
if result.GameName == "" { if result.SoundtrackName == "" {
t.Error("random game selection returned empty game") t.Error("random soundtrack selection returned empty soundtrack")
} }
found := false found := false
for _, g := range games { for _, s := range soundtracks {
if g.GameName == result.GameName { if s.SoundtrackName == result.SoundtrackName {
found = true found = true
break break
} }
} }
if !found { if !found {
t.Errorf("random game selection returned game not in list: %v", result.GameName) t.Errorf("random soundtrack selection returned soundtrack not in list: %v", result.SoundtrackName)
} }
} }
func TestFindGameByID(t *testing.T) { func TestFindSoundtrackByID(t *testing.T) {
games := []repository.Game{ soundtracks := []repository.Soundtrack{
{ID: 1, GameName: "Game1", TimesPlayed: 10}, {ID: 1, SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{ID: 2, GameName: "Game2", TimesPlayed: 20}, {ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{ID: 3, GameName: "Game3", TimesPlayed: 30}, {ID: 3, SoundtrackName: "Soundtrack3", TimesPlayed: 30},
} }
tests := []struct { tests := []struct {
name string name string
games []repository.Game soundtracks []repository.Soundtrack
gameID int32 soundtrackID int32
expected repository.Game expected repository.Soundtrack
}{ }{
{ {
name: "existing game", name: "existing soundtrack",
games: games, soundtracks: soundtracks,
gameID: 2, soundtrackID: 2,
expected: repository.Game{ID: 2, GameName: "Game2", TimesPlayed: 20}, expected: repository.Soundtrack{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
}, },
{ {
name: "non-existing game", name: "non-existing soundtrack",
games: games, soundtracks: soundtracks,
gameID: 99, soundtrackID: 99,
expected: repository.Game{}, expected: repository.Soundtrack{},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var result repository.Game var result repository.Soundtrack
for _, game := range tt.games { for _, s := range tt.soundtracks {
if game.ID == tt.gameID { if s.ID == tt.soundtrackID {
result = game result = s
break break
} }
} }
if result.ID != tt.expected.ID || result.GameName != tt.expected.GameName { if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName {
t.Errorf("findGameByID() = %v, want %v", result, tt.expected) t.Errorf("findSoundtrackByID() = %v, want %v", result, tt.expected)
} }
}) })
} }
} }
func TestExtractGameNames(t *testing.T) { func TestExtractSoundtrackNames(t *testing.T) {
games := []repository.Game{ soundtracks := []repository.Soundtrack{
{GameName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Soundtrack3", TimesPlayed: 30},
} }
var result []string var result []string
for _, game := range games { for _, s := range soundtracks {
result = append(result, game.GameName) result = append(result, s.SoundtrackName)
} }
expected := []string{"Game1", "Game2", "Game3"} expected := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
if len(result) != len(expected) { if len(result) != len(expected) {
t.Errorf("extractGameNames() length = %d, want %d", len(result), len(expected)) t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected))
return return
} }
for i, v := range result { for i, v := range result {
if v != expected[i] { if v != expected[i] {
t.Errorf("extractGameNames()[%d] = %v, want %v", i, v, expected[i]) t.Errorf("extractSoundtrackNames()[%d] = %v, want %v", i, v, expected[i])
} }
} }
} }
func TestShuffleGameNames(t *testing.T) { func TestShuffleSoundtrackNames(t *testing.T) {
games := []string{"Game1", "Game2", "Game3"} soundtracks := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
// Test that shuffle doesn't lose any elements // Test that shuffle doesn't lose any elements
// We can't test the order since it's random, but we can test length and contents // We can't test the order since it's random, but we can test length and contents
original := make([]string, len(games)) original := make([]string, len(soundtracks))
copy(original, games) copy(original, soundtracks)
// Simple shuffle implementation for testing // Simple shuffle implementation for testing
for i := range games { for i := range soundtracks {
j := i // In real code this would be random j := i // In real code this would be random
games[i], games[j] = games[j], games[i] soundtracks[i], soundtracks[j] = soundtracks[j], soundtracks[i]
} }
if len(games) != len(original) { if len(soundtracks) != len(original) {
t.Errorf("shuffleGameNames() changed length from %d to %d", len(original), len(games)) t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(soundtracks))
return return
} }
// Check all original elements are still present // Check all original elements are still present
for _, orig := range original { for _, orig := range original {
found := false found := false
for _, g := range games { for _, s := range soundtracks {
if g == orig { if s == orig {
found = true found = true
break break
} }
} }
if !found { if !found {
t.Errorf("shuffleGameNames() lost element: %v", orig) t.Errorf("shuffleSoundtrackNames() lost element: %v", orig)
} }
} }
} }
+92 -92
View File
@@ -9,34 +9,34 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// GameWithSongs represents a game with its songs for statistics // SoundtrackWithSongs represents a soundtrack with its songs for statistics
type GameWithSongs struct { type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"game_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"game_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"game_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"game_last_played,omitempty"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
Songs []SongInfoForStats `json:"songs"` Songs []SongInfoForStats `json:"songs"`
} }
// SongInfoForStats represents a song with game info for statistics // SongInfoForStats represents a song with soundtrack info for statistics
type SongInfoForStats struct { type SongInfoForStats struct {
SoundtrackID int32 `json:"game_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"game_name"` SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"` SongName string `json:"song_name"`
Path string `json:"path"` Path string `json:"path"`
TimesPlayed int32 `json:"times_played"` TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name,omitempty"` FileName *string `json:"file_name,omitempty"`
} }
// StatisticsSummary holds overall statistics // StatisticsSummary holds overall statistics
type StatisticsSummary struct { type StatisticsSummary struct {
TotalGames int64 `json:"total_games"` TotalSoundtracks int64 `json:"total_soundtracks"`
PlayedGames int64 `json:"played_games"` PlayedSoundtracks int64 `json:"played_soundtracks"`
NeverPlayedGames int64 `json:"never_played_games"` NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"`
TotalGamePlays int64 `json:"total_game_plays"` TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"`
AvgGamePlays float64 `json:"avg_game_plays"` AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"`
MaxGamePlays int64 `json:"max_game_plays"` MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"`
MinGamePlays int64 `json:"min_game_plays"` MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
} }
// StatisticsHandler manages statistics operations // StatisticsHandler manages statistics operations
@@ -49,19 +49,19 @@ func NewStatisticsHandler() *StatisticsHandler {
return &StatisticsHandler{} return &StatisticsHandler{}
} }
// GetMostPlayedGamesWithSongs returns the top N most played games with their songs // GetMostPlayedSoundtracksWithSongs returns the top N most played soundtracks with their songs
func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) { func (h *StatisticsHandler) GetMostPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
// Get raw results // Get raw results
rows, err := queries.GetMostPlayedGamesWithSongs(ctx, limit) rows, err := queries.GetMostPlayedSoundtracksWithSongs(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Convert to GameWithSongs // Convert to SoundtrackWithSongs
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -71,28 +71,28 @@ func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWith
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed, SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs, Songs: songs,
}) })
} }
return result, nil return result, nil
} }
// GetLeastPlayedGamesWithSongs returns the top N least played games with their songs // GetLeastPlayedSoundtracksWithSongs returns the top N least played soundtracks with their songs
func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) { func (h *StatisticsHandler) GetLeastPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetLeastPlayedGamesWithSongs(ctx, limit) rows, err := queries.GetLeastPlayedSoundtracksWithSongs(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -100,23 +100,23 @@ func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWit
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed, SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs, Songs: songs,
}) })
} }
return result, nil return result, nil
} }
// GetMostPlayedSongsWithGame returns the top N most played songs with their game info // GetMostPlayedSongsWithSoundtrack returns the top N most played songs with their soundtrack info
func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) { func (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetMostPlayedSongsWithGame(ctx, limit) rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -124,23 +124,23 @@ func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoF
var result []SongInfoForStats var result []SongInfoForStats
for _, row := range rows { for _, row := range rows {
result = append(result, SongInfoForStats{ result = append(result, SongInfoForStats{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SongName: row.SongName, SongName: row.SongName,
Path: row.Path, Path: row.Path,
TimesPlayed: row.TimesPlayed, TimesPlayed: row.TimesPlayed,
FileName: row.FileName, FileName: row.FileName,
}) })
} }
return result, nil return result, nil
} }
// GetLeastPlayedSongsWithGame returns the top N least played songs with their game info // GetLeastPlayedSongsWithSoundtrack returns the top N least played songs with their soundtrack info
func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) { func (h *StatisticsHandler) GetLeastPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetLeastPlayedSongsWithGame(ctx, limit) rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -148,28 +148,28 @@ func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfo
var result []SongInfoForStats var result []SongInfoForStats
for _, row := range rows { for _, row := range rows {
result = append(result, SongInfoForStats{ result = append(result, SongInfoForStats{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SongName: row.SongName, SongName: row.SongName,
Path: row.Path, Path: row.Path,
TimesPlayed: row.TimesPlayed, TimesPlayed: row.TimesPlayed,
FileName: row.FileName, FileName: row.FileName,
}) })
} }
return result, nil return result, nil
} }
// GetNeverPlayedGames returns games that have never been played // GetNeverPlayedSoundtracks returns soundtracks that have never been played
func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) { func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetNeverPlayedGames(ctx) rows, err := queries.GetNeverPlayedSoundtracks(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -177,28 +177,28 @@ func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: nil, SoundtrackLastPlayed: nil,
Songs: songs, Songs: songs,
}) })
} }
return result, nil return result, nil
} }
// GetLastPlayedGames returns the most recently played games // GetLastPlayedSoundtracks returns the most recently played soundtracks
func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, error) { func (h *StatisticsHandler) GetLastPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetLastPlayedGames(ctx, limit) rows, err := queries.GetLastPlayedSoundtracks(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -206,28 +206,28 @@ func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, er
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed, SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs, Songs: songs,
}) })
} }
return result, nil return result, nil
} }
// GetOldestPlayedGames returns the least recently played games // GetOldestPlayedSoundtracks returns the least recently played soundtracks
func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs, error) { func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo() queries := BackendRepo()
ctx := BackendCtx() ctx := BackendCtx()
rows, err := queries.GetOldestPlayedGames(ctx, limit) rows, err := queries.GetOldestPlayedSoundtracks(ctx, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []GameWithSongs var result []SoundtrackWithSongs
for _, row := range rows { for _, row := range rows {
var songs []SongInfoForStats var songs []SongInfoForStats
if row.Songs != nil { if row.Songs != nil {
@@ -235,12 +235,12 @@ func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs,
songs = make([]SongInfoForStats, 0) songs = make([]SongInfoForStats, 0)
} }
} }
result = append(result, GameWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed, SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs, Songs: songs,
}) })
} }
return result, nil return result, nil
@@ -257,13 +257,13 @@ func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
} }
return &StatisticsSummary{ return &StatisticsSummary{
TotalGames: int64(row.TotalSoundtracks), TotalSoundtracks: int64(row.TotalSoundtracks),
PlayedGames: int64(row.PlayedSoundtracks), PlayedSoundtracks: int64(row.PlayedSoundtracks),
NeverPlayedGames: int64(row.NeverPlayedSoundtracks), NeverPlayedSoundtracks: int64(row.NeverPlayedSoundtracks),
TotalGamePlays: int64(row.TotalSoundtrackPlays), TotalSoundtrackPlays: int64(row.TotalSoundtrackPlays),
AvgGamePlays: float64(row.AvgSoundtrackPlays), AvgSoundtrackPlays: float64(row.AvgSoundtrackPlays),
MaxGamePlays: int64(row.MaxSoundtrackPlays), MaxSoundtrackPlays: int64(row.MaxSoundtrackPlays),
MinGamePlays: int64(row.MinSoundtrackPlays), MinSoundtrackPlays: int64(row.MinSoundtrackPlays),
}, nil }, nil
} }
+135 -175
View File
@@ -16,8 +16,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/panjf2000/ants/v2" "github.com/panjf2000/ants/v2"
"github.com/MShekow/directory-checksum/directory_checksum" "github.com/MShekow/directory-checksum/directory_checksum"
@@ -32,14 +30,14 @@ var start time.Time
var totalTime time.Duration var totalTime time.Duration
var timeSpent time.Duration var timeSpent time.Duration
var allGames []repository.Soundtrack var allSoundtracks []repository.Soundtrack
var gamesBeforeSync []repository.Soundtrack var soundtracksBeforeSync []repository.Soundtrack
var gamesAfterSync []repository.Soundtrack var soundtracksAfterSync []repository.Soundtrack
var gamesAdded []string var soundtracksAdded []string
var gamesReAdded []string var soundtracksReAdded []string
var gamesChangedTitle map[string]string var soundtracksChangedTitle map[string]string
var gamesChangedContent []string var soundtracksChangedContent []string
var gamesRemoved []string var soundtracksRemoved []string
var catchedErrors []string var catchedErrors []string
type brokenSong struct { type brokenSong struct {
@@ -52,13 +50,13 @@ var pool *ants.Pool
var poolSong *ants.Pool var poolSong *ants.Pool
type SyncResponse struct { type SyncResponse struct {
GamesAdded []string `json:"games_added"` SoundtracksAdded []string `json:"soundtracks_added"`
GamesReAdded []string `json:"games_re_added"` SoundtracksReAdded []string `json:"soundtracks_re_added"`
GamesChangedTitle map[string]string `json:"games_changed_title"` SoundtracksChangedTitle map[string]string `json:"soundtracks_changed_title"`
GamesChangedContent []string `json:"games_changed_content"` SoundtracksChangedContent []string `json:"soundtracks_changed_content"`
GamesRemoved []string `json:"games_removed"` SoundtracksRemoved []string `json:"soundtracks_removed"`
CatchedErrors []string `json:"catched_errors"` CatchedErrors []string `json:"catched_errors"`
TotalTime string `json:"total_time"` TotalTime string `json:"total_time"`
} }
type ProgressResponse struct { type ProgressResponse struct {
@@ -66,24 +64,24 @@ type ProgressResponse struct {
TimeSpent string `json:"time_spent"` TimeSpent string `json:"time_spent"`
} }
type GameStatus int type SoundtrackStatus int
const ( const (
NotChanged GameStatus = iota NotChanged SoundtrackStatus = iota
TitleChanged TitleChanged
GameChanged SoundtrackChanged
NewGame NewSoundtrack
) )
var statusName = map[GameStatus]string{ var statusName = map[SoundtrackStatus]string{
NotChanged: "Not changed", NotChanged: "Not changed",
TitleChanged: "Title changed", TitleChanged: "Title changed",
GameChanged: "Game changed", SoundtrackChanged: "Soundtrack changed",
NewGame: "New game", NewSoundtrack: "New soundtrack",
} }
func (gs GameStatus) String() string { func (ss SoundtrackStatus) String() string {
return statusName[gs] return statusName[ss]
} }
func ResetDB() { func ResetDB() {
@@ -109,54 +107,54 @@ func SyncProgress() ProgressResponse {
func SyncResult() SyncResponse { func SyncResult() SyncResponse {
logging.GetLogger().Info("Sync completed", logging.GetLogger().Info("Sync completed",
zap.Int("games_before", len(gamesBeforeSync)), zap.Int("soundtracks_before", len(soundtracksBeforeSync)),
zap.Int("games_after", len(gamesAfterSync))) zap.Int("soundtracks_after", len(soundtracksAfterSync)))
if len(gamesAdded) > 0 { if len(soundtracksAdded) > 0 {
logging.GetLogger().Debug("Games added", zap.Strings("games", gamesAdded)) logging.GetLogger().Debug("Soundtracks added", zap.Strings("soundtracks", soundtracksAdded))
} }
if len(gamesReAdded) > 0 { if len(soundtracksReAdded) > 0 {
logging.GetLogger().Debug("Games readded", zap.Strings("games", gamesReAdded)) logging.GetLogger().Debug("Soundtracks readded", zap.Strings("soundtracks", soundtracksReAdded))
} }
if len(gamesChangedTitle) > 0 { if len(soundtracksChangedTitle) > 0 {
logging.GetLogger().Debug("Games with changed title", zap.Any("changes", gamesChangedTitle)) logging.GetLogger().Debug("Soundtracks with changed title", zap.Any("changes", soundtracksChangedTitle))
} }
if len(gamesChangedContent) > 0 { if len(soundtracksChangedContent) > 0 {
logging.GetLogger().Debug("Games with changed content", zap.Strings("games", gamesChangedContent)) logging.GetLogger().Debug("Soundtracks with changed content", zap.Strings("soundtracks", soundtracksChangedContent))
} }
var gamesRemovedTemp []string var soundtracksRemovedTemp []string
for _, beforeGame := range gamesBeforeSync { for _, beforeSoundtrack := range soundtracksBeforeSync {
var found = false var found = false
for _, afterGame := range gamesAfterSync { for _, afterSoundtrack := range soundtracksAfterSync {
if beforeGame.SoundtrackName == afterGame.SoundtrackName { if beforeSoundtrack.SoundtrackName == afterSoundtrack.SoundtrackName {
found = true found = true
break break
} }
} }
if !found { if !found {
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.SoundtrackName) soundtracksRemovedTemp = append(soundtracksRemovedTemp, beforeSoundtrack.SoundtrackName)
} }
} }
for _, game := range gamesRemovedTemp { for _, soundtrack := range soundtracksRemovedTemp {
var found bool = false var found bool = false
for key := range gamesChangedTitle { for key := range soundtracksChangedTitle {
if game == key { if soundtrack == key {
found = true found = true
break break
} }
} }
if !found { if !found {
gamesRemoved = append(gamesRemoved, game) soundtracksRemoved = append(soundtracksRemoved, soundtrack)
} }
} }
if len(gamesRemoved) > 0 { if len(soundtracksRemoved) > 0 {
logging.GetLogger().Debug("Games removed", zap.Strings("games", gamesRemoved)) logging.GetLogger().Debug("Soundtracks removed", zap.Strings("soundtracks", soundtracksRemoved))
} }
if len(catchedErrors) > 0 { if len(catchedErrors) > 0 {
@@ -167,29 +165,27 @@ func SyncResult() SyncResponse {
logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000"))) logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000")))
return SyncResponse{ return SyncResponse{
GamesAdded: gamesAdded, SoundtracksAdded: soundtracksAdded,
GamesReAdded: gamesReAdded, SoundtracksReAdded: soundtracksReAdded,
GamesChangedTitle: gamesChangedTitle, SoundtracksChangedTitle: soundtracksChangedTitle,
GamesChangedContent: gamesChangedContent, SoundtracksChangedContent: soundtracksChangedContent,
GamesRemoved: gamesRemoved, SoundtracksRemoved: soundtracksRemoved,
CatchedErrors: catchedErrors, CatchedErrors: catchedErrors,
TotalTime: out.Format("15:04:05"), TotalTime: out.Format("15:04:05"),
} }
} }
func SyncSoundtracksNewFull() { func SyncSoundtracksFull() {
syncGamesNew(true) syncSoundtracks(true)
Reset() Reset()
} }
func SyncSoundtracksNewOnlyChanges() { func SyncSoundtracksOnlyChanges() {
syncGamesNew(false) syncSoundtracks(false)
Reset() Reset()
} }
func syncGamesNew(full bool) { func syncSoundtracks(full bool) {
Syncing = true
musicPath := os.Getenv("MUSIC_PATH") musicPath := os.Getenv("MUSIC_PATH")
fmt.Printf("dir: %s\n", musicPath) fmt.Printf("dir: %s\n", musicPath)
logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath)) logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath))
@@ -201,23 +197,23 @@ func syncGamesNew(full bool) {
initRepo() initRepo()
start = time.Now() 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)) logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
var err error var err error
gamesAdded = nil soundtracksAdded = nil
gamesReAdded = nil soundtracksReAdded = nil
gamesChangedTitle = nil soundtracksChangedTitle = nil
gamesChangedContent = nil soundtracksChangedContent = nil
gamesRemoved = nil soundtracksRemoved = nil
catchedErrors = nil catchedErrors = nil
brokenSongs = nil brokenSongs = nil
gamesBeforeSync, err = repo.FindAllSoundtracks(BackendCtx()) soundtracksBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
handleError("FindAllSoundtracks Before", err, "") handleError("FindAllSoundtracks Before", err, "")
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync))) logging.GetLogger().Info("Starting sync", zap.Int("soundtracks_before", len(soundtracksBeforeSync)))
allGames, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx()) allSoundtracks, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx())
handleError("GetAllSoundtracksIncludingDeleted", err, "") handleError("GetAllSoundtracksIncludingDeleted", err, "")
err = repo.SetSoundtrackDeletionDate(BackendCtx()) err = repo.SetSoundtrackDeletionDate(BackendCtx())
handleError("SetSoundtrackDeletionDate", err, "") handleError("SetSoundtrackDeletionDate", err, "")
@@ -226,7 +222,6 @@ func syncGamesNew(full bool) {
if err != nil { if err != nil {
logging.GetLogger().Fatal("Failed to read music directory", zap.String("path", musicPath), zap.String("error", err.Error())) logging.GetLogger().Fatal("Failed to read music directory", zap.String("path", musicPath), zap.String("error", err.Error()))
} }
pool, _ = ants.NewPool(10, ants.WithPreAlloc(true)) pool, _ = ants.NewPool(10, ants.WithPreAlloc(true))
poolSong, _ = ants.NewPool(10, ants.WithPreAlloc(true)) poolSong, _ = ants.NewPool(10, ants.WithPreAlloc(true))
defer pool.Release() defer pool.Release()
@@ -238,13 +233,13 @@ func syncGamesNew(full bool) {
for _, dir := range directories { for _, dir := range directories {
pool.Submit(func() { pool.Submit(func() {
defer syncWg.Done() defer syncWg.Done()
syncGameNew(dir, foldersToSkip, musicPath, full) syncSoundtrack(dir, foldersToSkip, musicPath, full)
}) })
} }
syncWg.Wait() syncWg.Wait()
checkBrokenSongsNew() checkBrokenSongs()
gamesAfterSync, err = repo.FindAllSoundtracks(BackendCtx()) soundtracksAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
handleError("FindAllSoundtracks After", err, "") handleError("FindAllSoundtracks After", err, "")
finished := time.Now() finished := time.Now()
@@ -255,7 +250,7 @@ func syncGamesNew(full bool) {
Syncing = false Syncing = false
} }
func checkBrokenSongsNew() { func checkBrokenSongs() {
allSongs, err := repo.FetchAllSongs(BackendCtx()) allSongs, err := repo.FetchAllSongs(BackendCtx())
handleError("FetchAllSongs", err, "") handleError("FetchAllSongs", err, "")
var brokenWg sync.WaitGroup var brokenWg sync.WaitGroup
@@ -266,7 +261,7 @@ func checkBrokenSongsNew() {
for _, song := range allSongs { for _, song := range allSongs {
poolBroken.Submit(func() { poolBroken.Submit(func() {
defer brokenWg.Done() defer brokenWg.Done()
checkBrokenSongNew(song) checkBrokenSong(song)
}) })
} }
brokenWg.Wait() brokenWg.Wait()
@@ -276,7 +271,7 @@ func checkBrokenSongsNew() {
} }
} }
func checkBrokenSongNew(song repository.Song) { func checkBrokenSong(song repository.Song) {
//Check if file exists and open //Check if file exists and open
openFile, err := os.Open(song.Path) openFile, err := os.Open(song.Path)
if err != nil { if err != nil {
@@ -291,119 +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()) { if file.IsDir() && !contains(foldersToSkip, file.Name()) {
logging.GetLogger().Debug("Syncing game", zap.String("game", file.Name())) logging.GetLogger().Debug("Syncing soundtrack", zap.String("soundtrack", file.Name()))
gameDir := baseDir + file.Name() + "/" soundtrackDir := baseDir + file.Name() + "/"
dirHash := getHashForDir(gameDir) dirHash := getHashForDir(soundtrackDir)
var status GameStatus = NewGame var status SoundtrackStatus = NewSoundtrack
var oldGame repository.Soundtrack var oldSoundtrack repository.Soundtrack
var id int32 = -1 var id int32 = -1
//fmt.Printf("Games before: %d\n", len(gamesBeforeSync)) //fmt.Printf("Soundtracks before: %d\n", len(soundtracksBeforeSync))
for _, currentGame := range allGames { for _, currentSoundtrack := range allSoundtracks {
oldGame = currentGame oldSoundtrack = currentSoundtrack
//fmt.Printf("%s | %s\n", oldGame.SoundtrackName, oldGame.Hash) //fmt.Printf("%s | %s\n", oldSoundtrack.SoundtrackName, oldSoundtrack.Hash)
if oldGame.SoundtrackName == file.Name() && oldGame.Hash == dirHash { if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash == dirHash {
status = NotChanged status = NotChanged
id = oldGame.ID id = oldSoundtrack.ID
//fmt.Printf("Game not changed\n") //fmt.Printf("Soundtrack not changed\n")
break break
} else if oldGame.SoundtrackName == file.Name() && oldGame.Hash != dirHash { } else if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash != dirHash {
status = GameChanged status = SoundtrackChanged
id = oldGame.ID id = oldSoundtrack.ID
//fmt.Printf("Game changed\n") //fmt.Printf("Soundtrack changed\n")
break break
} else if oldGame.SoundtrackName != file.Name() && oldGame.Hash == dirHash { } else if oldSoundtrack.SoundtrackName != file.Name() && oldSoundtrack.Hash == dirHash {
status = TitleChanged status = TitleChanged
id = oldGame.ID id = oldSoundtrack.ID
//fmt.Printf("SoundtrackName changed\n") //fmt.Printf("SoundtrackName changed\n")
break break
} }
} }
if full { if full && status != NewSoundtrack {
status = TitleChanged status = TitleChanged
} }
entries, err := os.ReadDir(gameDir) entries, err := os.ReadDir(soundtrackDir)
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to read game directory", zap.String("path", gameDir), zap.String("error", err.Error())) logging.GetLogger().Error("Failed to read soundtrack directory", zap.String("path", soundtrackDir), zap.String("error", err.Error()))
} }
switch status { switch status {
case NewGame: case NewSoundtrack:
if id != -1 { id = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
for _, entry := range entries { logging.GetLogger().Debug("New soundtrack detected",
fileInfo, err := entry.Info()
if err != nil {
logging.GetLogger().Error("Failed to get file info", zap.String("error", err.Error()))
continue
}
id = getIdFromFileNew(fileInfo)
if id != -1 {
break
}
}
gameUuid := pgtype.UUID{Bytes: uuid.New(), Valid: true}
err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, Uuid: gameUuid, 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",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("game", file.Name()), zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
gamesAdded = append(gamesAdded, file.Name()) soundtracksAdded = append(soundtracksAdded, file.Name())
newCheckSongs(entries, gameDir, id) checkSongs(entries, soundtrackDir, id)
case GameChanged: case SoundtrackChanged:
logging.GetLogger().Debug("Game changed", logging.GetLogger().Debug("Soundtrack changed",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("game", file.Name()), zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id}) err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
handleError("UpdateSoundtrackHash", err, "") handleError("UpdateSoundtrackHash", err, "")
gamesChangedContent = append(gamesChangedContent, file.Name()) soundtracksChangedContent = append(soundtracksChangedContent, file.Name())
newCheckSongs(entries, gameDir, id) checkSongs(entries, soundtrackDir, id)
case TitleChanged: case TitleChanged:
logging.GetLogger().Debug("Game title changed", logging.GetLogger().Debug("Soundtrack title changed",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("oldName", oldGame.SoundtrackName), zap.String("oldName", oldSoundtrack.SoundtrackName),
zap.String("newName", file.Name()), zap.String("newName", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: gameDir, ID: id}) err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id})
handleError("UpdateSoundtrackName", err, "") handleError("UpdateSoundtrackName", err, "")
newCheckSongs(entries, gameDir, id) checkSongs(entries, soundtrackDir, id)
if gamesChangedTitle == nil { if soundtracksChangedTitle == nil {
gamesChangedTitle = make(map[string]string) soundtracksChangedTitle = make(map[string]string)
} }
gamesChangedTitle[oldGame.SoundtrackName] = file.Name() soundtracksChangedTitle[oldSoundtrack.SoundtrackName] = file.Name()
case NotChanged: case NotChanged:
var found bool = false var found bool = false
for _, beforeGame := range gamesBeforeSync { for _, beforeSoundtrack := range soundtracksBeforeSync {
if dirHash == beforeGame.Hash { if dirHash == beforeSoundtrack.Hash {
found = true found = true
logging.GetLogger().Debug("Game not changed", logging.GetLogger().Debug("Soundtrack not changed",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("newName", file.Name()), zap.String("newName", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
@@ -411,9 +373,9 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
} }
} }
if !found { if !found {
newCheckSongs(entries, gameDir, id) checkSongs(entries, soundtrackDir, id)
gamesReAdded = append(gamesReAdded, file.Name()) soundtracksReAdded = append(soundtracksReAdded, file.Name())
logging.GetLogger().Debug("Game added again", logging.GetLogger().Debug("Soundtrack added again",
zap.Int32("id", id), zap.Int32("id", id),
zap.String("newName", file.Name()), zap.String("newName", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
@@ -421,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.Int32("id", id),
zap.String("game", file.Name()), zap.String("soundtrack", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id) err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
@@ -436,25 +398,24 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100))) zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100)))
} }
func insertGameNew(name string, path string, hash string) int32 { func insertSoundtrack(name string, path string, hash string) int32 {
var duplicateError = errors.New("ERROR: duplicate key value violates unique") var duplicateError = errors.New("ERROR: duplicate key value violates unique")
gameUuid := pgtype.UUID{Bytes: uuid.New(), Valid: true} id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash})
id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{Uuid: gameUuid, SoundtrackName: name, Path: path, Hash: hash})
handleError("InsertSoundtrack", err, "") handleError("InsertSoundtrack", err, "")
if err != nil { if err != nil {
logging.GetLogger().Warn("ID collision detected, resetting sequence") logging.GetLogger().Warn("ID collision detected, resetting sequence")
if strings.HasPrefix(err.Error(), duplicateError.Error()) { if strings.HasPrefix(err.Error(), duplicateError.Error()) {
logging.GetLogger().Debug("Resetting game ID sequence") logging.GetLogger().Debug("Resetting soundtrack ID sequence")
_, err = repo.ResetSoundtrackIdSeq(BackendCtx()) _, err = repo.ResetSoundtrackIdSeq(BackendCtx())
handleError("ResetSoundtrackIdSeq", err, "") handleError("ResetSoundtrackIdSeq", err, "")
id = insertGameNew(name, path, hash) id = insertSoundtrack(name, path, hash)
} }
} }
return id 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() //hasher := md5.New()
var numberOfSongs int32 var numberOfSongs int32
numberOfFiles := len(entries) numberOfFiles := len(entries)
@@ -464,7 +425,7 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
for _, entry := range entries { for _, entry := range entries {
poolSong.Submit(func() { poolSong.Submit(func() {
defer songWg.Done() defer songWg.Done()
if newCheckSong(entry, gameDir, id) { if checkSong(entry, soundtrackDir, id) {
numberOfSongs++ numberOfSongs++
} }
}) })
@@ -473,7 +434,7 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
return numberOfSongs return numberOfSongs
} }
func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool { func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool {
fileInfo, err := entry.Info() fileInfo, err := entry.Info()
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
@@ -481,7 +442,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
} }
if isSong(fileInfo) { if isSong(fileInfo) {
path := gameDir + entry.Name() path := soundtrackDir + entry.Name()
songHash := getHashForFile(path) songHash := getHashForFile(path)
//numberOfSongs++ //numberOfSongs++
@@ -497,7 +458,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
} }
} }
logging.GetLogger().Debug("Song changed", logging.GetLogger().Debug("Song changed",
zap.Int32("game_id", id), zap.Int32("soundtrack_id", id),
zap.String("path", path), zap.String("path", path),
zap.String("song_name", songName), zap.String("song_name", songName),
zap.String("song_hash", songHash)) zap.String("song_hash", songHash))
@@ -526,8 +487,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path}) err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path})
handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
} else { } else {
songUuid := pgtype.UUID{Bytes: uuid.New(), Valid: true} err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
err = repo.AddSong(BackendCtx(), repository.AddSongParams{Uuid: songUuid, SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
} }
@@ -556,8 +516,8 @@ func handleError(funcName string, err error, msg string) {
} }
} }
func getHashForDir(gameDir string) string { func getHashForDir(soundtrackDir string) string {
directory, _ := directory_checksum.ScanDirectory(gameDir, afero.NewOsFs()) directory, _ := directory_checksum.ScanDirectory(soundtrackDir, afero.NewOsFs())
hash, _ := directory.ComputeDirectoryChecksums() hash, _ := directory.ComputeDirectoryChecksums()
return hash return hash
@@ -578,7 +538,7 @@ func getHashForFile(path string) string {
return hex.EncodeToString(hasher.Sum(nil)) return hex.EncodeToString(hasher.Sum(nil))
} }
func getIdFromFileNew(file os.FileInfo) int32 { func getIdFromFile(file os.FileInfo) int32 {
name := file.Name() name := file.Name()
if !file.IsDir() && strings.HasSuffix(name, ".id") { if !file.IsDir() && strings.HasSuffix(name, ".id") {
name = strings.Replace(name, ".id", "", 1) name = strings.Replace(name, ".id", "", 1)
+18 -18
View File
@@ -9,10 +9,10 @@ import (
func TestContains(t *testing.T) { func TestContains(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
slice []string slice []string
search string search string
expected bool expected bool
}{ }{
{ {
name: "element exists", name: "element exists",
@@ -155,9 +155,9 @@ func TestGetIdFromFileNew(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := getIdFromFileNew(tt.fileInfo) result := getIdFromFile(tt.fileInfo)
if result != tt.expected { if result != tt.expected {
t.Errorf("getIdFromFileNew() = %v, want %v", result, tt.expected) t.Errorf("getIdFromFile() = %v, want %v", result, tt.expected)
} }
}) })
} }
@@ -172,10 +172,10 @@ type mockFileInfoForSong struct {
func (m *mockFileInfoForSong) Name() string { return m.name } func (m *mockFileInfoForSong) Name() string { return m.name }
func (m *mockFileInfoForSong) Size() int64 { return m.size } func (m *mockFileInfoForSong) Size() int64 { return m.size }
func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 } func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} } func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForSong) IsDir() bool { return m.isDir } func (m *mockFileInfoForSong) IsDir() bool { return m.isDir }
func (m *mockFileInfoForSong) Sys() interface{} { return nil } func (m *mockFileInfoForSong) Sys() interface{} { return nil }
type mockFileInfoForCover struct { type mockFileInfoForCover struct {
name string name string
@@ -185,10 +185,10 @@ type mockFileInfoForCover struct {
func (m *mockFileInfoForCover) Name() string { return m.name } func (m *mockFileInfoForCover) Name() string { return m.name }
func (m *mockFileInfoForCover) Size() int64 { return m.size } func (m *mockFileInfoForCover) Size() int64 { return m.size }
func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 } func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} } func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForCover) IsDir() bool { return m.isDir } func (m *mockFileInfoForCover) IsDir() bool { return m.isDir }
func (m *mockFileInfoForCover) Sys() interface{} { return nil } func (m *mockFileInfoForCover) Sys() interface{} { return nil }
type mockFileInfoForId struct { type mockFileInfoForId struct {
name string name string
@@ -198,7 +198,7 @@ type mockFileInfoForId struct {
func (m *mockFileInfoForId) Name() string { return m.name } func (m *mockFileInfoForId) Name() string { return m.name }
func (m *mockFileInfoForId) Size() int64 { return m.size } func (m *mockFileInfoForId) Size() int64 { return m.size }
func (m *mockFileInfoForId) Mode() os.FileMode { return 0 } func (m *mockFileInfoForId) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} } func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForId) IsDir() bool { return m.isDir } func (m *mockFileInfoForId) IsDir() bool { return m.isDir }
func (m *mockFileInfoForId) Sys() interface{} { return nil } func (m *mockFileInfoForId) Sys() interface{} { return nil }
+111
View File
@@ -0,0 +1,111 @@
package backend
type VersionData struct {
Version string `json:"version" example:"1.0.0"`
Changelog []string `json:"changelog" example:"[\"Initial release\",\"Bug fixes\"]"`
}
var data = []VersionData{
{
Version: "5.0.0-Beta",
Changelog: []string{
"#16 - Upgrade Echo framework from v4 to v5",
"#17 - Add Zap structured logging framework",
"#18 - Add OpenAPI/Swagger documentation",
"#19 - Replace Tailwind CSS with pure CSS",
"#20 - Change domain from sanplex.tech to sanplex.xyz",
"#21 - Refactor handlers into domain-specific files",
"#22 - Change VersionData Changelog from string to string array",
"#23 - Update all dependencies to latest versions",
},
},
{
Version: "4.5.0",
Changelog: []string{
"#1 - Created request to check newest version of the app",
"#2 - Added request to download the newest version of the app",
"#3 - Added request to check progress during sync",
"#4 - Now blocking all request while sync is in progress",
"#5 - Implemented ants for thread pooling",
"#6 - Changed the sync request to now only start the sync",
},
},
{
Version: "4.0.0",
Changelog: []string{
"Changed framework from gin to Echo",
"Reorganized the code",
"Implemented sqlc",
"Added support to send character images from the server",
"Added function to create a new database of no one exists",
},
},
{
Version: "3.2",
Changelog: []string{"Upgraded Go version and the version of all dependencies. Fixed som more bugs."},
},
{
Version: "3.1",
Changelog: []string{"Fixed some bugs with songs not found made the application crash. Now checking if song exists and if not, remove song from DB and find another one. Frontend is now decoupled from the backend."},
},
{
Version: "3.0",
Changelog: []string{"Changed routing framework from mux to Gin. Swagger doc is now included in the application. A fronted can now be hosted from the application."},
},
{
Version: "2.3.0",
Changelog: []string{"Images should not be included in the database, removes songs where the path doesn't work."},
},
{
Version: "2.2.0",
Changelog: []string{"Changed the structure of the whole application, should be no changes to functionality."},
},
{
Version: "2.1.4",
Changelog: []string{"Game list should now be sorted, a new endpoint with the game list in random order have been added."},
},
{
Version: "2.1.3",
Changelog: []string{"Added a check to see if song exists before returning it, if not a new song will be picked up."},
},
{
Version: "2.1.2",
Changelog: []string{"Added test server to swagger file."},
},
{
Version: "2.1.1",
Changelog: []string{"Fixed bug where wrong song was showed as currently played."},
},
{
Version: "2.1.0",
Changelog: []string{
"Added /addQue to add the last received song to the songQue.",
"Changed /rand and /rand/low to not add song to the que.",
"Changed /next to not call /rand when the end of the que is reached, instead the last song in the que will be resent.",
},
},
{
Version: "2.0.3",
Changelog: []string{"Another small change that should fix the caching problem."},
},
{
Version: "2.0.2",
Changelog: []string{"Hopefully fixed the caching problem with random."},
},
{
Version: "2.0.1",
Changelog: []string{"Fixed CORS"},
},
{
Version: "2.0.0",
Changelog: []string{"Rebuilt the application in Go."},
},
}
func GetLatestVersion() VersionData {
return data[0]
}
func GetVersionHistory() []VersionData {
return data
}
+21
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"time"
"music-server/internal/logging" "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. // RunMigrations runs all pending database migrations to the latest version.
// Uses the existing pool to extract connection details. // Uses the existing pool to extract connection details.
func (db *Database) RunMigrations() error { func (db *Database) RunMigrations() error {
+3 -18
View File
@@ -56,25 +56,10 @@ func CloseDb() {
Dbpool.Close() Dbpool.Close()
} }
func Testf() { func ResetSoundtrackIdSeq() {
rows, dbErr := Dbpool.Query(Ctx, "select game_name from game") _, err := Dbpool.Query(Ctx, "SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);")
if dbErr != nil {
logging.GetLogger().Fatal("Query failed", zap.String("error", dbErr.Error()))
}
for rows.Next() {
var gameName string
dbErr = rows.Scan(&gameName)
if dbErr != nil {
logging.GetLogger().Error("Row scan failed", zap.String("error", dbErr.Error()))
}
logging.GetLogger().Debug("Game found", zap.String("name", gameName))
}
}
func ResetGameIdSeq() {
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);")
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to reset game ID sequence", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to reset soundtrack ID sequence", zap.String("error", err.Error()))
} }
} }
+19 -10
View File
@@ -1,7 +1,6 @@
package db package db
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"os" "os"
@@ -80,9 +79,9 @@ func TestMigrationsStepByStep(t *testing.T) {
} }
for _, s := range songs { for _, s := range songs {
_, err := db.Exec(`INSERT INTO song (game_id, song_name, path) _, err := db.Exec(`INSERT INTO song (game_id, song_name, path, hash)
VALUES ($1, $2, $3)`, VALUES ($1, $2, $3, $4)`,
s.gameID, s.name, s.path) s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name))
require.NoError(t, err, "Failed to insert song %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 var songCount int
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount) err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
require.NoError(t, err) 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) // Step 3: Apply migration 5 (rename game→soundtrack)
@@ -126,7 +125,7 @@ func TestMigrationsStepByStep(t *testing.T) {
var songCount int var songCount int
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount) err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
require.NoError(t, err) 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 // Verify data integrity: soundtrack_name values
rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id") 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) require.NoError(t, err)
m, err := migrate.NewWithDatabaseInstance( m, err := migrate.NewWithDatabaseInstance(
"file://internal/db/migrations", "file://migrations",
"postgres", driver) "postgres", driver)
require.NoError(t, err) require.NoError(t, err)
// Get current version // Get current version
version, _, err := m.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) t.Logf("Current migration version: %d", version)
// Apply exactly 'steps' migrations // Apply exactly 'steps' migrations
@@ -237,6 +241,11 @@ func applyMigrations(t *testing.T, host, port, user, password, dbname string, st
// Get new version // Get new version
newVersion, _, err := m.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) 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 -- Update song primary key
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey; ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
ALTER TABLE song ADD PRIMARY KEY (soundtrack_id, path); 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 -- Update song_list table references
ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name; ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name;
@@ -1,9 +0,0 @@
-- Rollback: Remove UUID columns from soundtrack and song tables
-- Drop indexes
DROP INDEX IF EXISTS idx_soundtrack_uuid;
DROP INDEX IF EXISTS idx_song_uuid;
-- Drop UUID columns
ALTER TABLE soundtrack DROP COLUMN IF EXISTS uuid;
ALTER TABLE song DROP COLUMN IF EXISTS uuid;
@@ -1,21 +0,0 @@
-- Migration: Add UUID columns to soundtrack and song, then backfill
-- Add UUID column to soundtrack (nullable for now)
ALTER TABLE soundtrack ADD COLUMN uuid UUID NULL UNIQUE;
-- Create index on uuid for performance
CREATE INDEX IF NOT EXISTS idx_soundtrack_uuid ON soundtrack(uuid);
-- Add UUID column to song (nullable for now)
ALTER TABLE song ADD COLUMN uuid UUID NULL UNIQUE;
-- Create index on uuid for performance
CREATE INDEX IF NOT EXISTS idx_song_uuid ON song(uuid);
-- Backfill existing records immediately
UPDATE soundtrack SET uuid = gen_random_uuid() WHERE uuid IS NULL;
UPDATE song SET uuid = gen_random_uuid() WHERE uuid IS NULL;
-- Verify no nulls remain
-- SELECT COUNT(*) FROM soundtrack WHERE uuid IS NULL; -- Should be 0
-- SELECT COUNT(*) FROM song WHERE uuid IS NULL; -- Should be 0
+1 -1
View File
@@ -5,7 +5,7 @@ DELETE FROM song;
DELETE FROM song WHERE soundtrack_id = $1; DELETE FROM song WHERE soundtrack_id = $1;
-- name: AddSong :exec -- name: AddSong :exec
INSERT INTO song(uuid, soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5, $6); INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
-- name: CheckSong :one -- name: CheckSong :one
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2; SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2;
+2 -2
View File
@@ -29,10 +29,10 @@ UPDATE soundtrack SET deleted=NULL WHERE id=$1;
SELECT id FROM soundtrack WHERE soundtrack_name = $1; SELECT id FROM soundtrack WHERE soundtrack_name = $1;
-- name: InsertSoundtrack :one -- name: InsertSoundtrack :one
INSERT INTO soundtrack (uuid, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now()) returning id; INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id;
-- name: InsertSoundtrackWithExistingId :exec -- name: InsertSoundtrackWithExistingId :exec
INSERT INTO soundtrack (id, uuid, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, $5, now()); INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
-- name: FindAllSoundtracks :many -- name: FindAllSoundtracks :many
SELECT * SELECT *
+10 -10
View File
@@ -1,5 +1,5 @@
-- Most played soundtracks with their songs -- Most played soundtracks with their songs
-- name: GetMostPlayedGamesWithSongs :many -- name: GetMostPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -21,7 +21,7 @@ ORDER BY g.times_played DESC, g.soundtrack_name
LIMIT $1; LIMIT $1;
-- Least played soundtracks with their songs -- Least played soundtracks with their songs
-- name: GetLeastPlayedGamesWithSongs :many -- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -43,7 +43,7 @@ ORDER BY g.times_played ASC, g.soundtrack_name
LIMIT $1; LIMIT $1;
-- Most played songs with their soundtrack info -- Most played songs with their soundtrack info
-- name: GetMostPlayedSongsWithGame :many -- name: GetMostPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -58,7 +58,7 @@ ORDER BY s.times_played DESC, s.song_name
LIMIT $1; LIMIT $1;
-- Least played songs with their soundtrack info -- Least played songs with their soundtrack info
-- name: GetLeastPlayedSongsWithGame :many -- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -72,8 +72,8 @@ WHERE g.deleted IS NULL
ORDER BY s.times_played ASC, s.song_name ORDER BY s.times_played ASC, s.song_name
LIMIT $1; LIMIT $1;
-- Games that have never been played (times_played = 0) -- Soundtracks that have never been played (times_played = 0)
-- name: GetNeverPlayedGames :many -- name: GetNeverPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -93,7 +93,7 @@ GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
ORDER BY g.soundtrack_name; ORDER BY g.soundtrack_name;
-- Last played soundtracks (most recently played) -- Last played soundtracks (most recently played)
-- name: GetLastPlayedGames :many -- name: GetLastPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -114,7 +114,7 @@ ORDER BY g.last_played DESC
LIMIT $1; LIMIT $1;
-- Oldest played soundtracks (least recently played, but has been played at least once) -- Oldest played soundtracks (least recently played, but has been played at least once)
-- name: GetOldestPlayedGames :many -- name: GetOldestPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -138,8 +138,8 @@ LIMIT $1;
-- name: GetStatisticsSummary :one -- name: GetStatisticsSummary :one
SELECT SELECT
COUNT(*) as total_soundtracks, COUNT(*) as total_soundtracks,
SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks, COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint 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 never_played_soundtracks,
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays, COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays, COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays, COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
+10 -12
View File
@@ -27,7 +27,6 @@ type Song struct {
Hash string `json:"hash"` Hash string `json:"hash"`
FileName *string `json:"file_name"` FileName *string `json:"file_name"`
ID pgtype.Int4 `json:"id"` ID pgtype.Int4 `json:"id"`
Uuid pgtype.UUID `json:"uuid"`
} }
type SongList struct { type SongList struct {
@@ -39,17 +38,16 @@ type SongList struct {
} }
type Soundtrack struct { type Soundtrack struct {
ID int32 `json:"id"` ID int32 `json:"id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
Added time.Time `json:"added"` Added time.Time `json:"added"`
Deleted *time.Time `json:"deleted"` Deleted *time.Time `json:"deleted"`
LastChanged *time.Time `json:"last_changed"` LastChanged *time.Time `json:"last_changed"`
Path string `json:"path"` Path string `json:"path"`
TimesPlayed int32 `json:"times_played"` TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played"` LastPlayed *time.Time `json:"last_played"`
NumberOfSongs int32 `json:"number_of_songs"` NumberOfSongs int32 `json:"number_of_songs"`
Hash string `json:"hash"` Hash string `json:"hash"`
Uuid pgtype.UUID `json:"uuid"`
} }
type Vgmq struct { type Vgmq struct {
+10 -16
View File
@@ -27,21 +27,19 @@ func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) er
} }
const addSong = `-- name: AddSong :exec const addSong = `-- name: AddSong :exec
INSERT INTO song(uuid, soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5, $6) INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
` `
type AddSongParams struct { type AddSongParams struct {
Uuid pgtype.UUID `json:"uuid"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackID int32 `json:"soundtrack_id"` SongName string `json:"song_name"`
SongName string `json:"song_name"` Path string `json:"path"`
Path string `json:"path"` FileName *string `json:"file_name"`
FileName *string `json:"file_name"` Hash string `json:"hash"`
Hash string `json:"hash"`
} }
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error { func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
_, err := q.db.Exec(ctx, addSong, _, err := q.db.Exec(ctx, addSong,
arg.Uuid,
arg.SoundtrackID, arg.SoundtrackID,
arg.SongName, arg.SongName,
arg.Path, arg.Path,
@@ -112,7 +110,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int
} }
const fetchAllSongs = `-- name: FetchAllSongs :many const fetchAllSongs = `-- name: FetchAllSongs :many
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, 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) { func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
@@ -132,7 +130,6 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
&i.Hash, &i.Hash,
&i.FileName, &i.FileName,
&i.ID, &i.ID,
&i.Uuid,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -145,7 +142,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
} }
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id
FROM song FROM song
WHERE soundtrack_id = $1 WHERE soundtrack_id = $1
` `
@@ -167,7 +164,6 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
&i.Hash, &i.Hash,
&i.FileName, &i.FileName,
&i.ID, &i.ID,
&i.Uuid,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -180,7 +176,7 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
} }
const getSongById = `-- name: GetSongById :one 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) { func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) {
@@ -194,13 +190,12 @@ func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error)
&i.Hash, &i.Hash,
&i.FileName, &i.FileName,
&i.ID, &i.ID,
&i.Uuid,
) )
return i, err return i, err
} }
const getSongWithHash = `-- name: GetSongWithHash :one 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) { func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
@@ -214,7 +209,6 @@ func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error
&i.Hash, &i.Hash,
&i.FileName, &i.FileName,
&i.ID, &i.ID,
&i.Uuid,
) )
return i, err return i, err
} }
+13 -26
View File
@@ -7,8 +7,6 @@ package repository
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype"
) )
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
@@ -30,7 +28,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
} }
const findAllSoundtracks = `-- name: FindAllSoundtracks :many const findAllSoundtracks = `-- name: FindAllSoundtracks :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
FROM soundtrack FROM soundtrack
WHERE deleted IS NULL WHERE deleted IS NULL
ORDER BY soundtrack_name ORDER BY soundtrack_name
@@ -56,7 +54,6 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.LastPlayed, &i.LastPlayed,
&i.NumberOfSongs, &i.NumberOfSongs,
&i.Hash, &i.Hash,
&i.Uuid,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -69,7 +66,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
} }
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
FROM soundtrack FROM soundtrack
ORDER BY soundtrack_name ORDER BY soundtrack_name
` `
@@ -94,7 +91,6 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.LastPlayed, &i.LastPlayed,
&i.NumberOfSongs, &i.NumberOfSongs,
&i.Hash, &i.Hash,
&i.Uuid,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -118,7 +114,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
} }
const getSoundtrackById = `-- name: GetSoundtrackById :one const getSoundtrackById = `-- name: GetSoundtrackById :one
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
FROM soundtrack FROM soundtrack
WHERE id = $1 WHERE id = $1
AND deleted IS NULL AND deleted IS NULL
@@ -138,7 +134,6 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.LastPlayed, &i.LastPlayed,
&i.NumberOfSongs, &i.NumberOfSongs,
&i.Hash, &i.Hash,
&i.Uuid,
) )
return i, err return i, err
} }
@@ -155,44 +150,36 @@ func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string,
} }
const insertSoundtrack = `-- name: InsertSoundtrack :one const insertSoundtrack = `-- name: InsertSoundtrack :one
INSERT INTO soundtrack (uuid, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now()) returning id INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
` `
type InsertSoundtrackParams struct { type InsertSoundtrackParams struct {
Uuid pgtype.UUID `json:"uuid"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackName string `json:"soundtrack_name"` Path string `json:"path"`
Path string `json:"path"` Hash string `json:"hash"`
Hash string `json:"hash"`
} }
func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) { func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) {
row := q.db.QueryRow(ctx, insertSoundtrack, row := q.db.QueryRow(ctx, insertSoundtrack, arg.SoundtrackName, arg.Path, arg.Hash)
arg.Uuid,
arg.SoundtrackName,
arg.Path,
arg.Hash,
)
var id int32 var id int32
err := row.Scan(&id) err := row.Scan(&id)
return id, err return id, err
} }
const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec
INSERT INTO soundtrack (id, uuid, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, $5, now()) INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now())
` `
type InsertSoundtrackWithExistingIdParams struct { type InsertSoundtrackWithExistingIdParams struct {
ID int32 `json:"id"` ID int32 `json:"id"`
Uuid pgtype.UUID `json:"uuid"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackName string `json:"soundtrack_name"` Path string `json:"path"`
Path string `json:"path"` Hash string `json:"hash"`
Hash string `json:"hash"`
} }
func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error { func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error {
_, err := q.db.Exec(ctx, insertSoundtrackWithExistingId, _, err := q.db.Exec(ctx, insertSoundtrackWithExistingId,
arg.ID, arg.ID,
arg.Uuid,
arg.SoundtrackName, arg.SoundtrackName,
arg.Path, arg.Path,
arg.Hash, arg.Hash,
+45 -45
View File
@@ -10,7 +10,7 @@ import (
"time" "time"
) )
const getLastPlayedGames = `-- name: GetLastPlayedGames :many const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -31,7 +31,7 @@ ORDER BY g.last_played DESC
LIMIT $1 LIMIT $1
` `
type GetLastPlayedGamesRow struct { type GetLastPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -40,15 +40,15 @@ type GetLastPlayedGamesRow struct {
} }
// Last played soundtracks (most recently played) // Last played soundtracks (most recently played)
func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLastPlayedGamesRow, error) { func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getLastPlayedGames, limit) rows, err := q.db.Query(ctx, getLastPlayedSoundtracks, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetLastPlayedGamesRow var items []GetLastPlayedSoundtracksRow
for rows.Next() { for rows.Next() {
var i GetLastPlayedGamesRow var i GetLastPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -66,7 +66,7 @@ func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLas
return items, nil return items, nil
} }
const getLeastPlayedGamesWithSongs = `-- name: GetLeastPlayedGamesWithSongs :many const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -88,7 +88,7 @@ ORDER BY g.times_played ASC, g.soundtrack_name
LIMIT $1 LIMIT $1
` `
type GetLeastPlayedGamesWithSongsRow struct { type GetLeastPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -97,15 +97,15 @@ type GetLeastPlayedGamesWithSongsRow struct {
} }
// Least played soundtracks with their songs // Least played soundtracks with their songs
func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedGamesWithSongsRow, error) { func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) {
rows, err := q.db.Query(ctx, getLeastPlayedGamesWithSongs, limit) rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetLeastPlayedGamesWithSongsRow var items []GetLeastPlayedSoundtracksWithSongsRow
for rows.Next() { for rows.Next() {
var i GetLeastPlayedGamesWithSongsRow var i GetLeastPlayedSoundtracksWithSongsRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -123,7 +123,7 @@ func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32)
return items, nil return items, nil
} }
const getLeastPlayedSongsWithGame = `-- name: GetLeastPlayedSongsWithGame :many const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -138,7 +138,7 @@ ORDER BY s.times_played ASC, s.song_name
LIMIT $1 LIMIT $1
` `
type GetLeastPlayedSongsWithGameRow struct { type GetLeastPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"` SongName string `json:"song_name"`
@@ -148,15 +148,15 @@ type GetLeastPlayedSongsWithGameRow struct {
} }
// Least played songs with their soundtrack info // Least played songs with their soundtrack info
func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithGameRow, error) { func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithSoundtrackRow, error) {
rows, err := q.db.Query(ctx, getLeastPlayedSongsWithGame, limit) rows, err := q.db.Query(ctx, getLeastPlayedSongsWithSoundtrack, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetLeastPlayedSongsWithGameRow var items []GetLeastPlayedSongsWithSoundtrackRow
for rows.Next() { for rows.Next() {
var i GetLeastPlayedSongsWithGameRow var i GetLeastPlayedSongsWithSoundtrackRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -175,7 +175,7 @@ func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32)
return items, nil return items, nil
} }
const getMostPlayedGamesWithSongs = `-- name: GetMostPlayedGamesWithSongs :many const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -197,7 +197,7 @@ ORDER BY g.times_played DESC, g.soundtrack_name
LIMIT $1 LIMIT $1
` `
type GetMostPlayedGamesWithSongsRow struct { type GetMostPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -206,15 +206,15 @@ type GetMostPlayedGamesWithSongsRow struct {
} }
// Most played soundtracks with their songs // Most played soundtracks with their songs
func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedGamesWithSongsRow, error) { func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) {
rows, err := q.db.Query(ctx, getMostPlayedGamesWithSongs, limit) rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetMostPlayedGamesWithSongsRow var items []GetMostPlayedSoundtracksWithSongsRow
for rows.Next() { for rows.Next() {
var i GetMostPlayedGamesWithSongsRow var i GetMostPlayedSoundtracksWithSongsRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -232,7 +232,7 @@ func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32)
return items, nil return items, nil
} }
const getMostPlayedSongsWithGame = `-- name: GetMostPlayedSongsWithGame :many const getMostPlayedSongsWithSoundtrack = `-- name: GetMostPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -247,7 +247,7 @@ ORDER BY s.times_played DESC, s.song_name
LIMIT $1 LIMIT $1
` `
type GetMostPlayedSongsWithGameRow struct { type GetMostPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"` SongName string `json:"song_name"`
@@ -257,15 +257,15 @@ type GetMostPlayedSongsWithGameRow struct {
} }
// Most played songs with their soundtrack info // Most played songs with their soundtrack info
func (q *Queries) GetMostPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithGameRow, error) { func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithSoundtrackRow, error) {
rows, err := q.db.Query(ctx, getMostPlayedSongsWithGame, limit) rows, err := q.db.Query(ctx, getMostPlayedSongsWithSoundtrack, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetMostPlayedSongsWithGameRow var items []GetMostPlayedSongsWithSoundtrackRow
for rows.Next() { for rows.Next() {
var i GetMostPlayedSongsWithGameRow var i GetMostPlayedSongsWithSoundtrackRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -284,7 +284,7 @@ func (q *Queries) GetMostPlayedSongsWithGame(ctx context.Context, limit int32) (
return items, nil return items, nil
} }
const getNeverPlayedGames = `-- name: GetNeverPlayedGames :many const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -304,7 +304,7 @@ GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
ORDER BY g.soundtrack_name ORDER BY g.soundtrack_name
` `
type GetNeverPlayedGamesRow struct { type GetNeverPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -312,16 +312,16 @@ type GetNeverPlayedGamesRow struct {
Songs []byte `json:"songs"` Songs []byte `json:"songs"`
} }
// Games that have never been played (times_played = 0) // Soundtracks that have never been played (times_played = 0)
func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGamesRow, error) { func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getNeverPlayedGames) rows, err := q.db.Query(ctx, getNeverPlayedSoundtracks)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetNeverPlayedGamesRow var items []GetNeverPlayedSoundtracksRow
for rows.Next() { for rows.Next() {
var i GetNeverPlayedGamesRow var i GetNeverPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -339,7 +339,7 @@ func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGame
return items, nil return items, nil
} }
const getOldestPlayedGames = `-- name: GetOldestPlayedGames :many const getOldestPlayedSoundtracks = `-- name: GetOldestPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.soundtrack_name, g.soundtrack_name,
@@ -360,7 +360,7 @@ ORDER BY g.last_played ASC
LIMIT $1 LIMIT $1
` `
type GetOldestPlayedGamesRow struct { type GetOldestPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
@@ -369,15 +369,15 @@ type GetOldestPlayedGamesRow struct {
} }
// Oldest played soundtracks (least recently played, but has been played at least once) // Oldest played soundtracks (least recently played, but has been played at least once)
func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetOldestPlayedGamesRow, error) { func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error) {
rows, err := q.db.Query(ctx, getOldestPlayedGames, limit) rows, err := q.db.Query(ctx, getOldestPlayedSoundtracks, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetOldestPlayedGamesRow var items []GetOldestPlayedSoundtracksRow
for rows.Next() { for rows.Next() {
var i GetOldestPlayedGamesRow var i GetOldestPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackName, &i.SoundtrackName,
@@ -398,8 +398,8 @@ func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetO
const getStatisticsSummary = `-- name: GetStatisticsSummary :one const getStatisticsSummary = `-- name: GetStatisticsSummary :one
SELECT SELECT
COUNT(*) as total_soundtracks, COUNT(*) as total_soundtracks,
SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks, COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint 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 never_played_soundtracks,
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays, COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays, COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
COALESCE(MAX(times_played), 0)::bigint as max_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) 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 // Run migrations
if err := TestDatabase.RunMigrations(); err != nil { 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) 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 // "closed pool" errors when tests run sequentially
func TestTearDownDB(t *testing.T) { func TestTearDownDB(t *testing.T) {
// CloseDb() // Disabled to prevent pool closure between sequential tests // CloseDb() // Disabled to prevent pool closure between sequential tests
if TestDatabase != nil { // Note: We also don't nil TestDatabase to allow reuse across tests
TestDatabase.Close() // if TestDatabase != nil {
TestDatabase = nil // TestDatabase.Close()
} // TestDatabase = nil
// }
} }
// TestClearDatabase clears all data from the test database // 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 // Clear all tables in reverse order to respect foreign keys
// Note: This assumes the tables exist and have the expected structure // Note: This assumes the tables exist and have the expected structure
// After migration 000005, game table was renamed to soundtrack
tables := []string{ tables := []string{
"song_list", "song_list",
"song", "song",
"game", "soundtrack",
"vgmq",
"sessions",
} }
ctx := context.Background() ctx := context.Background()
@@ -126,9 +141,10 @@ func TestClearDatabase(t *testing.T) {
} }
} }
// Reset sequences // Reset sequences (renamed from game_id_seq to soundtrack_id_seq in migration 000005)
_, err := TestDatabase.Pool.Exec(ctx, "SELECT setval('game_id_seq', 1, false)") var seqErr error
if err != nil { _, seqErr = TestDatabase.Pool.Exec(ctx, "SELECT setval('soundtrack_id_seq', 1, false)")
t.Logf("Failed to reset game_id_seq: %v", err) if seqErr != nil {
t.Logf("Failed to reset soundtrack_id_seq: %v", seqErr)
} }
} }
+49
View File
@@ -0,0 +1,49 @@
package server
import (
"net/http"
"os"
"github.com/labstack/echo/v5"
"music-server/internal/backend"
)
type CharacterHandler struct {
}
func NewCharacterHandler() *CharacterHandler {
return &CharacterHandler{}
}
// GetCharacterList godoc
// @Summary Get list of characters
// @Description Returns a list of all available characters
// @Tags characters
// @Accept json
// @Produce json
// @Success 200 {array} string
// @Router /characters [get]
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
characters := backend.GetCharacterList()
return ctx.JSON(http.StatusOK, characters)
}
// GetCharacter godoc
// @Summary Get character image
// @Description Returns the image for a specific character
// @Tags characters
// @Accept json
// @Produce image/png
// @Param name query string true "Character name"
// @Success 200 {file} file
// @Router /character [get]
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
character := ctx.QueryParam("name")
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)
}
@@ -5,45 +5,9 @@ import (
"net/http" "net/http"
"testing" "testing"
"music-server/internal/backend"
"music-server/internal/db"
"github.com/stretchr/testify/assert" "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)
resp := MakeTestRequest(t, e, "GET", "/health")
assert.Equal(t, http.StatusOK, resp.Code)
var healthData map[string]string
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
assert.NoError(t, err)
assert.NotEmpty(t, healthData)
assert.Equal(t, "up", healthData["status"])
}
// TestGetVersion verifies the version endpoint returns version history
func TestGetVersion(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/version")
assert.Equal(t, http.StatusOK, resp.Code)
var versionData backend.VersionData
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
assert.NoError(t, err)
assert.NotEmpty(t, versionData.Version)
assert.NotEmpty(t, versionData.Changelog)
assert.NotEmpty(t, versionData.History)
}
// TestGetCharacterList verifies the characters endpoint returns list of characters // TestGetCharacterList verifies the characters endpoint returns list of characters
func TestGetCharacterList(t *testing.T) { func TestGetCharacterList(t *testing.T) {
e := StartTestServer(t) e := StartTestServer(t)
@@ -81,16 +45,3 @@ func TestGetCharacterNotFound(t *testing.T) {
// Should return 404 or similar error // Should return 404 or similar error
assert.NotEqual(t, http.StatusOK, resp.Code) assert.NotEqual(t, http.StatusOK, resp.Code)
} }
// TestDBTest verifies the database test endpoint
func TestDBTest(t *testing.T) {
// Setup database
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/dbtest")
assert.Equal(t, http.StatusOK, resp.Code)
assert.Contains(t, resp.Body.String(), "TestedDB")
}
+29
View File
@@ -0,0 +1,29 @@
package server
import (
"net/http"
"github.com/labstack/echo/v5"
"music-server/internal/db"
)
type HealthHandler struct {
db *db.Database
}
func NewHealthHandler(database *db.Database) *HealthHandler {
return &HealthHandler{db: database}
}
// HealthCheck godoc
//
// @Summary Check server health
// @Description Returns the health status of the server
// @Tags health
// @Accept json
// @Produce json
// @Success 200 {string} string "OK"
// @Router /health [get]
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, h.db.Health())
}
+24
View File
@@ -0,0 +1,24 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
// TestHealthCheck verifies the health endpoint returns database status
func TestHealthCheck(t *testing.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)
var healthData map[string]string
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
assert.NoError(t, err)
assert.NotEmpty(t, healthData)
assert.Equal(t, "up", healthData["status"])
}
-86
View File
@@ -1,86 +0,0 @@
package server
import (
"music-server/internal/backend"
"music-server/internal/db"
"net/http"
"github.com/labstack/echo/v5"
)
type IndexHandler struct {
}
func NewIndexHandler() *IndexHandler {
return &IndexHandler{}
}
// GetVersion godoc
//
// @Summary Getting the version of the backend
// @Description get string by ID
// @Tags accounts
// @Accept json
// @Produce json
// @Success 200 {object} backend.VersionData
// @Failure 404 {object} string
// @Router /version [get]
func (i *IndexHandler) GetVersion(ctx *echo.Context) error {
versionHistory := backend.GetVersionHistory()
if versionHistory.Version == "" {
return ctx.JSON(http.StatusNotFound, "version not found")
}
return ctx.JSON(http.StatusOK, versionHistory)
}
// GetDBTest godoc
// @Summary Test database connection
// @Description Tests the database connection
// @Tags database
// @Accept json
// @Produce json
// @Success 200 {string} string "TestedDB"
// @Router /dbtest [get]
func (i *IndexHandler) GetDBTest(ctx *echo.Context) error {
backend.TestDB()
return ctx.JSON(http.StatusOK, "TestedDB")
}
// HealthCheck godoc
// @Summary Check server health
// @Description Returns the health status of the server
// @Tags health
// @Accept json
// @Produce json
// @Success 200 {string} string "OK"
// @Router /health [get]
func (i *IndexHandler) HealthCheck(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, db.Health())
}
// GetCharacterList godoc
// @Summary Get list of characters
// @Description Returns a list of all available characters
// @Tags characters
// @Accept json
// @Produce json
// @Success 200 {array} string
// @Router /characters [get]
func (i *IndexHandler) GetCharacterList(ctx *echo.Context) error {
characters := backend.GetCharacterList()
return ctx.JSON(http.StatusOK, characters)
}
// GetCharacter godoc
// @Summary Get character image
// @Description Returns the image for a specific character
// @Tags characters
// @Accept json
// @Produce image/png
// @Param name query string true "Character name"
// @Success 200 {file} file
// @Router /character [get]
func (i *IndexHandler) GetCharacter(ctx *echo.Context) error {
character := ctx.QueryParam("name")
return ctx.File(backend.GetCharacter(character))
}
+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" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music [get] // @Router /music [get]
func (m *MusicHandler) GetSong(ctx *echo.Context) error { func (m *MusicHandler) GetSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
song := ctx.QueryParam("song") song := ctx.QueryParam("song")
if song == "" { if song == "" {
return ctx.String(http.StatusBadRequest, "song can't be empty") return ctx.String(http.StatusBadRequest, "song can't be empty")
@@ -58,10 +54,6 @@ func (m *MusicHandler) GetSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/soundTest [get] // @Router /music/soundTest [get]
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error { func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetSoundCheckSong() songPath := backend.GetSoundCheckSong()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -80,10 +72,6 @@ func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/reset [get] // @Router /music/reset [get]
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error { func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
backend.Reset() backend.Reset()
return ctx.NoContent(http.StatusOK) return ctx.NoContent(http.StatusOK)
} }
@@ -98,10 +86,6 @@ func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand [get] // @Router /music/rand [get]
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error { func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetRandomSong() songPath := backend.GetRandomSong()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -121,10 +105,6 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/low [get] // @Router /music/rand/low [get]
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error { func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetRandomSongLowChance() songPath := backend.GetRandomSongLowChance()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -144,10 +124,6 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/classic [get] // @Router /music/rand/classic [get]
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error { func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetRandomSongClassic() songPath := backend.GetRandomSongClassic()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -193,10 +169,6 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/next [get] // @Router /music/next [get]
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error { func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetNextSong() songPath := backend.GetNextSong()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -216,10 +188,6 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/previous [get] // @Router /music/previous [get]
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error { func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
songPath := backend.GetPreviousSong() songPath := backend.GetPreviousSong()
file, err := os.Open(songPath) file, err := os.Open(songPath)
if err != nil { if err != nil {
@@ -231,7 +199,7 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
// GetAllSoundtracks godoc // GetAllSoundtracks godoc
// @Summary Get all soundtracks // @Summary Get all soundtracks
// @Description Returns a list of all games in order // @Description Returns a list of all soundtracks in order
// @Tags music // @Tags music
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -239,17 +207,13 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/order [get] // @Router /music/all/order [get]
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error { func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
soundtrackList := backend.GetAllSoundtracks() soundtrackList := backend.GetAllSoundtracks()
return ctx.JSON(http.StatusOK, soundtrackList) return ctx.JSON(http.StatusOK, soundtrackList)
} }
// GetAllSoundtracksRandom godoc // GetAllSoundtracksRandom godoc
// @Summary Get all soundtracks random // @Summary Get all soundtracks random
// @Description Returns a list of all games in random order // @Description Returns a list of all soundtracks in random order
// @Tags music // @Tags music
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -257,10 +221,6 @@ func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/random [get] // @Router /music/all/random [get]
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error { func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
soundtrackList := backend.GetAllSoundtracksRandom() soundtrackList := backend.GetAllSoundtracksRandom()
return ctx.JSON(http.StatusOK, soundtrackList) return ctx.JSON(http.StatusOK, soundtrackList)
} }
@@ -277,15 +237,11 @@ func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/played [put] // @Router /music/played [put]
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error { func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
song, err := strconv.Atoi(ctx.QueryParam("song")) song, err := strconv.Atoi(ctx.QueryParam("song"))
if err != nil { if err != nil {
return ctx.JSON(http.StatusBadRequest, err.Error()) return ctx.JSON(http.StatusBadRequest, err.Error())
} }
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) backend.SetPlayed(song)
return ctx.NoContent(http.StatusOK) 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" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addQue [get] // @Router /music/addQue [get]
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error { func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
backend.AddLatestToQue() backend.AddLatestToQue()
return ctx.NoContent(http.StatusOK) return ctx.NoContent(http.StatusOK)
} }
@@ -316,10 +268,6 @@ func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addPlayed [get] // @Router /music/addPlayed [get]
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error { func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
backend.AddLatestPlayed() backend.AddLatestPlayed()
return ctx.NoContent(http.StatusOK) return ctx.NoContent(http.StatusOK)
} }
+43 -38
View File
@@ -50,8 +50,9 @@ func (s *Server) RegisterRoutes() http.Handler {
fileServer := http.FileServer(http.FS(web.Assets)) fileServer := http.FileServer(http.FS(web.Assets))
e.GET("/assets/*", echo.WrapHandler(fileServer)) e.GET("/assets/*", echo.WrapHandler(fileServer))
e.GET("/search", echo.WrapHandler(templ.Handler(web.HelloForm()))) e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindGameWebHandler))) e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
e.POST("/findfuzzy", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackFuzzyWebHandler)))
e.Static("/", "/frontend") e.Static("/", "/frontend")
@@ -63,12 +64,16 @@ func (s *Server) RegisterRoutes() http.Handler {
// ============================================ // ============================================
deprecatedMiddleware := middleware.DeprecationMiddleware deprecatedMiddleware := middleware.DeprecationMiddleware
index := NewIndexHandler() health := NewHealthHandler(s.db)
e.GET("/version", deprecatedMiddleware(index.GetVersion)) e.GET("/health", deprecatedMiddleware(health.HealthCheck))
e.GET("/dbtest", deprecatedMiddleware(index.GetDBTest))
e.GET("/health", deprecatedMiddleware(index.HealthCheck)) version := NewVersionHandler()
e.GET("/character", deprecatedMiddleware(index.GetCharacter)) e.GET("/version", deprecatedMiddleware(version.GetLatestVersion))
e.GET("/characters", deprecatedMiddleware(index.GetCharacterList)) e.GET("/version/history", deprecatedMiddleware(version.GetVersionHistory))
character := NewCharacterHandler()
e.GET("/character", deprecatedMiddleware(character.GetCharacter))
e.GET("/characters", deprecatedMiddleware(character.GetCharacterList))
download := NewDownloadHandler() download := NewDownloadHandler()
e.GET("/download", deprecatedMiddleware(download.checkLatest)) e.GET("/download", deprecatedMiddleware(download.checkLatest))
@@ -78,32 +83,32 @@ func (s *Server) RegisterRoutes() http.Handler {
sync := NewSyncHandler() sync := NewSyncHandler()
syncGroup := e.Group("/sync") syncGroup := e.Group("/sync")
syncGroup.GET("", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) syncGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress)) syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
syncGroup.GET("/new", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull)) syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull)) syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetDB)) syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB)))
music := NewMusicHandler() music := NewMusicHandler()
musicGroup := e.Group("/music") musicGroup := e.Group("/music")
musicGroup.GET("", deprecatedMiddleware(music.GetSong)) musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong)))
musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong)) musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong)))
musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic)) musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic)))
musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong)) musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong)))
musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance)) musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance)))
musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic)) musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic)))
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo)) musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs)) musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong)) musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong)))
musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong)) musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong)))
musicGroup.GET("/all", deprecatedMiddleware(music.GetAllSoundtracksRandom)) musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllSoundtracks)) musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks)))
musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllSoundtracksRandom)) musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed)) musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed)))
musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue)) musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue)))
musicGroup.GET("/addPlayed", deprecatedMiddleware(music.AddLatestPlayed)) musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed)))
// ============================================ // ============================================
// API v1 Routes with Token Authentication // API v1 Routes with Token Authentication
@@ -132,20 +137,20 @@ func (s *Server) RegisterRoutes() http.Handler {
// Statistics API endpoints (protected by token auth) // Statistics API endpoints (protected by token auth)
statistics := s.statisticsHandler statistics := s.statisticsHandler
protectedV1.GET("/statistics/games/most-played", func(c *echo.Context) error { protectedV1.GET("/statistics/soundtracks/most-played", func(c *echo.Context) error {
return statistics.GetMostPlayedGames(c) return statistics.GetMostPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/games/least-played", func(c *echo.Context) error { protectedV1.GET("/statistics/soundtracks/least-played", func(c *echo.Context) error {
return statistics.GetLeastPlayedGames(c) return statistics.GetLeastPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/games/never-played", func(c *echo.Context) error { protectedV1.GET("/statistics/soundtracks/never-played", func(c *echo.Context) error {
return statistics.GetNeverPlayedGames(c) return statistics.GetNeverPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/games/last-played", func(c *echo.Context) error { protectedV1.GET("/statistics/soundtracks/last-played", func(c *echo.Context) error {
return statistics.GetLastPlayedGames(c) return statistics.GetLastPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/games/oldest-played", func(c *echo.Context) error { protectedV1.GET("/statistics/soundtracks/oldest-played", func(c *echo.Context) error {
return statistics.GetOldestPlayedGames(c) return statistics.GetOldestPlayedSoundtracks(c)
}) })
protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error { protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error {
return statistics.GetMostPlayedSongs(c) return statistics.GetMostPlayedSongs(c)
+56 -56
View File
@@ -23,20 +23,20 @@ func NewStatisticsHandler() *StatisticsHandler {
} }
} }
// GetMostPlayedGames returns top N most played games with songs // GetMostPlayedSoundtracks returns top N most played soundtracks with songs
// GET /api/v1/statistics/games/most-played // GET /api/v1/statistics/soundtracks/most-played
// //
// @Summary Get most played games // @Summary Get most played soundtracks
// @Description Returns the top N most played games with their songs // @Description Returns the top N most played soundtracks with their songs
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param limit query int false "Number of results (default: 10)" // @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/most-played [get] // @Router /api/v1/statistics/soundtracks/most-played [get]
func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 // default limit := 10 // default
limitStr := ctx.QueryParam("limit") limitStr := ctx.QueryParam("limit")
if limitStr != "" { if limitStr != "" {
@@ -51,28 +51,28 @@ func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error {
} }
} }
games, err := h.statsBackend.GetMostPlayedGamesWithSongs(int32(limit)) soundtracks, err := h.statsBackend.GetMostPlayedSoundtracksWithSongs(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get most played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get most played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetLeastPlayedGames returns top N least played games with songs // GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
// GET /api/v1/statistics/games/least-played // GET /api/v1/statistics/soundtracks/least-played
// //
// @Summary Get least played games // @Summary Get least played soundtracks
// @Description Returns the top N least played games with their songs // @Description Returns the top N least played soundtracks with their songs
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param limit query int false "Number of results (default: 10)" // @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/least-played [get] // @Router /api/v1/statistics/soundtracks/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 limit := 10
limitStr := ctx.QueryParam("limit") limitStr := ctx.QueryParam("limit")
if limitStr != "" { if limitStr != "" {
@@ -86,19 +86,19 @@ func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error {
} }
} }
games, err := h.statsBackend.GetLeastPlayedGamesWithSongs(int32(limit)) soundtracks, err := h.statsBackend.GetLeastPlayedSoundtracksWithSongs(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get least played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get least played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetMostPlayedSongs returns top N most played songs with game info // GetMostPlayedSongs returns top N most played songs with soundtrack info
// GET /api/v1/statistics/songs/most-played // GET /api/v1/statistics/songs/most-played
// //
// @Summary Get most played songs // @Summary Get most played songs
// @Description Returns the top N most played songs with their game info // @Description Returns the top N most played songs with their soundtrack info
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -121,7 +121,7 @@ func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
} }
} }
songs, err := h.statsBackend.GetMostPlayedSongsWithGame(int32(limit)) songs, err := h.statsBackend.GetMostPlayedSongsWithSoundtrack(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
@@ -129,11 +129,11 @@ func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, songs) return ctx.JSON(http.StatusOK, songs)
} }
// GetLeastPlayedSongs returns top N least played songs with game info // GetLeastPlayedSongs returns top N least played songs with soundtrack info
// GET /api/v1/statistics/songs/least-played // GET /api/v1/statistics/songs/least-played
// //
// @Summary Get least played songs // @Summary Get least played songs
// @Description Returns the top N least played songs with their game info // @Description Returns the top N least played songs with their soundtrack info
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -156,7 +156,7 @@ func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
} }
} }
songs, err := h.statsBackend.GetLeastPlayedSongsWithGame(int32(limit)) songs, err := h.statsBackend.GetLeastPlayedSongsWithSoundtrack(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
@@ -164,40 +164,40 @@ func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, songs) return ctx.JSON(http.StatusOK, songs)
} }
// GetNeverPlayedGames returns games that have never been played // GetNeverPlayedSoundtracks returns soundtracks that have never been played
// GET /api/v1/statistics/games/never-played // GET /api/v1/statistics/soundtracks/never-played
// //
// @Summary Get never played games // @Summary Get never played soundtracks
// @Description Returns all games that have never been played (times_played = 0) // @Description Returns all soundtracks that have never been played (times_played = 0)
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/never-played [get] // @Router /api/v1/statistics/soundtracks/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
games, err := h.statsBackend.GetNeverPlayedGames() soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get never played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get never played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetLastPlayedGames returns most recently played games // GetLastPlayedSoundtracks returns most recently played soundtracks
// GET /api/v1/statistics/games/last-played // GET /api/v1/statistics/soundtracks/last-played
// //
// @Summary Get last played games // @Summary Get last played soundtracks
// @Description Returns the most recently played games // @Description Returns the most recently played soundtracks
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param limit query int false "Number of results (default: 10)" // @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/last-played [get] // @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 limit := 10
limitStr := ctx.QueryParam("limit") limitStr := ctx.QueryParam("limit")
if limitStr != "" { if limitStr != "" {
@@ -211,28 +211,28 @@ func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error {
} }
} }
games, err := h.statsBackend.GetLastPlayedGames(int32(limit)) soundtracks, err := h.statsBackend.GetLastPlayedSoundtracks(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get last played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get last played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetOldestPlayedGames returns least recently played games // GetOldestPlayedSoundtracks returns least recently played soundtracks
// GET /api/v1/statistics/games/oldest-played // GET /api/v1/statistics/soundtracks/oldest-played
// //
// @Summary Get oldest played games // @Summary Get oldest played soundtracks
// @Description Returns the least recently played games (that have been played at least once) // @Description Returns the least recently played soundtracks (that have been played at least once)
// @Tags statistics // @Tags statistics
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param limit query int false "Number of results (default: 10)" // @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.GameWithSongs // @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/games/oldest-played [get] // @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error { func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 limit := 10
limitStr := ctx.QueryParam("limit") limitStr := ctx.QueryParam("limit")
if limitStr != "" { if limitStr != "" {
@@ -246,12 +246,12 @@ func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error {
} }
} }
games, err := h.statsBackend.GetOldestPlayedGames(int32(limit)) soundtracks, err := h.statsBackend.GetOldestPlayedSoundtracks(int32(limit))
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get oldest played games", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get oldest played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
} }
return ctx.JSON(http.StatusOK, games) return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetStatisticsSummary returns overall statistics // GetStatisticsSummary returns overall statistics
+10 -5
View File
@@ -73,6 +73,11 @@ func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code) 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 // Verify data via statistics endpoint
req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil) req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
@@ -85,9 +90,9 @@ func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
err := json.Unmarshal(rec.Body.Bytes(), &summary) err := json.Unmarshal(rec.Body.Bytes(), &summary)
require.NoError(t, err) require.NoError(t, err)
// We inserted 5 soundtracks, so total should be at least 5 // After sync with /sync/new, only soundtracks matching filesystem remain
// (there might be existing data) // testMusic has 3 soundtracks
require.GreaterOrEqual(t, summary.TotalGames, int64(5)) require.Equal(t, int64(3), summary.TotalSoundtracks)
} }
// insertTestData inserts 5 test soundtracks with songs into the database // insertTestData inserts 5 test soundtracks with songs into the database
@@ -115,8 +120,8 @@ func insertTestData(t *testing.T) {
for _, st := range soundtracks { for _, st := range soundtracks {
_, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{ _, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{
SoundtrackName: st.name, SoundtrackName: st.name,
Path: st.path, Path: st.path,
Hash: "test-hash-" + st.name, Hash: "test-hash-" + st.name,
}) })
require.NoError(t, err, "Failed to insert soundtrack: %s", 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 // SyncSoundtracksNewOnlyChanges godoc
// @Summary Sync soundtracks with only changes // @Summary Sync soundtracks with only changes
// @Description Starts syncing games with only new changes // @Description Starts syncing soundtracks with only new changes
// @Tags sync // @Tags sync
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -44,18 +44,15 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /sync [get] // @Router /sync [get]
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error { func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Warn("Syncing is already in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
logging.GetLogger().Info("Starting sync with only changes") logging.GetLogger().Info("Starting sync with only changes")
go backend.SyncSoundtracksNewOnlyChanges() backend.Syncing = true
go backend.SyncSoundtracksOnlyChanges()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks") return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
} }
// SyncSoundtracksNewFull godoc // SyncSoundtracksNewFull godoc
// @Summary Sync all games fully // @Summary Sync all soundtracks fully
// @Description Starts a full sync of all games // @Description Starts a full sync of all soundtracks
// @Tags sync // @Tags sync
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -63,18 +60,15 @@ func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/full [get] // @Router /sync/full [get]
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error { func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Warn("Syncing is already in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
logging.GetLogger().Info("Starting full sync") logging.GetLogger().Info("Starting full sync")
go backend.SyncSoundtracksNewFull() backend.Syncing = true
go backend.SyncSoundtracksFull()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full") return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
} }
// ResetDB godoc // ResetDB godoc
// @Summary Reset soundtracks database // @Summary Reset soundtracks database
// @Description Resets the games database by deleting all games and songs // @Description Resets the soundtracks database by deleting all soundtracks and songs
// @Tags sync // @Tags sync
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -82,10 +76,6 @@ func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress" // @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/reset [get] // @Router /sync/reset [get]
func (s *SyncHandler) ResetDB(ctx *echo.Context) error { func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Warn("Cannot reset - syncing is in progress")
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
logging.GetLogger().Info("Resetting soundtracks database") logging.GetLogger().Info("Resetting soundtracks database")
backend.ResetDB() backend.ResetDB()
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database") return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
+42 -42
View File
@@ -28,7 +28,7 @@ func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
if err == nil && progress.Progress != "" { if err == nil && progress.Progress != "" {
// Successfully parsed as ProgressResponse with non-empty progress // Successfully parsed as ProgressResponse with non-empty progress
t.Logf("Sync progress: %s%%", progress.Progress) t.Logf("Sync progress: %s%%", progress.Progress)
if progress.Progress == "100" { if progress.Progress == "100" {
t.Log("Sync completed!") t.Log("Sync completed!")
// Wait for Syncing flag to be updated // Wait for Syncing flag to be updated
for j := 0; j < 50; j++ { for j := 0; j < 50; j++ {
@@ -61,7 +61,7 @@ func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
return false return false
} }
// TestSyncPopulatesDatabase verifies that sync populates the database with games // TestSyncPopulatesDatabase verifies that sync populates the database with soundtracks
func TestSyncPopulatesDatabase(t *testing.T) { func TestSyncPopulatesDatabase(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -74,12 +74,12 @@ func TestSyncPopulatesDatabase(t *testing.T) {
// Clear any existing data first // Clear any existing data first
db.TestClearDatabase(t) db.TestClearDatabase(t)
// Before sync - should have no games // Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
beforeCount := len(gamesBefore) beforeCount := len(soundtracksBefore)
t.Logf("Games before sync: %d", beforeCount) t.Logf("Soundtracks before sync: %d", beforeCount)
assert.Equal(t, 0, beforeCount, "Database should be empty after clear") assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
// Run sync // Run sync
@@ -91,14 +91,14 @@ func TestSyncPopulatesDatabase(t *testing.T) {
t.Error("Sync did not complete within timeout") t.Error("Sync did not complete within timeout")
} }
// After sync - should have games // After sync - should have soundtracks
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
afterCount := len(gamesAfter) afterCount := len(soundtracksAfter)
t.Logf("Games after sync: %d", afterCount) t.Logf("Soundtracks after sync: %d", afterCount)
// Should have more games than before (unless database was already populated) // Should have more soundtracks than before (unless database was already populated)
assert.True(t, afterCount > 0, "Database should have games after sync") assert.True(t, afterCount > 0, "Database should have soundtracks after sync")
} }
// TestSyncMakesDifference verifies that sync actually changes the database state // TestSyncMakesDifference verifies that sync actually changes the database state
@@ -111,11 +111,11 @@ func TestSyncMakesDifference(t *testing.T) {
// Clear any existing data first // Clear any existing data first
db.TestClearDatabase(t) db.TestClearDatabase(t)
// Before sync - should have no games // Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync") assert.Equal(t, 0, len(soundtracksBefore), "Should have no soundtracks before sync")
// Run sync // Run sync
resp := MakeTestRequest(t, e, "GET", "/sync/full") resp := MakeTestRequest(t, e, "GET", "/sync/full")
@@ -126,10 +126,10 @@ func TestSyncMakesDifference(t *testing.T) {
t.Error("Sync did not complete within timeout") t.Error("Sync did not complete within timeout")
} }
// After sync - should have games // After sync - should have soundtracks
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, len(gamesAfter) > 0, "Should have games after sync") assert.True(t, len(soundtracksAfter) > 0, "Should have soundtracks after sync")
} }
// TestSyncProgress verifies the sync progress endpoint // TestSyncProgress verifies the sync progress endpoint
@@ -183,8 +183,8 @@ func TestSyncProgress(t *testing.T) {
assert.True(t, foundComplete, "Should have seen completion") assert.True(t, foundComplete, "Should have seen completion")
} }
// TestSyncGamesNewOnlyChanges verifies the incremental sync endpoint // TestSyncSoundtracksNewOnlyChanges verifies the incremental sync endpoint
func TestSyncGamesNewOnlyChanges(t *testing.T) { func TestSyncSoundtracksNewOnlyChanges(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -200,8 +200,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
// Get initial count // Get initial count
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(gamesBefore) beforeCount := len(soundtracksBefore)
// Run incremental sync (should not change count if nothing changed) // Run incremental sync (should not change count if nothing changed)
resp := MakeTestRequest(t, e, "GET", "/sync/new") resp := MakeTestRequest(t, e, "GET", "/sync/new")
@@ -211,16 +211,16 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
// Count should be the same // Count should be the same
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(gamesAfter) afterCount := len(soundtracksAfter)
// Note: This might not be exactly equal due to timing, but should be close // Note: This might not be exactly equal due to timing, but should be close
t.Logf("Games before incremental sync: %d, after: %d", beforeCount, afterCount) t.Logf("Soundtracks before incremental sync: %d, after: %d", beforeCount, afterCount)
} }
// TestResetGames verifies the reset endpoint clears the database // TestResetSoundtracks verifies the reset endpoint clears the database
// RUN THIS LAST // RUN THIS LAST
func TestResetGames(t *testing.T) { func TestResetSoundtracks(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -228,8 +228,8 @@ func TestResetGames(t *testing.T) {
// First ensure we have data // First ensure we have data
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(gamesBefore) beforeCount := len(soundtracksBefore)
if beforeCount == 0 { if beforeCount == 0 {
// Run sync to populate // Run sync to populate
@@ -238,12 +238,12 @@ func TestResetGames(t *testing.T) {
t.Error("Sync did not complete within timeout") t.Error("Sync did not complete within timeout")
return return
} }
gamesBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount = len(gamesBefore) beforeCount = len(soundtracksBefore)
} }
t.Logf("Games before reset: %d", beforeCount) t.Logf("Soundtracks before reset: %d", beforeCount)
assert.True(t, beforeCount > 0, "Should have games to reset") assert.True(t, beforeCount > 0, "Should have soundtracks to reset")
// Call reset // Call reset
resp := MakeTestRequest(t, e, "GET", "/sync/reset") resp := MakeTestRequest(t, e, "GET", "/sync/reset")
@@ -253,16 +253,16 @@ func TestResetGames(t *testing.T) {
// Note: reset might take a moment to propagate // Note: reset might take a moment to propagate
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(gamesAfter) afterCount := len(soundtracksAfter)
t.Logf("Games after reset: %d", afterCount) t.Logf("Soundtracks after reset: %d", afterCount)
assert.Equal(t, 0, afterCount, "Database should be empty after reset") assert.Equal(t, 0, afterCount, "Database should be empty after reset")
} }
// TestSyncGamesNewFull verifies the full sync endpoint // TestSyncSoundtracksNewFull verifies the full sync endpoint
// RUN THIS LAST (before TestResetGames) // RUN THIS LAST (before TestResetSoundtracks)
func TestSyncGamesNewFull(t *testing.T) { func TestSyncSoundtracksNewFull(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -282,8 +282,8 @@ func TestSyncGamesNewFull(t *testing.T) {
// Verify database is populated // Verify database is populated
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
games, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, len(games) > 0, "Database should be populated after full sync") assert.True(t, len(soundtracks) > 0, "Database should be populated after full sync")
t.Logf("Full sync populated %d games", len(games)) t.Logf("Full sync populated %d soundtracks", len(soundtracks))
} }
+3 -2
View File
@@ -59,8 +59,9 @@ func StartTestServer(t *testing.T) *echo.Echo {
// Create a Server instance and get its routes // Create a Server instance and get its routes
s := &Server{ s := &Server{
db: db.TestDatabase, db: db.TestDatabase,
tokenHandler: NewTokenHandler(db.TestDatabase.Pool), tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
statisticsHandler: NewStatisticsHandler(),
} }
handler := s.RegisterRoutes() handler := s.RegisterRoutes()
+51
View File
@@ -0,0 +1,51 @@
package server
import (
"net/http"
"github.com/labstack/echo/v5"
"music-server/internal/backend"
)
type VersionHandler struct {
}
func NewVersionHandler() *VersionHandler {
return &VersionHandler{}
}
// GetVersionHistory godoc
//
// @Summary Getting the version history of the backend
// @Description get version history
// @Tags version
// @Accept json
// @Produce json
// @Success 200 {array} backend.VersionData
// @Failure 404 {object} string
// @Router /version/history [get]
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
versionHistory := backend.GetVersionHistory()
if len(versionHistory) == 0 {
return ctx.JSON(http.StatusNotFound, "version not found")
}
return ctx.JSON(http.StatusOK, versionHistory)
}
// GetLatestVersion godoc
//
// @Summary Getting the latest version of the backend
// @Description get latest version info
// @Tags version
// @Accept json
// @Produce json
// @Success 200 {object} backend.VersionData
// @Failure 404 {object} string
// @Router /version [get]
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
latestVersion := backend.GetLatestVersion()
if latestVersion.Version == "" {
return ctx.JSON(http.StatusNotFound, "version not found")
}
return ctx.JSON(http.StatusOK, latestVersion)
}
+40
View File
@@ -0,0 +1,40 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"music-server/internal/backend"
"github.com/stretchr/testify/assert"
)
// TestGetLatestVersion verifies the version endpoint returns latest version
func TestGetLatestVersion(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/version")
assert.Equal(t, http.StatusOK, resp.Code)
var versionData backend.VersionData
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
assert.NoError(t, err)
assert.NotEmpty(t, versionData.Version)
assert.NotEmpty(t, versionData.Changelog)
}
// TestGetVersionHistory verifies the version history endpoint returns version history
func TestGetVersionHistory(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/version/history")
assert.Equal(t, http.StatusOK, resp.Code)
var versionHistory []backend.VersionData
err := json.Unmarshal(resp.Body.Bytes(), &versionHistory)
assert.NoError(t, err)
assert.NotEmpty(t, versionHistory)
assert.NotEmpty(t, versionHistory[0].Version)
assert.NotEmpty(t, versionHistory[0].Changelog)
}
+18 -18
View File
@@ -16,12 +16,12 @@ import (
// ensureSyncRan ensures that sync has been run before testing music endpoints // ensureSyncRan ensures that sync has been run before testing music endpoints
func ensureSyncRan(t *testing.T, e *echo.Echo) { func ensureSyncRan(t *testing.T, e *echo.Echo) {
repo := repository.New(backend.BackendPool()) repo := repository.New(backend.BackendPool())
games, err := repo.FindAllSoundtracks(backend.BackendCtx()) soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
if len(games) == 0 { if len(soundtracks) == 0 {
// Run sync // Run sync
t.Log("No games found, running sync first...") t.Log("No soundtracks found, running sync first...")
resp := MakeTestRequest(t, e, "GET", "/sync/full") resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, http.StatusOK, resp.Code)
@@ -32,8 +32,8 @@ func ensureSyncRan(t *testing.T, e *echo.Echo) {
} }
} }
// TestGetAllGames verifies the /music/all/order endpoint // TestGetAllSoundtracks verifies the /music/all/order endpoint
func TestZGetAllGames(t *testing.T) { func TestZGetAllSoundtracks(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -45,15 +45,15 @@ func TestZGetAllGames(t *testing.T) {
resp := MakeTestRequest(t, e, "GET", "/music/all/order") resp := MakeTestRequest(t, e, "GET", "/music/all/order")
assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, http.StatusOK, resp.Code)
var games []string var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &games) err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, games, "Should have games after sync") assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
t.Logf("Found %d games", len(games)) t.Logf("Found %d soundtracks", len(soundtracks))
} }
// TestGetAllGamesRandom verifies the /music/all/random endpoint // TestGetAllSoundtracksRandom verifies the /music/all/random endpoint
func TestZGetAllGamesRandom(t *testing.T) { func TestZGetAllSoundtracksRandom(t *testing.T) {
db.TestSetupDB(t) db.TestSetupDB(t)
defer db.TestTearDownDB(t) defer db.TestTearDownDB(t)
@@ -65,17 +65,17 @@ func TestZGetAllGamesRandom(t *testing.T) {
resp := MakeTestRequest(t, e, "GET", "/music/all/random") resp := MakeTestRequest(t, e, "GET", "/music/all/random")
assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, http.StatusOK, resp.Code)
var games []string var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &games) err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, games, "Should have games after sync") assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
// Verify it's shuffled (not in original order) // Verify it's shuffled (not in original order)
// We can't easily verify randomness, but we can check it's the same length // We can't easily verify randomness, but we can check it's the same length
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order") resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
var gamesOrdered []string var soundtracksOrdered []string
json.Unmarshal(resp2.Body.Bytes(), &gamesOrdered) json.Unmarshal(resp2.Body.Bytes(), &soundtracksOrdered)
assert.Equal(t, len(games), len(gamesOrdered), "Random and ordered should have same count") assert.Equal(t, len(soundtracks), len(soundtracksOrdered), "Random and ordered should have same count")
} }
// TestGetRandomSong verifies the /music/rand endpoint // TestGetRandomSong verifies the /music/rand endpoint
@@ -153,7 +153,7 @@ func TestZGetSongInfo(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Note: CurrentlyPlaying might be false if no song is currently set // Note: CurrentlyPlaying might be false if no song is currently set
// Just verify we got a valid response // Just verify we got a valid response
t.Logf("Song info: Game=%s, Song=%s", info.Game, info.Song) t.Logf("Song info: Soundtrack=%s, Song=%s", info.Soundtrack, info.Song)
} }
// TestGetPlayedSongs verifies the /music/list endpoint // TestGetPlayedSongs verifies the /music/list endpoint
+13 -3
View File
@@ -80,9 +80,17 @@ run:
@templ generate @templ generate
@go run cmd/main.go @go run cmd/main.go
build-run: build
@go run cmd/main.go
test: build test: build
@echo "Testing..." @echo "Starting test database container..."
@go test ./... -v @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 the binary
clean: clean:
@@ -102,7 +110,9 @@ podman-down:
# Run integration tests with podman # Run integration tests with podman
# Starts a test PostgreSQL container, runs tests, then cleans up # Starts a test PostgreSQL container, runs tests, then cleans up
test-integration: test-integration:
@echo "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 @podman-compose -f compose.test.yaml up -d
@sleep 10 @sleep 10
@echo "Running integration tests..." @echo "Running integration tests..."