19 Commits

Author SHA1 Message Date
Sansan dbef39b828 Merge pull request 'Feature/statistics api' (#26) from feature/statistics-api into develop
Build / build (push) Successful in 51s
Reviewed-on: #26
2026-06-14 11:48:36 +02:00
Sansan 4e5bdc4ee2 Fixed some small bugs after merge 2026-06-14 11:30:58 +02:00
Sansan 0894d65ec5 Merge branch 'develop' into feature/statistics-api
# Conflicts:
#	internal/backend/music.go
#	internal/backend/sync.go
#	internal/server/server.go
#	internal/server/syncHandler.go
#	internal/server/sync_handler_test.go
#	internal/server/test_helpers.go
#	internal/server/zz_music_handler_test.go
2026-06-13 11:51:56 +02:00
Sansan 4033899a68 Merge pull request 'Feature/session token api' (#25) from feature/session-token-api into develop
Build / build (push) Successful in 50s
Reviewed-on: #25
2026-06-13 11:30:08 +02:00
Sansan c6a07e69e7 Fixed some small bugs. Frontend is now included in the docker image 2026-06-13 11:26:52 +02:00
Sansan 6d4a034753 Fix duplicate import in routes.go
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 20:44:17 +02:00
Sansan 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
Sansan 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
39 changed files with 2398 additions and 571 deletions
+17 -11
View File
@@ -1,26 +1,35 @@
# Stage 1: Build frontend
FROM node:18-alpine AS frontend-builder
RUN apk add --no-cache git
WORKDIR /app
RUN git clone https://gitea.sanplex.xyz/Sansan/MusicFrontend.git
WORKDIR /app/MusicFrontend
RUN npm install
RUN npm run build
# Generate config.js with empty API_HOSTNAME (relative paths)
RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > dist/config.js
# Stage 2: Build backend
FROM golang:1.25-alpine as build_go FROM golang:1.25-alpine as build_go
RUN apk add --no-cache curl RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
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 3: 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=frontend-builder /app/MusicFrontend/dist /frontend
COPY ./songs/ ./songs/
ENV PORT 8080 ENV PORT 8080
ENV DB_HOST "" ENV DB_HOST ""
ENV DB_PORT "" ENV DB_PORT ""
@@ -30,7 +39,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
+676 -39
View File
@@ -23,6 +23,539 @@ var doc = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/api/v1/statistics/games/last-played": {
"get": {
"description": "Returns the most recently played games",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get last played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/least-played": {
"get": {
"description": "Returns the top N least played games with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get least played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/most-played": {
"get": {
"description": "Returns the top N most played games with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get most played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/never-played": {
"get": {
"description": "Returns all games that have never been played (times_played = 0)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get never played games",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/oldest-played": {
"get": {
"description": "Returns the least recently played games (that have been played at least once)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get oldest played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/songs/least-played": {
"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",
@@ -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",
@@ -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",
@@ -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"
} }
@@ -729,7 +1239,7 @@ var doc = `{
"summary": "Sync all games fully", "summary": "Sync all games fully",
"responses": { "responses": {
"200": { "200": {
"description": "Start syncing games full", "description": "Start syncing soundtracks full",
"schema": { "schema": {
"type": "string" "type": "string"
} }
@@ -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.GameWithSongs": {
"type": "object",
"properties": {
"game_id": {
"type": "integer"
},
"game_last_played": {
"type": "string"
},
"game_name": {
"type": "string"
},
"game_played": {
"type": "integer"
},
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
}
},
"backend.SongInfoForStats": {
"type": "object",
"properties": {
"file_name": {
"type": "string"
},
"game_id": {
"type": "integer"
},
"game_name": {
"type": "string"
},
"path": {
"type": "string"
},
"song_name": {
"type": "string"
},
"times_played": {
"type": "integer"
}
}
},
"backend.StatisticsSummary": {
"type": "object",
"properties": {
"avg_game_plays": {
"type": "number"
},
"max_game_plays": {
"type": "integer"
},
"min_game_plays": {
"type": "integer"
},
"never_played_games": {
"type": "integer"
},
"played_games": {
"type": "integer"
},
"total_game_plays": {
"type": "integer"
},
"total_games": {
"type": "integer"
}
}
},
"backend.VersionData": { "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"
}
}
} }
} }
}` }`
+676 -39
View File
@@ -4,6 +4,539 @@
"contact": {} "contact": {}
}, },
"paths": { "paths": {
"/api/v1/statistics/games/last-played": {
"get": {
"description": "Returns the most recently played games",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get last played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/least-played": {
"get": {
"description": "Returns the top N least played games with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get least played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/most-played": {
"get": {
"description": "Returns the top N most played games with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get most played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/never-played": {
"get": {
"description": "Returns all games that have never been played (times_played = 0)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get never played games",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/games/oldest-played": {
"get": {
"description": "Returns the least recently played games (that have been played at least once)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"statistics"
],
"summary": "Get oldest played games",
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.GameWithSongs"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/statistics/songs/least-played": {
"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",
@@ -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",
@@ -305,7 +815,7 @@
"tags": [ "tags": [
"music" "music"
], ],
"summary": "Get all games", "summary": "Get all soundtracks",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@@ -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",
@@ -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"
} }
@@ -710,7 +1220,7 @@
"summary": "Sync all games fully", "summary": "Sync all games fully",
"responses": { "responses": {
"200": { "200": {
"description": "Start syncing games full", "description": "Start syncing soundtracks full",
"schema": { "schema": {
"type": "string" "type": "string"
} }
@@ -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.GameWithSongs": {
"type": "object",
"properties": {
"game_id": {
"type": "integer"
},
"game_last_played": {
"type": "string"
},
"game_name": {
"type": "string"
},
"game_played": {
"type": "integer"
},
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.SongInfoForStats"
}
}
}
},
"backend.SongInfoForStats": {
"type": "object",
"properties": {
"file_name": {
"type": "string"
},
"game_id": {
"type": "integer"
},
"game_name": {
"type": "string"
},
"path": {
"type": "string"
},
"song_name": {
"type": "string"
},
"times_played": {
"type": "integer"
}
}
},
"backend.StatisticsSummary": {
"type": "object",
"properties": {
"avg_game_plays": {
"type": "number"
},
"max_game_plays": {
"type": "integer"
},
"min_game_plays": {
"type": "integer"
},
"never_played_games": {
"type": "integer"
},
"played_games": {
"type": "integer"
},
"total_game_plays": {
"type": "integer"
},
"total_games": {
"type": "integer"
}
}
},
"backend.VersionData": { "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 {
+23 -1
View File
@@ -2,6 +2,7 @@ package web
templ HelloForm() { templ HelloForm() {
@Base() { @Base() {
<button id="dark-mode-toggle">🌙</button>
<div id="search-container"> <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"/> <input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#games-container"/>
<button type="button" id="clear" name="clear">Clear</button> <button type="button" id="clear" name="clear">Clear</button>
@@ -12,8 +13,29 @@ templ HelloForm() {
if (document.readyState == 'complete') { if (document.readyState == 'complete') {
htmx.ajax('POST', '/find', '#games-container'); htmx.ajax('POST', '/find', '#games-container');
document.getElementById("search_term").focus(); document.getElementById("search_term").focus();
// Initialize dark mode from localStorage (default to dark)
const savedTheme = localStorage.getItem('theme') || 'dark';
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('dark-mode-toggle').textContent = '☀️';
}
} }
}); });
// Dark mode toggle functionality
document.getElementById("dark-mode-toggle").addEventListener("click", function() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
// Update toggle button text
this.textContent = newTheme === 'dark' ? '☀️' : '🌙';
});
document.getElementById("clear").addEventListener("click", function (event) { document.getElementById("clear").addEventListener("click", function (event) {
document.getElementById("search_term").value = ""; document.getElementById("search_term").value = "";
htmx.ajax('POST', '/find', '#games-container'); htmx.ajax('POST', '/find', '#games-container');
@@ -26,7 +48,7 @@ templ HelloForm() {
templ FoundGames(games []string) { templ FoundGames(games []string) {
for _, game := range games { for _, game := range games {
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6"> <div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
<p>{ game }</p> <p class="game-text">{ game }</p>
</div> </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
}
+35 -37
View File
@@ -9,10 +9,10 @@ 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{ games := []repository.Soundtrack{
{GameName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Game1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Game2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Game3", TimesPlayed: 30},
} }
var sum int32 var sum int32
@@ -28,7 +28,7 @@ func TestCalculateAverage(t *testing.T) {
} }
func TestCalculateAverageEmpty(t *testing.T) { func TestCalculateAverageEmpty(t *testing.T) {
games := []repository.Game{} games := []repository.Soundtrack{}
if len(games) == 0 { if len(games) == 0 {
result := int32(0) result := int32(0)
@@ -52,8 +52,8 @@ func TestCalculateAverageEmpty(t *testing.T) {
} }
func TestCalculateAverageSingle(t *testing.T) { func TestCalculateAverageSingle(t *testing.T) {
games := []repository.Game{ games := []repository.Soundtrack{
{GameName: "Game1", TimesPlayed: 42}, {SoundtrackName: "Game1", TimesPlayed: 42},
} }
var sum int32 var sum int32
@@ -69,10 +69,10 @@ func TestCalculateAverageSingle(t *testing.T) {
} }
func TestGetRandomGame(t *testing.T) { func TestGetRandomGame(t *testing.T) {
games := []repository.Game{ games := []repository.Soundtrack{
{GameName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Game1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Game2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Game3", TimesPlayed: 30},
} }
// Set seed for reproducible tests // Set seed for reproducible tests
@@ -80,93 +80,93 @@ func TestGetRandomGame(t *testing.T) {
result := games[rand.Intn(len(games))] result := games[rand.Intn(len(games))]
if result.GameName == "" { if result.SoundtrackName == "" {
t.Error("random game selection returned empty game") t.Error("random game selection returned empty game")
} }
found := false found := false
for _, g := range games { for _, g := range games {
if g.GameName == result.GameName { if g.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 game selection returned game not in list: %v", result.SoundtrackName)
} }
} }
func TestFindGameByID(t *testing.T) { func TestFindGameByID(t *testing.T) {
games := []repository.Game{ games := []repository.Soundtrack{
{ID: 1, GameName: "Game1", TimesPlayed: 10}, {ID: 1, SoundtrackName: "Game1", TimesPlayed: 10},
{ID: 2, GameName: "Game2", TimesPlayed: 20}, {ID: 2, SoundtrackName: "Game2", TimesPlayed: 20},
{ID: 3, GameName: "Game3", TimesPlayed: 30}, {ID: 3, SoundtrackName: "Game3", TimesPlayed: 30},
} }
tests := []struct { tests := []struct {
name string name string
games []repository.Game games []repository.Soundtrack
gameID int32 gameID int32
expected repository.Game expected repository.Soundtrack
}{ }{
{ {
name: "existing game", name: "existing game",
games: games, games: games,
gameID: 2, gameID: 2,
expected: repository.Game{ID: 2, GameName: "Game2", TimesPlayed: 20}, expected: repository.Soundtrack{ID: 2, SoundtrackName: "Game2", TimesPlayed: 20},
}, },
{ {
name: "non-existing game", name: "non-existing game",
games: games, games: games,
gameID: 99, gameID: 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 _, game := range tt.games {
if game.ID == tt.gameID { if game.ID == tt.gameID {
result = game result = game
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("findGameByID() = %v, want %v", result, tt.expected)
} }
}) })
} }
} }
func TestExtractGameNames(t *testing.T) { func TestExtractSoundtrackNames(t *testing.T) {
games := []repository.Game{ games := []repository.Soundtrack{
{GameName: "Game1", TimesPlayed: 10}, {SoundtrackName: "Game1", TimesPlayed: 10},
{GameName: "Game2", TimesPlayed: 20}, {SoundtrackName: "Game2", TimesPlayed: 20},
{GameName: "Game3", TimesPlayed: 30}, {SoundtrackName: "Game3", TimesPlayed: 30},
} }
var result []string var result []string
for _, game := range games { for _, game := range games {
result = append(result, game.GameName) result = append(result, game.SoundtrackName)
} }
expected := []string{"Game1", "Game2", "Game3"} expected := []string{"Game1", "Game2", "Game3"}
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"} games := []string{"Game1", "Game2", "Game3"}
// Test that shuffle doesn't lose any elements // Test that shuffle doesn't lose any elements
@@ -181,7 +181,7 @@ func TestShuffleGameNames(t *testing.T) {
} }
if len(games) != len(original) { if len(games) != len(original) {
t.Errorf("shuffleGameNames() changed length from %d to %d", len(original), len(games)) t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(games))
return return
} }
@@ -195,9 +195,7 @@ func TestShuffleGameNames(t *testing.T) {
} }
} }
if !found { if !found {
t.Errorf("shuffleGameNames() lost element: %v", orig) t.Errorf("shuffleSoundtrackNames() lost element: %v", orig)
} }
} }
} }
+5 -13
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"
@@ -188,8 +186,6 @@ func SyncSoundtracksNewOnlyChanges() {
} }
func syncGamesNew(full bool) { func syncGamesNew(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,7 +197,7 @@ 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
@@ -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()
@@ -324,7 +319,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
} }
} }
if full { if full && status != NewGame {
status = TitleChanged status = TitleChanged
} }
entries, err := os.ReadDir(gameDir) entries, err := os.ReadDir(gameDir)
@@ -345,8 +340,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
break break
} }
} }
gameUuid := pgtype.UUID{Bytes: uuid.New(), Valid: true} err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, SoundtrackName: file.Name(), Path: gameDir, Hash: dirHash})
err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, Uuid: gameUuid, SoundtrackName: file.Name(), Path: gameDir, Hash: dirHash})
handleError("InsertSoundtrackWithExistingId", err, "") handleError("InsertSoundtrackWithExistingId", err, "")
if err != nil { if err != nil {
logging.GetLogger().Debug("Game already exists, removing old ID file", logging.GetLogger().Debug("Game already exists, removing old ID file",
@@ -438,8 +432,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
func insertGameNew(name string, path string, hash string) int32 { func insertGameNew(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")
@@ -526,8 +519,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))
} }
+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 {
-15
View File
@@ -56,21 +56,6 @@ func CloseDb() {
Dbpool.Close() Dbpool.Close()
} }
func Testf() {
rows, dbErr := Dbpool.Query(Ctx, "select game_name from game")
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() { func ResetGameIdSeq() {
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);") _, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);")
if err != nil { if err != nil {
+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,7 +0,0 @@
-- Rollback: Make UUID columns nullable again
-- Make UUID nullable on soundtrack
ALTER TABLE soundtrack ALTER COLUMN uuid DROP NOT NULL;
-- Make UUID nullable on song
ALTER TABLE song ALTER COLUMN uuid DROP NOT NULL;
@@ -1,7 +0,0 @@
-- Migration: Make UUID columns NOT NULL
-- Make UUID required on soundtrack
ALTER TABLE soundtrack ALTER COLUMN uuid SET NOT NULL;
-- Make UUID required on song
ALTER TABLE song ALTER COLUMN uuid SET NOT NULL;
+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 *
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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))
}
+10 -6
View File
@@ -63,12 +63,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))
+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 games
require.GreaterOrEqual(t, summary.TotalGames, int64(5)) require.Equal(t, int64(3), summary.TotalGames)
} }
// 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)
} }
+2
View File
@@ -49,6 +49,7 @@ func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
return ctx.JSON(http.StatusLocked, "Syncing is in progress") return ctx.JSON(http.StatusLocked, "Syncing is in progress")
} }
logging.GetLogger().Info("Starting sync with only changes") logging.GetLogger().Info("Starting sync with only changes")
backend.Syncing = true
go backend.SyncSoundtracksNewOnlyChanges() go backend.SyncSoundtracksNewOnlyChanges()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks") return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
} }
@@ -68,6 +69,7 @@ func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
return ctx.JSON(http.StatusLocked, "Syncing is in progress") return ctx.JSON(http.StatusLocked, "Syncing is in progress")
} }
logging.GetLogger().Info("Starting full sync") logging.GetLogger().Info("Starting full sync")
backend.Syncing = true
go backend.SyncSoundtracksNewFull() go backend.SyncSoundtracksNewFull()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full") return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
} }
+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)
}
+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..."