Compare commits
13
Commits
b0418b4f38
..
5.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
581d91f78b | ||
|
|
a8a2602f50 | ||
|
|
e86c0f8e04 | ||
|
|
eda60e0c07 | ||
|
|
a8df738108 | ||
|
|
dbef39b828 | ||
|
|
4e5bdc4ee2 | ||
|
|
0894d65ec5 | ||
|
|
4033899a68 | ||
|
|
c6a07e69e7 | ||
|
|
6d4a034753 | ||
|
|
24a9111333 | ||
|
|
6cc014ffa3 |
No files matched your search
@@ -0,0 +1,31 @@
|
|||||||
|
# Directories
|
||||||
|
.gitea
|
||||||
|
git
|
||||||
|
.idea
|
||||||
|
testMusic
|
||||||
|
testCharacters
|
||||||
|
|
||||||
|
# Files
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*
|
||||||
|
compose.yaml
|
||||||
|
.dockerignore
|
||||||
|
.gitignore
|
||||||
|
*.pprof
|
||||||
|
main
|
||||||
|
conf.yaml
|
||||||
|
output.css
|
||||||
|
tailwindcss
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Node.js (frontend build artifacts)
|
||||||
|
node_modules
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
*_test.go
|
||||||
|
integration_test.go
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -13,3 +13,7 @@ CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters
|
|||||||
PORT=8081
|
PORT=8081
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
LOG_JSON=false
|
LOG_JSON=false
|
||||||
|
|
||||||
|
# Session Authentication (for testing)
|
||||||
|
# Set to disable session auth during development
|
||||||
|
DISABLE_SESSION_AUTH=true
|
||||||
+12
-12
@@ -1,26 +1,29 @@
|
|||||||
|
# Stage 1: Build backend
|
||||||
FROM golang:1.25-alpine as build_go
|
FROM golang:1.25-alpine as build_go
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
COPY cmd/ ./cmd/
|
||||||
COPY . .
|
COPY internal/ ./internal/
|
||||||
|
RUN find . -name "*_test.go" -delete && \
|
||||||
|
find . -name "integration_test.go" -delete
|
||||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
|
||||||
RUN templ generate
|
RUN templ generate
|
||||||
|
|
||||||
RUN go build -o main cmd/main.go
|
RUN go build -o main cmd/main.go
|
||||||
|
|
||||||
# Stage 2, distribution container
|
# Stage 2: Final image
|
||||||
FROM golang:1.25-alpine
|
FROM golang:1.25-alpine
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME /sorted
|
VOLUME /sorted
|
||||||
VOLUME /frontend
|
|
||||||
VOLUME /characters
|
VOLUME /characters
|
||||||
|
|
||||||
|
COPY --from=build_go /app/main .
|
||||||
|
COPY --from=gitea.sanplex.xyz/sansan/musicfrontend:latest /usr/share/nginx/html /frontend
|
||||||
|
# Generate config.js with empty API_HOSTNAME (relative paths)
|
||||||
|
RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > /frontend/config.js
|
||||||
|
COPY ./songs/ ./songs/
|
||||||
|
|
||||||
ENV PORT 8080
|
ENV PORT 8080
|
||||||
ENV DB_HOST ""
|
ENV DB_HOST ""
|
||||||
ENV DB_PORT ""
|
ENV DB_PORT ""
|
||||||
@@ -30,7 +33,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
|
||||||
+699
-49
@@ -23,6 +23,548 @@ var doc = `{
|
|||||||
"host": "{{.Host}}",
|
"host": "{{.Host}}",
|
||||||
"basePath": "{{.BasePath}}",
|
"basePath": "{{.BasePath}}",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/api/v1/statistics/songs/least-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N least played songs with their soundtrack info",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get least played songs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/songs/most-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N most played songs with their soundtrack info",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get most played songs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/last-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the most recently played soundtracks",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get last played soundtracks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/least-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N least played soundtracks with their songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get least played soundtracks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/most-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N most played soundtracks with their songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get most played soundtracks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/never-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns all soundtracks that have never been played (times_played = 0)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get never played soundtracks",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/oldest-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the least recently played soundtracks (that have been played at least once)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get oldest played soundtracks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/summary": {
|
||||||
|
"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. Requires valid API password.",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Create session token",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Password and 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"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 +623,6 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/dbtest": {
|
|
||||||
"get": {
|
|
||||||
"description": "Tests the database connection",
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database"
|
|
||||||
],
|
|
||||||
"summary": "Test database connection",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "TestedDB",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/download": {
|
"/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Checks for the latest version of the application",
|
"description": "Checks for the latest version of the application",
|
||||||
@@ -314,7 +833,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/music/all/order": {
|
"/music/all/order": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all games in order",
|
"description": "Returns a list of all soundtracks in order",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -324,7 +843,7 @@ var doc = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Get all games",
|
"summary": "Get all soundtracks",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -347,7 +866,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/music/all/random": {
|
"/music/all/random": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all games in random order",
|
"description": "Returns a list of all soundtracks in random order",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -357,7 +876,7 @@ var doc = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Get all games random",
|
"summary": "Get all soundtracks random",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -687,7 +1206,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/sync": {
|
"/sync": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Starts syncing games with only new changes",
|
"description": "Starts syncing soundtracks with only new changes",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -697,10 +1216,10 @@ var doc = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
],
|
],
|
||||||
"summary": "Sync games with only changes",
|
"summary": "Sync soundtracks with only changes",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Start syncing games",
|
"description": "Start syncing soundtracks",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -716,7 +1235,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/sync/full": {
|
"/sync/full": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Starts a full sync of all games",
|
"description": "Starts a full sync of all soundtracks",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -726,10 +1245,10 @@ var doc = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
],
|
],
|
||||||
"summary": "Sync all games fully",
|
"summary": "Sync all soundtracks fully",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Start syncing games full",
|
"description": "Start syncing soundtracks full",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -769,7 +1288,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/sync/reset": {
|
"/sync/reset": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Resets the games database by deleting all games and songs",
|
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -779,10 +1298,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 +1317,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 +1325,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 +1343,158 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"definitions": {
|
"/version/history": {
|
||||||
"backend.VersionData": {
|
"get": {
|
||||||
"type": "object",
|
"description": "get version history",
|
||||||
"properties": {
|
"consumes": [
|
||||||
"changelog": {
|
"application/json"
|
||||||
"type": "string",
|
],
|
||||||
"example": "account name"
|
"produces": [
|
||||||
},
|
"application/json"
|
||||||
"history": {
|
],
|
||||||
|
"tags": [
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"summary": "Getting the version history of the backend",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/backend.VersionData"
|
"$ref": "#/definitions/backend.VersionData"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"backend.SongInfoForStats": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"song_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"soundtrack_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"soundtrack_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"times_played": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.SoundtrackWithSongs": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"songs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"soundtrack_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"soundtrack_last_played": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"soundtrack_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"soundtrack_played": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.StatisticsSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"avg_soundtrack_plays": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"max_soundtrack_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"min_soundtrack_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"never_played_soundtracks": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"played_soundtracks": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_soundtrack_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_soundtracks": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.VersionData": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"changelog": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"description": "Required: API password",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.TokenResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"client_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|||||||
+699
-49
@@ -4,6 +4,548 @@
|
|||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/api/v1/statistics/songs/least-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N least played songs with their soundtrack info",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get least played songs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/songs/most-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N most played songs with their soundtrack info",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get most played songs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/last-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the most recently played soundtracks",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get last played soundtracks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/least-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N least played soundtracks with their songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get least played soundtracks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/most-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N most played soundtracks with their songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get most played soundtracks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/never-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns all soundtracks that have never been played (times_played = 0)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get never played soundtracks",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/soundtracks/oldest-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the least recently played soundtracks (that have been played at least once)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get oldest played soundtracks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SoundtrackWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/summary": {
|
||||||
|
"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. Requires valid API password.",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Create session token",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Password and 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"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 +604,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/dbtest": {
|
|
||||||
"get": {
|
|
||||||
"description": "Tests the database connection",
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database"
|
|
||||||
],
|
|
||||||
"summary": "Test database connection",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "TestedDB",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/download": {
|
"/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Checks for the latest version of the application",
|
"description": "Checks for the latest version of the application",
|
||||||
@@ -295,7 +814,7 @@
|
|||||||
},
|
},
|
||||||
"/music/all/order": {
|
"/music/all/order": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all games in order",
|
"description": "Returns a list of all soundtracks in order",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -305,7 +824,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Get all games",
|
"summary": "Get all soundtracks",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -328,7 +847,7 @@
|
|||||||
},
|
},
|
||||||
"/music/all/random": {
|
"/music/all/random": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a list of all games in random order",
|
"description": "Returns a list of all soundtracks in random order",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -338,7 +857,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Get all games random",
|
"summary": "Get all soundtracks random",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -668,7 +1187,7 @@
|
|||||||
},
|
},
|
||||||
"/sync": {
|
"/sync": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Starts syncing games with only new changes",
|
"description": "Starts syncing soundtracks with only new changes",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -678,10 +1197,10 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
],
|
],
|
||||||
"summary": "Sync games with only changes",
|
"summary": "Sync soundtracks with only changes",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Start syncing games",
|
"description": "Start syncing soundtracks",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -697,7 +1216,7 @@
|
|||||||
},
|
},
|
||||||
"/sync/full": {
|
"/sync/full": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Starts a full sync of all games",
|
"description": "Starts a full sync of all soundtracks",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -707,10 +1226,10 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
],
|
],
|
||||||
"summary": "Sync all games fully",
|
"summary": "Sync all soundtracks fully",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Start syncing games full",
|
"description": "Start syncing soundtracks full",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -750,7 +1269,7 @@
|
|||||||
},
|
},
|
||||||
"/sync/reset": {
|
"/sync/reset": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Resets the games database by deleting all games and songs",
|
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -760,10 +1279,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 +1298,7 @@
|
|||||||
},
|
},
|
||||||
"/version": {
|
"/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get string by ID",
|
"description": "get latest version info",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -787,9 +1306,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 +1324,158 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"definitions": {
|
"/version/history": {
|
||||||
"backend.VersionData": {
|
"get": {
|
||||||
"type": "object",
|
"description": "get version history",
|
||||||
"properties": {
|
"consumes": [
|
||||||
"changelog": {
|
"application/json"
|
||||||
"type": "string",
|
],
|
||||||
"example": "account name"
|
"produces": [
|
||||||
},
|
"application/json"
|
||||||
"history": {
|
],
|
||||||
|
"tags": [
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"summary": "Getting the version history of the backend",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/backend.VersionData"
|
"$ref": "#/definitions/backend.VersionData"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"backend.SongInfoForStats": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"song_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"soundtrack_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"soundtrack_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"times_played": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.SoundtrackWithSongs": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"songs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"soundtrack_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"soundtrack_last_played": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"soundtrack_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"soundtrack_played": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.StatisticsSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"avg_soundtrack_plays": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"max_soundtrack_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"min_soundtrack_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"never_played_soundtracks": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"played_soundtracks": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_soundtrack_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_soundtracks": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.VersionData": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"changelog": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"description": "Required: API password",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.TokenResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"client_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+466
-35
@@ -1,20 +1,444 @@
|
|||||||
definitions:
|
definitions:
|
||||||
|
backend.SongInfoForStats:
|
||||||
|
properties:
|
||||||
|
file_name:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
song_name:
|
||||||
|
type: string
|
||||||
|
soundtrack_id:
|
||||||
|
type: integer
|
||||||
|
soundtrack_name:
|
||||||
|
type: string
|
||||||
|
times_played:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
backend.SoundtrackWithSongs:
|
||||||
|
properties:
|
||||||
|
songs:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SongInfoForStats'
|
||||||
|
type: array
|
||||||
|
soundtrack_id:
|
||||||
|
type: integer
|
||||||
|
soundtrack_last_played:
|
||||||
|
type: string
|
||||||
|
soundtrack_name:
|
||||||
|
type: string
|
||||||
|
soundtrack_played:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
backend.StatisticsSummary:
|
||||||
|
properties:
|
||||||
|
avg_soundtrack_plays:
|
||||||
|
type: number
|
||||||
|
max_soundtrack_plays:
|
||||||
|
type: integer
|
||||||
|
min_soundtrack_plays:
|
||||||
|
type: integer
|
||||||
|
never_played_soundtracks:
|
||||||
|
type: integer
|
||||||
|
played_soundtracks:
|
||||||
|
type: integer
|
||||||
|
total_soundtrack_plays:
|
||||||
|
type: integer
|
||||||
|
total_soundtracks:
|
||||||
|
type: integer
|
||||||
|
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
|
||||||
|
password:
|
||||||
|
description: 'Required: API password'
|
||||||
|
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/songs/least-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the top N least played songs with their soundtrack 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 soundtrack 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/soundtracks/last-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the most recently played soundtracks
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get last played soundtracks
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/soundtracks/least-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the top N least played soundtracks with their songs
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get least played soundtracks
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/soundtracks/most-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the top N most played soundtracks with their songs
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get most played soundtracks
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/soundtracks/never-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns all soundtracks that have never been played (times_played
|
||||||
|
= 0)
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||||
|
type: array
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get never played soundtracks
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/soundtracks/oldest-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the least recently played soundtracks (that have been played
|
||||||
|
at least once)
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get oldest played soundtracks
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/summary:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- 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. Requires valid API
|
||||||
|
password.
|
||||||
|
parameters:
|
||||||
|
- description: Password and 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
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
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 +477,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:
|
||||||
@@ -208,7 +617,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Returns a list of all games in order
|
description: Returns a list of all soundtracks in order
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -223,14 +632,14 @@ 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:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Returns a list of all games in random order
|
description: Returns a list of all soundtracks in random order
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -245,7 +654,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:
|
||||||
@@ -454,38 +863,38 @@ paths:
|
|||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Starts syncing games with only new changes
|
description: Starts syncing soundtracks with only new changes
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"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:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Starts a full sync of all games
|
description: Starts a full sync of all soundtracks
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Start syncing games full
|
description: Start syncing soundtracks full
|
||||||
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 all games fully
|
summary: Sync all soundtracks fully
|
||||||
tags:
|
tags:
|
||||||
- sync
|
- sync
|
||||||
/sync/progress:
|
/sync/progress:
|
||||||
@@ -508,26 +917,27 @@ paths:
|
|||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Resets the games database by deleting all games and songs
|
description: Resets the soundtracks database by deleting all soundtracks and
|
||||||
|
songs
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"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 +949,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"
|
||||||
@@ -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 {
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ package web
|
|||||||
|
|
||||||
templ Base() {
|
templ Base() {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<title>Music Search</title>
|
<title>Music Search</title>
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"music-server/internal/backend"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var games_added []string
|
|
||||||
|
|
||||||
func FindGameWebHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
search_term := r.FormValue("search_term")
|
|
||||||
|
|
||||||
search(search_term)
|
|
||||||
|
|
||||||
component := FoundGames(games_added)
|
|
||||||
err = component.Render(r.Context(), w)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
log.Fatalf("Error rendering in FindGameWebHandler: %e", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func search(searchText string) {
|
|
||||||
games_added = nil
|
|
||||||
games := backend.GetAllSoundtracks()
|
|
||||||
for _, game := range games {
|
|
||||||
if is_match_exact(searchText, game) {
|
|
||||||
add_game(game)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, game := range games {
|
|
||||||
if is_match_contains(clean_term(searchText), clean_term(game)) {
|
|
||||||
add_game(game)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, game := range games {
|
|
||||||
if is_match_regex(clean_term(searchText), clean_term(game)) {
|
|
||||||
add_game(game)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func is_match_exact(search_term string, game_name string) bool {
|
|
||||||
search_term = strings.ToLower(search_term)
|
|
||||||
game_name = strings.ToLower(game_name)
|
|
||||||
|
|
||||||
if search_term == "" {
|
|
||||||
return true
|
|
||||||
} else if strings.Contains(game_name, search_term) {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func is_match_contains(search_term string, game_name string) bool {
|
|
||||||
if search_term == "" {
|
|
||||||
return true
|
|
||||||
} else if strings.Contains(game_name, search_term) {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func is_match_regex(search_term string, game_name string) bool {
|
|
||||||
if search_term == "" {
|
|
||||||
return true
|
|
||||||
} else if compile_regex(search_term).MatchString(game_name) {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add_game(game string) {
|
|
||||||
if !check_if_game_exists(game) {
|
|
||||||
games_added = append(games_added, game)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func check_if_game_exists(gameName string) bool {
|
|
||||||
game_exists := false
|
|
||||||
for _, child := range games_added {
|
|
||||||
if child == gameName {
|
|
||||||
game_exists = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return game_exists
|
|
||||||
}
|
|
||||||
|
|
||||||
func compile_regex(search_term string) *regexp.Regexp {
|
|
||||||
regText := ".*"
|
|
||||||
for _, letter := range search_term {
|
|
||||||
regText += string(letter) + ".*"
|
|
||||||
}
|
|
||||||
r, _ := regexp.Compile(regText)
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func clean_term(term string) string {
|
|
||||||
term = strings.ReplaceAll(term, " ", "")
|
|
||||||
term = strings.ReplaceAll(term, "é", "e")
|
|
||||||
term = strings.ReplaceAll(term, "+", "plus")
|
|
||||||
term = strings.ReplaceAll(term, "&", "and")
|
|
||||||
term = strings.ReplaceAll(term, "'n", "and")
|
|
||||||
return strings.ToLower(term)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
templ HelloForm() {
|
|
||||||
@Base() {
|
|
||||||
<div id="search-container">
|
|
||||||
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#games-container"/>
|
|
||||||
<button type="button" id="clear" name="clear">Clear</button>
|
|
||||||
</div>
|
|
||||||
<div id="games-container"></div>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('readystatechange', () => {
|
|
||||||
if (document.readyState == 'complete') {
|
|
||||||
htmx.ajax('POST', '/find', '#games-container');
|
|
||||||
document.getElementById("search_term").focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.getElementById("clear").addEventListener("click", function (event) {
|
|
||||||
document.getElementById("search_term").value = "";
|
|
||||||
htmx.ajax('POST', '/find', '#games-container');
|
|
||||||
document.getElementById("search_term").focus();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ FoundGames(games []string) {
|
|
||||||
for _, game := range games {
|
|
||||||
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
|
||||||
<p>{ game }</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var soundtracks_added []string
|
||||||
|
|
||||||
|
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
search_term := r.FormValue("search_term")
|
||||||
|
search(search_term)
|
||||||
|
results := soundtracks_added
|
||||||
|
|
||||||
|
component := FoundSoundtracks(results)
|
||||||
|
err = component.Render(r.Context(), w)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
log.Fatalf("Error rendering in FindSoundtrackWebHandler: %e", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func search(searchText string) {
|
||||||
|
soundtracks_added = nil
|
||||||
|
soundtrackData := backend.GetSearchCache()
|
||||||
|
results := searchWithData(searchText, soundtrackData)
|
||||||
|
soundtracks_added = results
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchWithData is a testable version of search that takes soundtrack data as a parameter
|
||||||
|
func searchWithData(searchText string, soundtrackData []backend.SoundtrackData) []string {
|
||||||
|
var results []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
// Precompute cleaned and lowercase search terms once
|
||||||
|
cleanedSearchTerm := strings.ToLower(searchText)
|
||||||
|
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, " ", "")
|
||||||
|
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "é", "e")
|
||||||
|
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "+", "plus")
|
||||||
|
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "&", "and")
|
||||||
|
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "'n", "and")
|
||||||
|
lowercaseSearchText := strings.ToLower(searchText)
|
||||||
|
|
||||||
|
// Pass 1: Exact match (case-insensitive contains)
|
||||||
|
for _, data := range soundtrackData {
|
||||||
|
if !seen[data.Original] && (searchText == "" || strings.Contains(data.Lowercase, lowercaseSearchText)) {
|
||||||
|
results = append(results, data.Original)
|
||||||
|
seen[data.Original] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Contains match with cleaned terms
|
||||||
|
for _, data := range soundtrackData {
|
||||||
|
if !seen[data.Original] && (cleanedSearchTerm == "" || strings.Contains(data.Cleaned, cleanedSearchTerm)) {
|
||||||
|
results = append(results, data.Original)
|
||||||
|
seen[data.Original] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3: Regex match with cleaned terms
|
||||||
|
for _, data := range soundtrackData {
|
||||||
|
if !seen[data.Original] && backend.MatchRegexCached(cleanedSearchTerm, data.Cleaned) {
|
||||||
|
results = append(results, data.Original)
|
||||||
|
seen[data.Original] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
templ SearchForm() {
|
||||||
|
@Base() {
|
||||||
|
<button id="dark-mode-toggle">🌙</button>
|
||||||
|
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
|
||||||
|
<button type="button" id="clear" name="clear">Clear</button>
|
||||||
|
<div id="soundtracks-container"></div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('readystatechange', () => {
|
||||||
|
if (document.readyState == 'complete') {
|
||||||
|
// Initialize search
|
||||||
|
htmlx.ajax('POST', '/find', '#soundtracks-container');
|
||||||
|
document.getElementById("search_term").focus();
|
||||||
|
|
||||||
|
// Initialize theme from localStorage (default to dark)
|
||||||
|
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
document.getElementById('dark-mode-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dark mode toggle functionality
|
||||||
|
document.getElementById("dark-mode-toggle").addEventListener("click", function() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const currentTheme = html.getAttribute('data-theme');
|
||||||
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
|
||||||
|
html.setAttribute('data-theme', newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
|
||||||
|
// Update toggle button text
|
||||||
|
this.textContent = newTheme === 'dark' ? '☀️' : '🌙';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("clear").addEventListener("click", function (event) {
|
||||||
|
document.getElementById("search_term").value = "";
|
||||||
|
htmlx.ajax('POST', '/find', '#soundtracks-container');
|
||||||
|
document.getElementById("search_term").focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ FoundSoundtracks(soundtracks []string) {
|
||||||
|
for _, soundtrack := range soundtracks {
|
||||||
|
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
||||||
|
<p class="soundtrack-text">{ soundtrack }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCleanTerm tests that search terms are cleaned correctly
|
||||||
|
func TestCleanTerm(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
// CleanSearchTermForMatch removes spaces, replaces specific chars, and lowercases
|
||||||
|
{"MGS4", "mgs4"},
|
||||||
|
{"RE2", "re2"},
|
||||||
|
{"FFVII", "ffvii"},
|
||||||
|
{"Ys", "ys"},
|
||||||
|
{" test ", "test"},
|
||||||
|
{"Test 123", "test123"},
|
||||||
|
{"A+B", "aplusb"},
|
||||||
|
{"é", "e"},
|
||||||
|
// Note: CleanSearchTermForMatch does NOT remove apostrophes, it only replaces "'n" with "and"
|
||||||
|
{"It's", "it's"},
|
||||||
|
// But it does replace "'n" with "and"
|
||||||
|
{"I'n", "iand"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := backend.CleanSearchTermForMatch(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("CleanSearchTermForMatch(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchWithDataHelper is a helper to create backend.SoundtrackData from original names
|
||||||
|
func searchWithDataHelper(originals []string) []backend.SoundtrackData {
|
||||||
|
data := make([]backend.SoundtrackData, len(originals))
|
||||||
|
for i, orig := range originals {
|
||||||
|
data[i] = backend.SoundtrackData{
|
||||||
|
Original: orig,
|
||||||
|
Lowercase: strings.ToLower(orig),
|
||||||
|
Cleaned: backend.CleanSearchTermForMatch(orig),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalSearch tests the normal search function
|
||||||
|
func TestNormalSearch(t *testing.T) {
|
||||||
|
// Set up comprehensive test data with all user-provided soundtracks
|
||||||
|
testData := searchWithDataHelper([]string{
|
||||||
|
"A Bird Story",
|
||||||
|
"Actraiser",
|
||||||
|
"Actraiser 2",
|
||||||
|
"Bangai-O Spirits",
|
||||||
|
"Ciconia When they Cry",
|
||||||
|
"Civilization IV",
|
||||||
|
"Civilization VI",
|
||||||
|
"Clair Obscur - Expedition 33",
|
||||||
|
"Club House Games (42 All-Time Classics)",
|
||||||
|
"Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv)",
|
||||||
|
"Final Fantasy VII Crisis Core",
|
||||||
|
"Final Fantasy VII Rebirth",
|
||||||
|
"Final Fantasy VII Remake",
|
||||||
|
"Final Fantasy VII",
|
||||||
|
"Joe & Mac",
|
||||||
|
"Judgment",
|
||||||
|
"Kingdom Hearts",
|
||||||
|
"Kingdom Hearts Birth By Sleep",
|
||||||
|
"Kingdom Hearts II",
|
||||||
|
"Kingdom Hearts III",
|
||||||
|
"Lemmings (SNES + Mega Drive)",
|
||||||
|
"Lemmings 3D",
|
||||||
|
"Mega Man X4",
|
||||||
|
"Metal Gear Rising - Revengeance",
|
||||||
|
"Metal Gear Solid (+Twin Snakes)",
|
||||||
|
"Metal Gear Solid 2 - Sons of Liberty",
|
||||||
|
"Metal Gear Solid 3 - Snake Eater",
|
||||||
|
"Metal Gear Solid 4 - Guns of the Patriots",
|
||||||
|
"Metroid Prime",
|
||||||
|
"Octopath Traveler",
|
||||||
|
"Octopath Traveler 2",
|
||||||
|
"Ori and the Blind Forest",
|
||||||
|
"Ori and the Will of the Wisps",
|
||||||
|
"OutRun 2006 - Coast 2 Coast",
|
||||||
|
"Paper Mario - The Thousand Year Door",
|
||||||
|
"Pokémon HeartGold & SoulSilver",
|
||||||
|
"Resident Evil 2 (Remake)",
|
||||||
|
"Shenmue",
|
||||||
|
"Super Mario Galaxy",
|
||||||
|
"Trails in the Sky FC (Sora no Kiseki FC)",
|
||||||
|
"Trails in the Sky SC (Sora no Kiseki SC)",
|
||||||
|
"Turrican II - The Final Fight",
|
||||||
|
"Umineko no Naku Koro ni",
|
||||||
|
"Umineko no Naku Koro ni Chiru",
|
||||||
|
"VVVVVV",
|
||||||
|
"YS VIII - Lacrimosa of DANA",
|
||||||
|
"Yakuza 0",
|
||||||
|
"Ys (Chronicles)",
|
||||||
|
"Ys - Memories of Celceta",
|
||||||
|
"Ys - The Ark of Napishtim",
|
||||||
|
"Ys II (Chronicles)",
|
||||||
|
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
|
||||||
|
"Ys IV - The Dawn of Ys",
|
||||||
|
"Ys Origin",
|
||||||
|
"Ys Seven",
|
||||||
|
"Ys X Nordics",
|
||||||
|
"Call of Duty: Modern Warfare",
|
||||||
|
"Half-Life 3",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test cases covering all match types
|
||||||
|
tests := []struct {
|
||||||
|
query string
|
||||||
|
wantFound string
|
||||||
|
}{
|
||||||
|
// Exact substring matches
|
||||||
|
{"Metal", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||||
|
{"Resident", "Resident Evil 2 (Remake)"},
|
||||||
|
{"Final", "Final Fantasy VII"},
|
||||||
|
{"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||||
|
|
||||||
|
// Contains matches (on cleaned terms)
|
||||||
|
{"gotp", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||||
|
{"mgs", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||||
|
{"kh", "Kingdom Hearts"},
|
||||||
|
{"mg", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||||
|
|
||||||
|
// Regex matches
|
||||||
|
{"mgs4go", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||||
|
{"re2r", "Resident Evil 2 (Remake)"},
|
||||||
|
{"ffv", "Final Fantasy VII"},
|
||||||
|
{"yiwfyt", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||||
|
{"codmw", "Call of Duty: Modern Warfare"},
|
||||||
|
{"mgs4gop", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.query, func(t *testing.T) {
|
||||||
|
results := searchWithData(tt.query, testData)
|
||||||
|
|
||||||
|
// Check that the expected item is in the results
|
||||||
|
found := false
|
||||||
|
for _, result := range results {
|
||||||
|
if result == tt.wantFound {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("searchWithData(%q, ...) did not find expected item %q, got %v", tt.query, tt.wantFound, results)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSearchOrdering tests that Pass 1 matches come before Pass 2 matches
|
||||||
|
func TestSearchOrdering(t *testing.T) {
|
||||||
|
// "MG" matches Pass 1 (exact substring on original)
|
||||||
|
// "Metal Gear" matches Pass 2 (contains on cleaned) but NOT Pass 1 (no "mg" substring in "metal gear")
|
||||||
|
testData := searchWithDataHelper([]string{
|
||||||
|
"Metal Gear",
|
||||||
|
"MG",
|
||||||
|
})
|
||||||
|
|
||||||
|
results := searchWithData("mg", testData)
|
||||||
|
|
||||||
|
// Find indices
|
||||||
|
indexPass1 := -1
|
||||||
|
indexPass2 := -1
|
||||||
|
for i, result := range results {
|
||||||
|
if result == "MG" {
|
||||||
|
indexPass1 = i
|
||||||
|
}
|
||||||
|
if result == "Metal Gear" {
|
||||||
|
indexPass2 = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1 match (MG) should come before Pass 2 match (Metal Gear)
|
||||||
|
if indexPass1 >= 0 && indexPass2 >= 0 && indexPass1 >= indexPass2 {
|
||||||
|
t.Errorf("Expected Pass 1 match 'MG' to come before Pass 2 match 'Metal Gear', but got order: %v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The tests above verify:
|
||||||
|
// 1. clean_term - the term cleaning logic
|
||||||
|
// 2. search - the complete normal search with exact, contains, and regex matching
|
||||||
|
// 3. ordering - exact matches come before contains matches
|
||||||
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/MShekow/directory-checksum v1.4.18
|
github.com/MShekow/directory-checksum v1.4.18
|
||||||
github.com/a-h/templ v0.3.1020
|
github.com/a-h/templ v0.3.1020
|
||||||
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/labstack/echo/v5 v5.1.1
|
github.com/labstack/echo/v5 v5.1.1
|
||||||
@@ -18,6 +19,8 @@ require (
|
|||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/google/uuid v1.6.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
@@ -32,7 +35,6 @@ require (
|
|||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.6.0 // indirect
|
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/ebitengine/purego v0.10.0 // indirect
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
@@ -50,7 +52,6 @@ require (
|
|||||||
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
||||||
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|||||||
+50
-50
@@ -12,8 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SongInfo struct {
|
type SongInfo struct {
|
||||||
Game string `json:"Game"`
|
Soundtrack string `json:"Soundtrack"`
|
||||||
GamePlayed int32 `json:"GamePlayed"`
|
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
|
||||||
Song string `json:"Song"`
|
Song string `json:"Song"`
|
||||||
SongPlayed int32 `json:"SongPlayed"`
|
SongPlayed int32 `json:"SongPlayed"`
|
||||||
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
|
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
|
||||||
@@ -22,7 +22,7 @@ type SongInfo struct {
|
|||||||
|
|
||||||
var currentSong = -1
|
var currentSong = -1
|
||||||
|
|
||||||
var gamesNew []repository.Soundtrack
|
var soundtracksNew []repository.Soundtrack
|
||||||
|
|
||||||
var songQueNew []repository.Song
|
var songQueNew []repository.Song
|
||||||
|
|
||||||
@@ -37,12 +37,12 @@ func initRepo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAllGames() []repository.Soundtrack {
|
func getAllSoundtracks() []repository.Soundtrack {
|
||||||
if len(gamesNew) == 0 {
|
if len(soundtracksNew) == 0 {
|
||||||
initRepo()
|
initRepo()
|
||||||
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||||
}
|
}
|
||||||
return gamesNew
|
return soundtracksNew
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ func Reset() {
|
|||||||
songQueNew = nil
|
songQueNew = nil
|
||||||
currentSong = -1
|
currentSong = -1
|
||||||
initRepo()
|
initRepo()
|
||||||
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddLatestToQue() {
|
func AddLatestToQue() {
|
||||||
@@ -92,34 +92,34 @@ func SetPlayed(songNumber int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetRandomSong() string {
|
func GetRandomSong() string {
|
||||||
getAllGames()
|
getAllSoundtracks()
|
||||||
if len(gamesNew) == 0 {
|
if len(soundtracksNew) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
song := getSongFromList(gamesNew)
|
song := getSongFromList(soundtracksNew)
|
||||||
lastFetchedNew = song
|
lastFetchedNew = song
|
||||||
return song.Path
|
return song.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRandomSongLowChance() string {
|
func GetRandomSongLowChance() string {
|
||||||
getAllGames()
|
getAllSoundtracks()
|
||||||
|
|
||||||
var listOfGames []repository.Soundtrack
|
var listOfSoundtracks []repository.Soundtrack
|
||||||
|
|
||||||
var averagePlayed = getAveragePlayed()
|
var averagePlayed = getAveragePlayed()
|
||||||
|
|
||||||
for _, data := range gamesNew {
|
for _, data := range soundtracksNew {
|
||||||
timesToAdd := averagePlayed - data.TimesPlayed
|
timesToAdd := averagePlayed - data.TimesPlayed
|
||||||
if timesToAdd <= 0 {
|
if timesToAdd <= 0 {
|
||||||
listOfGames = append(listOfGames, data)
|
listOfSoundtracks = append(listOfSoundtracks, data)
|
||||||
} else {
|
} else {
|
||||||
for i := int32(0); i < timesToAdd; i++ {
|
for i := int32(0); i < timesToAdd; i++ {
|
||||||
listOfGames = append(listOfGames, data)
|
listOfSoundtracks = append(listOfSoundtracks, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
song := getSongFromList(listOfGames)
|
song := getSongFromList(listOfSoundtracks)
|
||||||
|
|
||||||
lastFetchedNew = song
|
lastFetchedNew = song
|
||||||
return song.Path
|
return song.Path
|
||||||
@@ -127,11 +127,11 @@ func GetRandomSongLowChance() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetRandomSongClassic() string {
|
func GetRandomSongClassic() string {
|
||||||
getAllGames()
|
getAllSoundtracks()
|
||||||
|
|
||||||
var listOfAllSongs []repository.Song
|
var listOfAllSongs []repository.Song
|
||||||
for _, game := range gamesNew {
|
for _, soundtrack := range soundtracksNew {
|
||||||
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID)
|
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
|
||||||
listOfAllSongs = append(listOfAllSongs, songList...)
|
listOfAllSongs = append(listOfAllSongs, songList...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,25 +139,25 @@ func GetRandomSongClassic() string {
|
|||||||
var song repository.Song
|
var song repository.Song
|
||||||
for !songFound {
|
for !songFound {
|
||||||
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
|
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
|
||||||
gameData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
|
soundtrackData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||||
logging.GetLogger().Warn("Song not found, removed from database",
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
zap.String("song", song.SongName),
|
zap.String("song", song.SongName),
|
||||||
zap.String("game", gameData.SoundtrackName),
|
zap.String("soundtrack", soundtrackData.SoundtrackName),
|
||||||
zap.String("filename", *song.FileName))
|
zap.String("filename", *song.FileName))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
//Check if file exists and open
|
//Check if file exists and open
|
||||||
openFile, err := os.Open(song.Path)
|
openFile, err := os.Open(song.Path)
|
||||||
if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) {
|
if err != nil || (song.FileName != nil && soundtrackData.Path+*song.FileName != song.Path) {
|
||||||
//File not found
|
//File not found
|
||||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||||
logging.GetLogger().Warn("Song not found, removed from database",
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
zap.String("song", song.SongName),
|
zap.String("song", song.SongName),
|
||||||
zap.String("game", gameData.SoundtrackName),
|
zap.String("soundtrack", soundtrackData.SoundtrackName),
|
||||||
zap.String("filename", *song.FileName))
|
zap.String("filename", *song.FileName))
|
||||||
} else {
|
} else {
|
||||||
songFound = true
|
songFound = true
|
||||||
@@ -177,11 +177,11 @@ func GetSongInfo() SongInfo {
|
|||||||
}
|
}
|
||||||
var currentSongData = songQueNew[currentSong]
|
var currentSongData = songQueNew[currentSong]
|
||||||
|
|
||||||
currentGameData := getCurrentGame(currentSongData)
|
currentSoundtrackData := getCurrentSoundtrack(currentSongData)
|
||||||
|
|
||||||
return SongInfo{
|
return SongInfo{
|
||||||
Game: currentGameData.SoundtrackName,
|
Soundtrack: currentSoundtrackData.SoundtrackName,
|
||||||
GamePlayed: currentGameData.TimesPlayed,
|
SoundtrackPlayed: currentSoundtrackData.TimesPlayed,
|
||||||
Song: currentSongData.SongName,
|
Song: currentSongData.SongName,
|
||||||
SongPlayed: currentSongData.TimesPlayed,
|
SongPlayed: currentSongData.TimesPlayed,
|
||||||
CurrentlyPlaying: true,
|
CurrentlyPlaying: true,
|
||||||
@@ -193,10 +193,10 @@ func GetPlayedSongs() []SongInfo {
|
|||||||
var songList []SongInfo
|
var songList []SongInfo
|
||||||
|
|
||||||
for i, song := range songQueNew {
|
for i, song := range songQueNew {
|
||||||
gameData := getCurrentGame(song)
|
soundtrackData := getCurrentSoundtrack(song)
|
||||||
songList = append(songList, SongInfo{
|
songList = append(songList, SongInfo{
|
||||||
Game: gameData.SoundtrackName,
|
Soundtrack: soundtrackData.SoundtrackName,
|
||||||
GamePlayed: gameData.TimesPlayed,
|
SoundtrackPlayed: soundtrackData.TimesPlayed,
|
||||||
Song: song.SongName,
|
Song: song.SongName,
|
||||||
SongPlayed: song.TimesPlayed,
|
SongPlayed: song.TimesPlayed,
|
||||||
CurrentlyPlaying: i == currentSong,
|
CurrentlyPlaying: i == currentSong,
|
||||||
@@ -218,21 +218,21 @@ func GetSong(song string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetAllSoundtracks() []string {
|
func GetAllSoundtracks() []string {
|
||||||
getAllGames()
|
getAllSoundtracks()
|
||||||
|
|
||||||
var jsonArray []string
|
var jsonArray []string
|
||||||
for _, game := range gamesNew {
|
for _, soundtrack := range soundtracksNew {
|
||||||
jsonArray = append(jsonArray, game.SoundtrackName)
|
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
|
||||||
}
|
}
|
||||||
return jsonArray
|
return jsonArray
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllSoundtracksRandom() []string {
|
func GetAllSoundtracksRandom() []string {
|
||||||
getAllGames()
|
getAllSoundtracks()
|
||||||
|
|
||||||
var jsonArray []string
|
var jsonArray []string
|
||||||
for _, game := range gamesNew {
|
for _, soundtrack := range soundtracksNew {
|
||||||
jsonArray = append(jsonArray, game.SoundtrackName)
|
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
|
||||||
}
|
}
|
||||||
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
|
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
|
||||||
return jsonArray
|
return jsonArray
|
||||||
@@ -266,12 +266,12 @@ func GetPreviousSong() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSongFromList(games []repository.Soundtrack) repository.Song {
|
func getSongFromList(soundtracks []repository.Soundtrack) repository.Song {
|
||||||
songFound := false
|
songFound := false
|
||||||
var song repository.Song
|
var song repository.Song
|
||||||
for !songFound {
|
for !songFound {
|
||||||
game := getRandomGame(games)
|
soundtrack := getRandomSoundtrack(soundtracks)
|
||||||
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID)
|
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
|
||||||
if len(songs) == 0 {
|
if len(songs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -280,12 +280,12 @@ func getSongFromList(games []repository.Soundtrack) repository.Song {
|
|||||||
|
|
||||||
//Check if file exists and open
|
//Check if file exists and open
|
||||||
openFile, err := os.Open(song.Path)
|
openFile, err := os.Open(song.Path)
|
||||||
if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
if err != nil || (song.FileName != nil && soundtrack.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
||||||
//File not found
|
//File not found
|
||||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||||
logging.GetLogger().Warn("Song not found, removed from database",
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
zap.String("song", song.SongName),
|
zap.String("song", song.SongName),
|
||||||
zap.String("game", game.SoundtrackName),
|
zap.String("soundtrack", soundtrack.SoundtrackName),
|
||||||
zap.Any("filename", song.FileName))
|
zap.Any("filename", song.FileName))
|
||||||
} else {
|
} else {
|
||||||
songFound = true
|
songFound = true
|
||||||
@@ -299,24 +299,24 @@ func getSongFromList(games []repository.Soundtrack) repository.Song {
|
|||||||
return song
|
return song
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCurrentGame(currentSongData repository.Song) repository.Soundtrack {
|
func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
|
||||||
for _, game := range gamesNew {
|
for _, soundtrack := range soundtracksNew {
|
||||||
if game.ID == currentSongData.SoundtrackID {
|
if soundtrack.ID == currentSongData.SoundtrackID {
|
||||||
return game
|
return soundtrack
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return repository.Soundtrack{}
|
return repository.Soundtrack{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAveragePlayed() int32 {
|
func getAveragePlayed() int32 {
|
||||||
getAllGames()
|
getAllSoundtracks()
|
||||||
var sum int32
|
var sum int32
|
||||||
for _, data := range gamesNew {
|
for _, data := range soundtracksNew {
|
||||||
sum += data.TimesPlayed
|
sum += data.TimesPlayed
|
||||||
}
|
}
|
||||||
return sum / int32(len(gamesNew))
|
return sum / int32(len(soundtracksNew))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRandomGame(listOfGames []repository.Soundtrack) repository.Soundtrack {
|
func getRandomSoundtrack(listOfSoundtracks []repository.Soundtrack) repository.Soundtrack {
|
||||||
return listOfGames[rand.Intn(len(listOfGames))]
|
return listOfSoundtracks[rand.Intn(len(listOfSoundtracks))]
|
||||||
}
|
}
|
||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
|
|
||||||
// Test the average calculation logic directly without database access
|
// Test the average calculation logic directly without database access
|
||||||
func TestCalculateAverage(t *testing.T) {
|
func TestCalculateAverage(t *testing.T) {
|
||||||
games := []repository.Game{
|
soundtracks := []repository.Soundtrack{
|
||||||
{GameName: "Game1", TimesPlayed: 10},
|
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||||
{GameName: "Game2", TimesPlayed: 20},
|
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||||
{GameName: "Game3", TimesPlayed: 30},
|
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
var sum int32
|
var sum int32
|
||||||
for _, data := range games {
|
for _, data := range soundtracks {
|
||||||
sum += data.TimesPlayed
|
sum += data.TimesPlayed
|
||||||
}
|
}
|
||||||
result := sum / int32(len(games))
|
result := sum / int32(len(soundtracks))
|
||||||
expected := int32(20)
|
expected := int32(20)
|
||||||
|
|
||||||
if result != expected {
|
if result != expected {
|
||||||
@@ -28,9 +28,9 @@ func TestCalculateAverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateAverageEmpty(t *testing.T) {
|
func TestCalculateAverageEmpty(t *testing.T) {
|
||||||
games := []repository.Game{}
|
soundtracks := []repository.Soundtrack{}
|
||||||
|
|
||||||
if len(games) == 0 {
|
if len(soundtracks) == 0 {
|
||||||
result := int32(0)
|
result := int32(0)
|
||||||
expected := int32(0)
|
expected := int32(0)
|
||||||
if result != expected {
|
if result != expected {
|
||||||
@@ -40,10 +40,10 @@ func TestCalculateAverageEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sum int32
|
var sum int32
|
||||||
for _, data := range games {
|
for _, data := range soundtracks {
|
||||||
sum += data.TimesPlayed
|
sum += data.TimesPlayed
|
||||||
}
|
}
|
||||||
result := sum / int32(len(games))
|
result := sum / int32(len(soundtracks))
|
||||||
expected := int32(0)
|
expected := int32(0)
|
||||||
|
|
||||||
if result != expected {
|
if result != expected {
|
||||||
@@ -52,152 +52,150 @@ func TestCalculateAverageEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateAverageSingle(t *testing.T) {
|
func TestCalculateAverageSingle(t *testing.T) {
|
||||||
games := []repository.Game{
|
soundtracks := []repository.Soundtrack{
|
||||||
{GameName: "Game1", TimesPlayed: 42},
|
{SoundtrackName: "Soundtrack1", TimesPlayed: 42},
|
||||||
}
|
}
|
||||||
|
|
||||||
var sum int32
|
var sum int32
|
||||||
for _, data := range games {
|
for _, data := range soundtracks {
|
||||||
sum += data.TimesPlayed
|
sum += data.TimesPlayed
|
||||||
}
|
}
|
||||||
result := sum / int32(len(games))
|
result := sum / int32(len(soundtracks))
|
||||||
expected := int32(42)
|
expected := int32(42)
|
||||||
|
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Errorf("Average calculation with single game = %v, want %v", result, expected)
|
t.Errorf("Average calculation with single soundtrack = %v, want %v", result, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRandomGame(t *testing.T) {
|
func TestGetRandomSoundtrack(t *testing.T) {
|
||||||
games := []repository.Game{
|
soundtracks := []repository.Soundtrack{
|
||||||
{GameName: "Game1", TimesPlayed: 10},
|
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||||
{GameName: "Game2", TimesPlayed: 20},
|
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||||
{GameName: "Game3", TimesPlayed: 30},
|
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set seed for reproducible tests
|
// Set seed for reproducible tests
|
||||||
rand.Seed(42)
|
rand.Seed(42)
|
||||||
|
|
||||||
result := games[rand.Intn(len(games))]
|
result := soundtracks[rand.Intn(len(soundtracks))]
|
||||||
|
|
||||||
if result.GameName == "" {
|
if result.SoundtrackName == "" {
|
||||||
t.Error("random game selection returned empty game")
|
t.Error("random soundtrack selection returned empty soundtrack")
|
||||||
}
|
}
|
||||||
|
|
||||||
found := false
|
found := false
|
||||||
for _, g := range games {
|
for _, s := range soundtracks {
|
||||||
if g.GameName == result.GameName {
|
if s.SoundtrackName == result.SoundtrackName {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Errorf("random game selection returned game not in list: %v", result.GameName)
|
t.Errorf("random soundtrack selection returned soundtrack not in list: %v", result.SoundtrackName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindGameByID(t *testing.T) {
|
func TestFindSoundtrackByID(t *testing.T) {
|
||||||
games := []repository.Game{
|
soundtracks := []repository.Soundtrack{
|
||||||
{ID: 1, GameName: "Game1", TimesPlayed: 10},
|
{ID: 1, SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||||
{ID: 2, GameName: "Game2", TimesPlayed: 20},
|
{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||||
{ID: 3, GameName: "Game3", TimesPlayed: 30},
|
{ID: 3, SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
games []repository.Game
|
soundtracks []repository.Soundtrack
|
||||||
gameID int32
|
soundtrackID int32
|
||||||
expected repository.Game
|
expected repository.Soundtrack
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "existing game",
|
name: "existing soundtrack",
|
||||||
games: games,
|
soundtracks: soundtracks,
|
||||||
gameID: 2,
|
soundtrackID: 2,
|
||||||
expected: repository.Game{ID: 2, GameName: "Game2", TimesPlayed: 20},
|
expected: repository.Soundtrack{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-existing game",
|
name: "non-existing soundtrack",
|
||||||
games: games,
|
soundtracks: soundtracks,
|
||||||
gameID: 99,
|
soundtrackID: 99,
|
||||||
expected: repository.Game{},
|
expected: repository.Soundtrack{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
var result repository.Game
|
var result repository.Soundtrack
|
||||||
for _, game := range tt.games {
|
for _, s := range tt.soundtracks {
|
||||||
if game.ID == tt.gameID {
|
if s.ID == tt.soundtrackID {
|
||||||
result = game
|
result = s
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result.ID != tt.expected.ID || result.GameName != tt.expected.GameName {
|
if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName {
|
||||||
t.Errorf("findGameByID() = %v, want %v", result, tt.expected)
|
t.Errorf("findSoundtrackByID() = %v, want %v", result, tt.expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractGameNames(t *testing.T) {
|
func TestExtractSoundtrackNames(t *testing.T) {
|
||||||
games := []repository.Game{
|
soundtracks := []repository.Soundtrack{
|
||||||
{GameName: "Game1", TimesPlayed: 10},
|
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||||
{GameName: "Game2", TimesPlayed: 20},
|
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||||
{GameName: "Game3", TimesPlayed: 30},
|
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []string
|
var result []string
|
||||||
for _, game := range games {
|
for _, s := range soundtracks {
|
||||||
result = append(result, game.GameName)
|
result = append(result, s.SoundtrackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"Game1", "Game2", "Game3"}
|
expected := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
|
||||||
|
|
||||||
if len(result) != len(expected) {
|
if len(result) != len(expected) {
|
||||||
t.Errorf("extractGameNames() length = %d, want %d", len(result), len(expected))
|
t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, v := range result {
|
for i, v := range result {
|
||||||
if v != expected[i] {
|
if v != expected[i] {
|
||||||
t.Errorf("extractGameNames()[%d] = %v, want %v", i, v, expected[i])
|
t.Errorf("extractSoundtrackNames()[%d] = %v, want %v", i, v, expected[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShuffleGameNames(t *testing.T) {
|
func TestShuffleSoundtrackNames(t *testing.T) {
|
||||||
games := []string{"Game1", "Game2", "Game3"}
|
soundtracks := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
|
||||||
|
|
||||||
// Test that shuffle doesn't lose any elements
|
// Test that shuffle doesn't lose any elements
|
||||||
// We can't test the order since it's random, but we can test length and contents
|
// We can't test the order since it's random, but we can test length and contents
|
||||||
original := make([]string, len(games))
|
original := make([]string, len(soundtracks))
|
||||||
copy(original, games)
|
copy(original, soundtracks)
|
||||||
|
|
||||||
// Simple shuffle implementation for testing
|
// Simple shuffle implementation for testing
|
||||||
for i := range games {
|
for i := range soundtracks {
|
||||||
j := i // In real code this would be random
|
j := i // In real code this would be random
|
||||||
games[i], games[j] = games[j], games[i]
|
soundtracks[i], soundtracks[j] = soundtracks[j], soundtracks[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(games) != len(original) {
|
if len(soundtracks) != len(original) {
|
||||||
t.Errorf("shuffleGameNames() changed length from %d to %d", len(original), len(games))
|
t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(soundtracks))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all original elements are still present
|
// Check all original elements are still present
|
||||||
for _, orig := range original {
|
for _, orig := range original {
|
||||||
found := false
|
found := false
|
||||||
for _, g := range games {
|
for _, s := range soundtracks {
|
||||||
if g == orig {
|
if s == orig {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Errorf("shuffleGameNames() lost element: %v", orig)
|
t.Errorf("shuffleSoundtrackNames() lost element: %v", orig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SoundtrackData holds precomputed search data for a soundtrack
|
||||||
|
type SoundtrackData struct {
|
||||||
|
Original string
|
||||||
|
Lowercase string
|
||||||
|
Cleaned string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
searchCache []SoundtrackData
|
||||||
|
searchCacheMutex sync.Mutex
|
||||||
|
searchRegexCache = make(map[string]*regexp.Regexp)
|
||||||
|
searchRegexCacheMutex sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSearchCache returns the precomputed search cache, initializing it if needed
|
||||||
|
func GetSearchCache() []SoundtrackData {
|
||||||
|
searchCacheMutex.Lock()
|
||||||
|
defer searchCacheMutex.Unlock()
|
||||||
|
|
||||||
|
if len(searchCache) == 0 {
|
||||||
|
loadSearchCache()
|
||||||
|
}
|
||||||
|
return searchCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadSearchCache clears and reloads the search cache.
|
||||||
|
// This should be called after a sync operation to ensure search results are up to date.
|
||||||
|
func ReloadSearchCache() {
|
||||||
|
searchCacheMutex.Lock()
|
||||||
|
defer searchCacheMutex.Unlock()
|
||||||
|
searchCache = nil
|
||||||
|
loadSearchCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSearchCache loads the soundtrack data into the cache
|
||||||
|
func loadSearchCache() {
|
||||||
|
soundtracks := GetAllSoundtracks()
|
||||||
|
searchCache = make([]SoundtrackData, len(soundtracks))
|
||||||
|
|
||||||
|
for i, soundtrack := range soundtracks {
|
||||||
|
cleaned := cleanSearchTerm(soundtrack)
|
||||||
|
lowercase := strings.ToLower(soundtrack)
|
||||||
|
searchCache[i] = SoundtrackData{
|
||||||
|
Original: soundtrack,
|
||||||
|
Lowercase: lowercase,
|
||||||
|
Cleaned: cleaned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanSearchTermForMatch cleans a search term for better matching
|
||||||
|
func CleanSearchTermForMatch(term string) string {
|
||||||
|
term = strings.ReplaceAll(term, " ", "")
|
||||||
|
term = strings.ReplaceAll(term, "é", "e")
|
||||||
|
term = strings.ReplaceAll(term, "+", "plus")
|
||||||
|
term = strings.ReplaceAll(term, "&", "and")
|
||||||
|
term = strings.ReplaceAll(term, "'n", "and")
|
||||||
|
return strings.ToLower(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSearchTerm cleans a search term for better matching (internal)
|
||||||
|
func cleanSearchTerm(term string) string {
|
||||||
|
return CleanSearchTermForMatch(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchRegexCached checks if a search term matches using cached compiled regex
|
||||||
|
func MatchRegexCached(searchTerm string, soundtrackName string) bool {
|
||||||
|
if searchTerm == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
searchRegexCacheMutex.Lock()
|
||||||
|
re, exists := searchRegexCache[searchTerm]
|
||||||
|
if !exists {
|
||||||
|
re = compileSearchRegex(searchTerm)
|
||||||
|
searchRegexCache[searchTerm] = re
|
||||||
|
}
|
||||||
|
searchRegexCacheMutex.Unlock()
|
||||||
|
|
||||||
|
return re.MatchString(soundtrackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileSearchRegex compiles a search term into a regex pattern
|
||||||
|
func compileSearchRegex(searchTerm string) *regexp.Regexp {
|
||||||
|
regText := ".*"
|
||||||
|
for _, letter := range searchTerm {
|
||||||
|
regText += string(letter) + ".*"
|
||||||
|
}
|
||||||
|
re, _ := regexp.Compile(regText)
|
||||||
|
return re
|
||||||
|
}
|
||||||
@@ -9,19 +9,19 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GameWithSongs represents a game with its songs for statistics
|
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
|
||||||
type GameWithSongs struct {
|
type SoundtrackWithSongs struct {
|
||||||
SoundtrackID int32 `json:"game_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackName string `json:"game_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"game_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
SoundtrackLastPlayed *time.Time `json:"game_last_played,omitempty"`
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
|
||||||
Songs []SongInfoForStats `json:"songs"`
|
Songs []SongInfoForStats `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SongInfoForStats represents a song with game info for statistics
|
// SongInfoForStats represents a song with soundtrack info for statistics
|
||||||
type SongInfoForStats struct {
|
type SongInfoForStats struct {
|
||||||
SoundtrackID int32 `json:"game_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackName string `json:"game_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SongName string `json:"song_name"`
|
SongName string `json:"song_name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
TimesPlayed int32 `json:"times_played"`
|
TimesPlayed int32 `json:"times_played"`
|
||||||
@@ -30,13 +30,13 @@ type SongInfoForStats struct {
|
|||||||
|
|
||||||
// StatisticsSummary holds overall statistics
|
// StatisticsSummary holds overall statistics
|
||||||
type StatisticsSummary struct {
|
type StatisticsSummary struct {
|
||||||
TotalGames int64 `json:"total_games"`
|
TotalSoundtracks int64 `json:"total_soundtracks"`
|
||||||
PlayedGames int64 `json:"played_games"`
|
PlayedSoundtracks int64 `json:"played_soundtracks"`
|
||||||
NeverPlayedGames int64 `json:"never_played_games"`
|
NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"`
|
||||||
TotalGamePlays int64 `json:"total_game_plays"`
|
TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"`
|
||||||
AvgGamePlays float64 `json:"avg_game_plays"`
|
AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"`
|
||||||
MaxGamePlays int64 `json:"max_game_plays"`
|
MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"`
|
||||||
MinGamePlays int64 `json:"min_game_plays"`
|
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatisticsHandler manages statistics operations
|
// StatisticsHandler manages statistics operations
|
||||||
@@ -49,19 +49,19 @@ func NewStatisticsHandler() *StatisticsHandler {
|
|||||||
return &StatisticsHandler{}
|
return &StatisticsHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMostPlayedGamesWithSongs returns the top N most played games with their songs
|
// GetMostPlayedSoundtracksWithSongs returns the top N most played soundtracks with their songs
|
||||||
func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) {
|
func (h *StatisticsHandler) GetMostPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
|
||||||
queries := BackendRepo()
|
queries := BackendRepo()
|
||||||
ctx := BackendCtx()
|
ctx := BackendCtx()
|
||||||
|
|
||||||
// Get raw results
|
// Get raw results
|
||||||
rows, err := queries.GetMostPlayedGamesWithSongs(ctx, limit)
|
rows, err := queries.GetMostPlayedSoundtracksWithSongs(ctx, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to GameWithSongs
|
// Convert to SoundtrackWithSongs
|
||||||
var result []GameWithSongs
|
var result []SoundtrackWithSongs
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var songs []SongInfoForStats
|
var songs []SongInfoForStats
|
||||||
if row.Songs != nil {
|
if row.Songs != nil {
|
||||||
@@ -71,7 +71,7 @@ func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWith
|
|||||||
songs = make([]SongInfoForStats, 0)
|
songs = make([]SongInfoForStats, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, GameWithSongs{
|
result = append(result, SoundtrackWithSongs{
|
||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
@@ -82,17 +82,17 @@ func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWith
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeastPlayedGamesWithSongs returns the top N least played games with their songs
|
// GetLeastPlayedSoundtracksWithSongs returns the top N least played soundtracks with their songs
|
||||||
func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) {
|
func (h *StatisticsHandler) GetLeastPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
|
||||||
queries := BackendRepo()
|
queries := BackendRepo()
|
||||||
ctx := BackendCtx()
|
ctx := BackendCtx()
|
||||||
|
|
||||||
rows, err := queries.GetLeastPlayedGamesWithSongs(ctx, limit)
|
rows, err := queries.GetLeastPlayedSoundtracksWithSongs(ctx, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []GameWithSongs
|
var result []SoundtrackWithSongs
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var songs []SongInfoForStats
|
var songs []SongInfoForStats
|
||||||
if row.Songs != nil {
|
if row.Songs != nil {
|
||||||
@@ -100,7 +100,7 @@ func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWit
|
|||||||
songs = make([]SongInfoForStats, 0)
|
songs = make([]SongInfoForStats, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, GameWithSongs{
|
result = append(result, SoundtrackWithSongs{
|
||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
@@ -111,12 +111,12 @@ func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWit
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMostPlayedSongsWithGame returns the top N most played songs with their game info
|
// GetMostPlayedSongsWithSoundtrack returns the top N most played songs with their soundtrack info
|
||||||
func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) {
|
func (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
|
||||||
queries := BackendRepo()
|
queries := BackendRepo()
|
||||||
ctx := BackendCtx()
|
ctx := BackendCtx()
|
||||||
|
|
||||||
rows, err := queries.GetMostPlayedSongsWithGame(ctx, limit)
|
rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -135,12 +135,12 @@ func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoF
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeastPlayedSongsWithGame returns the top N least played songs with their game info
|
// GetLeastPlayedSongsWithSoundtrack returns the top N least played songs with their soundtrack info
|
||||||
func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) {
|
func (h *StatisticsHandler) GetLeastPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
|
||||||
queries := BackendRepo()
|
queries := BackendRepo()
|
||||||
ctx := BackendCtx()
|
ctx := BackendCtx()
|
||||||
|
|
||||||
rows, err := queries.GetLeastPlayedSongsWithGame(ctx, limit)
|
rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -159,17 +159,17 @@ func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfo
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNeverPlayedGames returns games that have never been played
|
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||||
func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
|
func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
|
||||||
queries := BackendRepo()
|
queries := BackendRepo()
|
||||||
ctx := BackendCtx()
|
ctx := BackendCtx()
|
||||||
|
|
||||||
rows, err := queries.GetNeverPlayedGames(ctx)
|
rows, err := queries.GetNeverPlayedSoundtracks(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []GameWithSongs
|
var result []SoundtrackWithSongs
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var songs []SongInfoForStats
|
var songs []SongInfoForStats
|
||||||
if row.Songs != nil {
|
if row.Songs != nil {
|
||||||
@@ -177,7 +177,7 @@ func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
|
|||||||
songs = make([]SongInfoForStats, 0)
|
songs = make([]SongInfoForStats, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, GameWithSongs{
|
result = append(result, SoundtrackWithSongs{
|
||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
@@ -188,17 +188,17 @@ func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLastPlayedGames returns the most recently played games
|
// GetLastPlayedSoundtracks returns the most recently played soundtracks
|
||||||
func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, error) {
|
func (h *StatisticsHandler) GetLastPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
|
||||||
queries := BackendRepo()
|
queries := BackendRepo()
|
||||||
ctx := BackendCtx()
|
ctx := BackendCtx()
|
||||||
|
|
||||||
rows, err := queries.GetLastPlayedGames(ctx, limit)
|
rows, err := queries.GetLastPlayedSoundtracks(ctx, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []GameWithSongs
|
var result []SoundtrackWithSongs
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var songs []SongInfoForStats
|
var songs []SongInfoForStats
|
||||||
if row.Songs != nil {
|
if row.Songs != nil {
|
||||||
@@ -206,7 +206,7 @@ func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, er
|
|||||||
songs = make([]SongInfoForStats, 0)
|
songs = make([]SongInfoForStats, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, GameWithSongs{
|
result = append(result, SoundtrackWithSongs{
|
||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
@@ -217,17 +217,17 @@ func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, er
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOldestPlayedGames returns the least recently played games
|
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
|
||||||
func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs, error) {
|
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
|
||||||
queries := BackendRepo()
|
queries := BackendRepo()
|
||||||
ctx := BackendCtx()
|
ctx := BackendCtx()
|
||||||
|
|
||||||
rows, err := queries.GetOldestPlayedGames(ctx, limit)
|
rows, err := queries.GetOldestPlayedSoundtracks(ctx, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []GameWithSongs
|
var result []SoundtrackWithSongs
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var songs []SongInfoForStats
|
var songs []SongInfoForStats
|
||||||
if row.Songs != nil {
|
if row.Songs != nil {
|
||||||
@@ -235,7 +235,7 @@ func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs,
|
|||||||
songs = make([]SongInfoForStats, 0)
|
songs = make([]SongInfoForStats, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, GameWithSongs{
|
result = append(result, SoundtrackWithSongs{
|
||||||
SoundtrackID: row.SoundtrackID,
|
SoundtrackID: row.SoundtrackID,
|
||||||
SoundtrackName: row.SoundtrackName,
|
SoundtrackName: row.SoundtrackName,
|
||||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
@@ -257,13 +257,13 @@ func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &StatisticsSummary{
|
return &StatisticsSummary{
|
||||||
TotalGames: int64(row.TotalSoundtracks),
|
TotalSoundtracks: int64(row.TotalSoundtracks),
|
||||||
PlayedGames: int64(row.PlayedSoundtracks),
|
PlayedSoundtracks: int64(row.PlayedSoundtracks),
|
||||||
NeverPlayedGames: int64(row.NeverPlayedSoundtracks),
|
NeverPlayedSoundtracks: int64(row.NeverPlayedSoundtracks),
|
||||||
TotalGamePlays: int64(row.TotalSoundtrackPlays),
|
TotalSoundtrackPlays: int64(row.TotalSoundtrackPlays),
|
||||||
AvgGamePlays: float64(row.AvgSoundtrackPlays),
|
AvgSoundtrackPlays: float64(row.AvgSoundtrackPlays),
|
||||||
MaxGamePlays: int64(row.MaxSoundtrackPlays),
|
MaxSoundtrackPlays: int64(row.MaxSoundtrackPlays),
|
||||||
MinGamePlays: int64(row.MinSoundtrackPlays),
|
MinSoundtrackPlays: int64(row.MinSoundtrackPlays),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+195
-167
@@ -16,6 +16,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/panjf2000/ants/v2"
|
"github.com/panjf2000/ants/v2"
|
||||||
|
|
||||||
"github.com/MShekow/directory-checksum/directory_checksum"
|
"github.com/MShekow/directory-checksum/directory_checksum"
|
||||||
@@ -30,14 +31,14 @@ var start time.Time
|
|||||||
var totalTime time.Duration
|
var totalTime time.Duration
|
||||||
var timeSpent time.Duration
|
var timeSpent time.Duration
|
||||||
|
|
||||||
var allGames []repository.Soundtrack
|
var allSoundtracks []repository.Soundtrack
|
||||||
var gamesBeforeSync []repository.Soundtrack
|
var soundtracksBeforeSync []repository.Soundtrack
|
||||||
var gamesAfterSync []repository.Soundtrack
|
var soundtracksAfterSync []repository.Soundtrack
|
||||||
var gamesAdded []string
|
var soundtracksAdded []string
|
||||||
var gamesReAdded []string
|
var soundtracksReAdded []string
|
||||||
var gamesChangedTitle map[string]string
|
var soundtracksChangedTitle map[string]string
|
||||||
var gamesChangedContent []string
|
var soundtracksChangedContent []string
|
||||||
var gamesRemoved []string
|
var soundtracksRemoved []string
|
||||||
var catchedErrors []string
|
var catchedErrors []string
|
||||||
|
|
||||||
type brokenSong struct {
|
type brokenSong struct {
|
||||||
@@ -50,11 +51,11 @@ var pool *ants.Pool
|
|||||||
var poolSong *ants.Pool
|
var poolSong *ants.Pool
|
||||||
|
|
||||||
type SyncResponse struct {
|
type SyncResponse struct {
|
||||||
GamesAdded []string `json:"games_added"`
|
SoundtracksAdded []string `json:"soundtracks_added"`
|
||||||
GamesReAdded []string `json:"games_re_added"`
|
SoundtracksReAdded []string `json:"soundtracks_re_added"`
|
||||||
GamesChangedTitle map[string]string `json:"games_changed_title"`
|
SoundtracksChangedTitle map[string]string `json:"soundtracks_changed_title"`
|
||||||
GamesChangedContent []string `json:"games_changed_content"`
|
SoundtracksChangedContent []string `json:"soundtracks_changed_content"`
|
||||||
GamesRemoved []string `json:"games_removed"`
|
SoundtracksRemoved []string `json:"soundtracks_removed"`
|
||||||
CatchedErrors []string `json:"catched_errors"`
|
CatchedErrors []string `json:"catched_errors"`
|
||||||
TotalTime string `json:"total_time"`
|
TotalTime string `json:"total_time"`
|
||||||
}
|
}
|
||||||
@@ -64,24 +65,24 @@ type ProgressResponse struct {
|
|||||||
TimeSpent string `json:"time_spent"`
|
TimeSpent string `json:"time_spent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameStatus int
|
type SoundtrackStatus int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NotChanged GameStatus = iota
|
NotChanged SoundtrackStatus = iota
|
||||||
TitleChanged
|
TitleChanged
|
||||||
GameChanged
|
SoundtrackChanged
|
||||||
NewGame
|
NewSoundtrack
|
||||||
)
|
)
|
||||||
|
|
||||||
var statusName = map[GameStatus]string{
|
var statusName = map[SoundtrackStatus]string{
|
||||||
NotChanged: "Not changed",
|
NotChanged: "Not changed",
|
||||||
TitleChanged: "Title changed",
|
TitleChanged: "Title changed",
|
||||||
GameChanged: "Game changed",
|
SoundtrackChanged: "Soundtrack changed",
|
||||||
NewGame: "New game",
|
NewSoundtrack: "New soundtrack",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gs GameStatus) String() string {
|
func (ss SoundtrackStatus) String() string {
|
||||||
return statusName[gs]
|
return statusName[ss]
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResetDB() {
|
func ResetDB() {
|
||||||
@@ -107,54 +108,54 @@ func SyncProgress() ProgressResponse {
|
|||||||
|
|
||||||
func SyncResult() SyncResponse {
|
func SyncResult() SyncResponse {
|
||||||
logging.GetLogger().Info("Sync completed",
|
logging.GetLogger().Info("Sync completed",
|
||||||
zap.Int("games_before", len(gamesBeforeSync)),
|
zap.Int("soundtracks_before", len(soundtracksBeforeSync)),
|
||||||
zap.Int("games_after", len(gamesAfterSync)))
|
zap.Int("soundtracks_after", len(soundtracksAfterSync)))
|
||||||
|
|
||||||
if len(gamesAdded) > 0 {
|
if len(soundtracksAdded) > 0 {
|
||||||
logging.GetLogger().Debug("Games added", zap.Strings("games", gamesAdded))
|
logging.GetLogger().Debug("Soundtracks added", zap.Strings("soundtracks", soundtracksAdded))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(gamesReAdded) > 0 {
|
if len(soundtracksReAdded) > 0 {
|
||||||
logging.GetLogger().Debug("Games readded", zap.Strings("games", gamesReAdded))
|
logging.GetLogger().Debug("Soundtracks readded", zap.Strings("soundtracks", soundtracksReAdded))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(gamesChangedTitle) > 0 {
|
if len(soundtracksChangedTitle) > 0 {
|
||||||
logging.GetLogger().Debug("Games with changed title", zap.Any("changes", gamesChangedTitle))
|
logging.GetLogger().Debug("Soundtracks with changed title", zap.Any("changes", soundtracksChangedTitle))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(gamesChangedContent) > 0 {
|
if len(soundtracksChangedContent) > 0 {
|
||||||
logging.GetLogger().Debug("Games with changed content", zap.Strings("games", gamesChangedContent))
|
logging.GetLogger().Debug("Soundtracks with changed content", zap.Strings("soundtracks", soundtracksChangedContent))
|
||||||
}
|
}
|
||||||
|
|
||||||
var gamesRemovedTemp []string
|
var soundtracksRemovedTemp []string
|
||||||
for _, beforeGame := range gamesBeforeSync {
|
for _, beforeSoundtrack := range soundtracksBeforeSync {
|
||||||
var found = false
|
var found = false
|
||||||
for _, afterGame := range gamesAfterSync {
|
for _, afterSoundtrack := range soundtracksAfterSync {
|
||||||
if beforeGame.SoundtrackName == afterGame.SoundtrackName {
|
if beforeSoundtrack.SoundtrackName == afterSoundtrack.SoundtrackName {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.SoundtrackName)
|
soundtracksRemovedTemp = append(soundtracksRemovedTemp, beforeSoundtrack.SoundtrackName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, game := range gamesRemovedTemp {
|
for _, soundtrack := range soundtracksRemovedTemp {
|
||||||
var found bool = false
|
var found bool = false
|
||||||
for key := range gamesChangedTitle {
|
for key := range soundtracksChangedTitle {
|
||||||
if game == key {
|
if soundtrack == key {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
gamesRemoved = append(gamesRemoved, game)
|
soundtracksRemoved = append(soundtracksRemoved, soundtrack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(gamesRemoved) > 0 {
|
if len(soundtracksRemoved) > 0 {
|
||||||
logging.GetLogger().Debug("Games removed", zap.Strings("games", gamesRemoved))
|
logging.GetLogger().Debug("Soundtracks removed", zap.Strings("soundtracks", soundtracksRemoved))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(catchedErrors) > 0 {
|
if len(catchedErrors) > 0 {
|
||||||
@@ -165,29 +166,29 @@ func SyncResult() SyncResponse {
|
|||||||
logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000")))
|
logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000")))
|
||||||
|
|
||||||
return SyncResponse{
|
return SyncResponse{
|
||||||
GamesAdded: gamesAdded,
|
SoundtracksAdded: soundtracksAdded,
|
||||||
GamesReAdded: gamesReAdded,
|
SoundtracksReAdded: soundtracksReAdded,
|
||||||
GamesChangedTitle: gamesChangedTitle,
|
SoundtracksChangedTitle: soundtracksChangedTitle,
|
||||||
GamesChangedContent: gamesChangedContent,
|
SoundtracksChangedContent: soundtracksChangedContent,
|
||||||
GamesRemoved: gamesRemoved,
|
SoundtracksRemoved: soundtracksRemoved,
|
||||||
CatchedErrors: catchedErrors,
|
CatchedErrors: catchedErrors,
|
||||||
TotalTime: out.Format("15:04:05"),
|
TotalTime: out.Format("15:04:05"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncSoundtracksNewFull() {
|
func SyncSoundtracksFull() {
|
||||||
syncGamesNew(true)
|
syncSoundtracks(true)
|
||||||
Reset()
|
Reset()
|
||||||
|
ReloadSearchCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncSoundtracksNewOnlyChanges() {
|
func SyncSoundtracksOnlyChanges() {
|
||||||
syncGamesNew(false)
|
syncSoundtracks(false)
|
||||||
Reset()
|
Reset()
|
||||||
|
ReloadSearchCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncGamesNew(full bool) {
|
func syncSoundtracks(full bool) {
|
||||||
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))
|
||||||
@@ -199,23 +200,23 @@ func syncGamesNew(full bool) {
|
|||||||
|
|
||||||
initRepo()
|
initRepo()
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
foldersToSkip := []string{".sync", "dist", "old", "characters"}
|
foldersToSkip := []string{".sync", "characters", "dist", "old"}
|
||||||
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
|
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
gamesAdded = nil
|
soundtracksAdded = nil
|
||||||
gamesReAdded = nil
|
soundtracksReAdded = nil
|
||||||
gamesChangedTitle = nil
|
soundtracksChangedTitle = nil
|
||||||
gamesChangedContent = nil
|
soundtracksChangedContent = nil
|
||||||
gamesRemoved = nil
|
soundtracksRemoved = nil
|
||||||
catchedErrors = nil
|
catchedErrors = nil
|
||||||
brokenSongs = nil
|
brokenSongs = nil
|
||||||
|
|
||||||
gamesBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
|
soundtracksBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
|
||||||
handleError("FindAllSoundtracks Before", err, "")
|
handleError("FindAllSoundtracks Before", err, "")
|
||||||
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
|
logging.GetLogger().Info("Starting sync", zap.Int("soundtracks_before", len(soundtracksBeforeSync)))
|
||||||
|
|
||||||
allGames, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx())
|
allSoundtracks, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx())
|
||||||
handleError("GetAllSoundtracksIncludingDeleted", err, "")
|
handleError("GetAllSoundtracksIncludingDeleted", err, "")
|
||||||
err = repo.SetSoundtrackDeletionDate(BackendCtx())
|
err = repo.SetSoundtrackDeletionDate(BackendCtx())
|
||||||
handleError("SetSoundtrackDeletionDate", err, "")
|
handleError("SetSoundtrackDeletionDate", err, "")
|
||||||
@@ -224,7 +225,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()
|
||||||
@@ -236,13 +236,13 @@ func syncGamesNew(full bool) {
|
|||||||
for _, dir := range directories {
|
for _, dir := range directories {
|
||||||
pool.Submit(func() {
|
pool.Submit(func() {
|
||||||
defer syncWg.Done()
|
defer syncWg.Done()
|
||||||
syncGameNew(dir, foldersToSkip, musicPath, full)
|
syncSoundtrack(dir, foldersToSkip, musicPath, full)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
syncWg.Wait()
|
syncWg.Wait()
|
||||||
checkBrokenSongsNew()
|
checkBrokenSongs()
|
||||||
|
|
||||||
gamesAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
|
soundtracksAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
|
||||||
handleError("FindAllSoundtracks After", err, "")
|
handleError("FindAllSoundtracks After", err, "")
|
||||||
|
|
||||||
finished := time.Now()
|
finished := time.Now()
|
||||||
@@ -253,7 +253,7 @@ func syncGamesNew(full bool) {
|
|||||||
Syncing = false
|
Syncing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkBrokenSongsNew() {
|
func checkBrokenSongs() {
|
||||||
allSongs, err := repo.FetchAllSongs(BackendCtx())
|
allSongs, err := repo.FetchAllSongs(BackendCtx())
|
||||||
handleError("FetchAllSongs", err, "")
|
handleError("FetchAllSongs", err, "")
|
||||||
var brokenWg sync.WaitGroup
|
var brokenWg sync.WaitGroup
|
||||||
@@ -264,7 +264,7 @@ func checkBrokenSongsNew() {
|
|||||||
for _, song := range allSongs {
|
for _, song := range allSongs {
|
||||||
poolBroken.Submit(func() {
|
poolBroken.Submit(func() {
|
||||||
defer brokenWg.Done()
|
defer brokenWg.Done()
|
||||||
checkBrokenSongNew(song)
|
checkBrokenSong(song)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
brokenWg.Wait()
|
brokenWg.Wait()
|
||||||
@@ -274,7 +274,7 @@ func checkBrokenSongsNew() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkBrokenSongNew(song repository.Song) {
|
func checkBrokenSong(song repository.Song) {
|
||||||
//Check if file exists and open
|
//Check if file exists and open
|
||||||
openFile, err := os.Open(song.Path)
|
openFile, err := os.Open(song.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,118 +289,93 @@ func checkBrokenSongNew(song repository.Song) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) {
|
func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) {
|
||||||
if file.IsDir() && !contains(foldersToSkip, file.Name()) {
|
if file.IsDir() && !contains(foldersToSkip, file.Name()) {
|
||||||
logging.GetLogger().Debug("Syncing game", zap.String("game", file.Name()))
|
logging.GetLogger().Debug("Syncing soundtrack", zap.String("soundtrack", file.Name()))
|
||||||
gameDir := baseDir + file.Name() + "/"
|
soundtrackDir := baseDir + file.Name() + "/"
|
||||||
dirHash := getHashForDir(gameDir)
|
dirHash := getHashForDir(soundtrackDir)
|
||||||
|
|
||||||
var status GameStatus = NewGame
|
var status SoundtrackStatus = NewSoundtrack
|
||||||
var oldGame repository.Soundtrack
|
var oldSoundtrack repository.Soundtrack
|
||||||
var id int32 = -1
|
var id int32 = -1
|
||||||
|
var soundtrackUuid uuid.UUID
|
||||||
|
|
||||||
//fmt.Printf("Games before: %d\n", len(gamesBeforeSync))
|
//fmt.Printf("Soundtracks before: %d\n", len(soundtracksBeforeSync))
|
||||||
|
|
||||||
for _, currentGame := range allGames {
|
for _, currentSoundtrack := range allSoundtracks {
|
||||||
oldGame = currentGame
|
oldSoundtrack = currentSoundtrack
|
||||||
//fmt.Printf("%s | %s\n", oldGame.SoundtrackName, oldGame.Hash)
|
//fmt.Printf("%s | %s\n", oldSoundtrack.SoundtrackName, oldSoundtrack.Hash)
|
||||||
if oldGame.SoundtrackName == file.Name() && oldGame.Hash == dirHash {
|
if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash == dirHash {
|
||||||
status = NotChanged
|
status = NotChanged
|
||||||
id = oldGame.ID
|
id = oldSoundtrack.ID
|
||||||
//fmt.Printf("Game not changed\n")
|
soundtrackUuid = oldSoundtrack.Uuid
|
||||||
|
//fmt.Printf("Soundtrack not changed\n")
|
||||||
break
|
break
|
||||||
} else if oldGame.SoundtrackName == file.Name() && oldGame.Hash != dirHash {
|
} else if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash != dirHash {
|
||||||
status = GameChanged
|
status = SoundtrackChanged
|
||||||
id = oldGame.ID
|
id = oldSoundtrack.ID
|
||||||
//fmt.Printf("Game changed\n")
|
soundtrackUuid = oldSoundtrack.Uuid
|
||||||
|
//fmt.Printf("Soundtrack changed\n")
|
||||||
break
|
break
|
||||||
} else if oldGame.SoundtrackName != file.Name() && oldGame.Hash == dirHash {
|
} else if oldSoundtrack.SoundtrackName != file.Name() && oldSoundtrack.Hash == dirHash {
|
||||||
status = TitleChanged
|
status = TitleChanged
|
||||||
id = oldGame.ID
|
id = oldSoundtrack.ID
|
||||||
|
soundtrackUuid = oldSoundtrack.Uuid
|
||||||
//fmt.Printf("SoundtrackName changed\n")
|
//fmt.Printf("SoundtrackName changed\n")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if full {
|
if full && status != NewSoundtrack {
|
||||||
status = TitleChanged
|
status = TitleChanged
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(gameDir)
|
entries, err := os.ReadDir(soundtrackDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to read game directory", zap.String("path", gameDir), zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to read soundtrack directory", zap.String("path", soundtrackDir), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
switch status {
|
switch status {
|
||||||
case NewGame:
|
case NewSoundtrack:
|
||||||
if id != -1 {
|
id, soundtrackUuid = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
|
||||||
for _, entry := range entries {
|
logging.GetLogger().Debug("New soundtrack detected",
|
||||||
fileInfo, err := entry.Info()
|
|
||||||
if err != nil {
|
|
||||||
logging.GetLogger().Error("Failed to get file info", zap.String("error", err.Error()))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id = getIdFromFileNew(fileInfo)
|
|
||||||
if id != -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, SoundtrackName: file.Name(), Path: gameDir, Hash: dirHash})
|
|
||||||
handleError("InsertSoundtrackWithExistingId", err, "")
|
|
||||||
if err != nil {
|
|
||||||
logging.GetLogger().Debug("Game already exists, removing old ID file",
|
|
||||||
zap.Int32("id", id),
|
zap.Int32("id", id),
|
||||||
zap.String("game_dir", gameDir))
|
zap.String("uuid", soundtrackUuid.String()),
|
||||||
fileName := gameDir + "/." + strconv.Itoa(int(id)) + ".id"
|
zap.String("soundtrack", file.Name()),
|
||||||
logging.GetLogger().Debug("Removing ID file", zap.String("filename", fileName))
|
|
||||||
|
|
||||||
err := os.Remove(fileName)
|
|
||||||
if err != nil {
|
|
||||||
logging.GetLogger().Error("Failed to remove ID file", zap.String("filename", fileName), zap.String("error", err.Error()))
|
|
||||||
}
|
|
||||||
|
|
||||||
newDirHash := getHashForDir(gameDir)
|
|
||||||
|
|
||||||
id = insertGameNew(file.Name(), gameDir, newDirHash)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
id = insertGameNew(file.Name(), gameDir, dirHash)
|
|
||||||
}
|
|
||||||
logging.GetLogger().Debug("New game detected",
|
|
||||||
zap.Int32("id", id),
|
|
||||||
zap.String("game", file.Name()),
|
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
gamesAdded = append(gamesAdded, file.Name())
|
soundtracksAdded = append(soundtracksAdded, file.Name())
|
||||||
newCheckSongs(entries, gameDir, id)
|
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||||
case GameChanged:
|
case SoundtrackChanged:
|
||||||
logging.GetLogger().Debug("Game changed",
|
logging.GetLogger().Debug("Soundtrack changed",
|
||||||
zap.Int32("id", id),
|
zap.Int32("id", id),
|
||||||
zap.String("game", file.Name()),
|
zap.String("uuid", soundtrackUuid.String()),
|
||||||
|
zap.String("soundtrack", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
|
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
|
||||||
handleError("UpdateSoundtrackHash", err, "")
|
handleError("UpdateSoundtrackHash", err, "")
|
||||||
gamesChangedContent = append(gamesChangedContent, file.Name())
|
soundtracksChangedContent = append(soundtracksChangedContent, file.Name())
|
||||||
newCheckSongs(entries, gameDir, id)
|
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||||
case TitleChanged:
|
case TitleChanged:
|
||||||
logging.GetLogger().Debug("Game title changed",
|
logging.GetLogger().Debug("Soundtrack title changed",
|
||||||
zap.Int32("id", id),
|
zap.Int32("id", id),
|
||||||
zap.String("oldName", oldGame.SoundtrackName),
|
zap.String("uuid", soundtrackUuid.String()),
|
||||||
|
zap.String("oldName", oldSoundtrack.SoundtrackName),
|
||||||
zap.String("newName", file.Name()),
|
zap.String("newName", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id})
|
||||||
handleError("UpdateSoundtrackName", err, "")
|
handleError("UpdateSoundtrackName", err, "")
|
||||||
newCheckSongs(entries, gameDir, id)
|
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||||
if gamesChangedTitle == nil {
|
if soundtracksChangedTitle == nil {
|
||||||
gamesChangedTitle = make(map[string]string)
|
soundtracksChangedTitle = make(map[string]string)
|
||||||
}
|
}
|
||||||
gamesChangedTitle[oldGame.SoundtrackName] = file.Name()
|
soundtracksChangedTitle[oldSoundtrack.SoundtrackName] = file.Name()
|
||||||
case NotChanged:
|
case NotChanged:
|
||||||
var found bool = false
|
var found bool = false
|
||||||
for _, beforeGame := range gamesBeforeSync {
|
for _, beforeSoundtrack := range soundtracksBeforeSync {
|
||||||
if dirHash == beforeGame.Hash {
|
if dirHash == beforeSoundtrack.Hash {
|
||||||
found = true
|
found = true
|
||||||
logging.GetLogger().Debug("Game not changed",
|
logging.GetLogger().Debug("Soundtrack not changed",
|
||||||
zap.Int32("id", id),
|
zap.Int32("id", id),
|
||||||
zap.String("newName", file.Name()),
|
zap.String("newName", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
@@ -408,23 +383,33 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
newCheckSongs(entries, gameDir, id)
|
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||||
gamesReAdded = append(gamesReAdded, file.Name())
|
soundtracksReAdded = append(soundtracksReAdded, file.Name())
|
||||||
logging.GetLogger().Debug("Game added again",
|
logging.GetLogger().Debug("Soundtrack added again",
|
||||||
zap.Int32("id", id),
|
zap.Int32("id", id),
|
||||||
|
zap.String("uuid", soundtrackUuid.String()),
|
||||||
zap.String("newName", file.Name()),
|
zap.String("newName", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logging.GetLogger().Debug("Game sync status",
|
logging.GetLogger().Debug("Soundtrack sync status",
|
||||||
zap.Int32("id", id),
|
zap.Int32("id", id),
|
||||||
zap.String("game", file.Name()),
|
zap.String("soundtrack", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
|
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
|
||||||
handleError("RemoveSoundtrackDeletionDate", err, "")
|
handleError("RemoveSoundtrackDeletionDate", err, "")
|
||||||
|
|
||||||
|
// Remove .id files and update hash if any were removed
|
||||||
|
if removeIdFilesInSoundtrack(soundtrackDir) {
|
||||||
|
newHash := getHashForDir(soundtrackDir)
|
||||||
|
if newHash != dirHash {
|
||||||
|
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: newHash, ID: id})
|
||||||
|
handleError("UpdateSoundtrackHash for .id cleanup", err, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
foldersSynced++
|
foldersSynced++
|
||||||
logging.GetLogger().Debug("Sync progress",
|
logging.GetLogger().Debug("Sync progress",
|
||||||
@@ -433,24 +418,31 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100)))
|
zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertGameNew(name string, path string, hash string) int32 {
|
func insertSoundtrack(name string, path string, hash string) (int32, uuid.UUID) {
|
||||||
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
||||||
id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash})
|
soundtrackUuid := uuid.New()
|
||||||
|
result, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{
|
||||||
|
SoundtrackName: name,
|
||||||
|
Path: path,
|
||||||
|
Hash: hash,
|
||||||
|
Uuid: soundtrackUuid,
|
||||||
|
})
|
||||||
handleError("InsertSoundtrack", err, "")
|
handleError("InsertSoundtrack", err, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
logging.GetLogger().Warn("ID collision or UUID duplicate detected, resetting sequence")
|
||||||
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
||||||
logging.GetLogger().Debug("Resetting game ID sequence")
|
logging.GetLogger().Debug("Resetting soundtrack ID sequence")
|
||||||
_, err = repo.ResetSoundtrackIdSeq(BackendCtx())
|
_, err = repo.ResetSoundtrackIdSeq(BackendCtx())
|
||||||
handleError("ResetSoundtrackIdSeq", err, "")
|
handleError("ResetSoundtrackIdSeq", err, "")
|
||||||
id = insertGameNew(name, path, hash)
|
id, uuid := insertSoundtrack(name, path, hash)
|
||||||
|
return id, uuid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return id
|
return result.ID, result.Uuid
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
|
func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) int32 {
|
||||||
//hasher := md5.New()
|
//hasher := md5.New()
|
||||||
var numberOfSongs int32
|
var numberOfSongs int32
|
||||||
numberOfFiles := len(entries)
|
numberOfFiles := len(entries)
|
||||||
@@ -460,7 +452,7 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
poolSong.Submit(func() {
|
poolSong.Submit(func() {
|
||||||
defer songWg.Done()
|
defer songWg.Done()
|
||||||
if newCheckSong(entry, gameDir, id) {
|
if checkSong(entry, soundtrackDir, id, soundtrackUuid) {
|
||||||
numberOfSongs++
|
numberOfSongs++
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -469,7 +461,7 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
|
|||||||
return numberOfSongs
|
return numberOfSongs
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
func checkSong(entry os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) bool {
|
||||||
fileInfo, err := entry.Info()
|
fileInfo, err := entry.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
|
||||||
@@ -477,7 +469,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isSong(fileInfo) {
|
if isSong(fileInfo) {
|
||||||
path := gameDir + entry.Name()
|
path := soundtrackDir + entry.Name()
|
||||||
|
|
||||||
songHash := getHashForFile(path)
|
songHash := getHashForFile(path)
|
||||||
//numberOfSongs++
|
//numberOfSongs++
|
||||||
@@ -493,7 +485,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
logging.GetLogger().Debug("Song changed",
|
logging.GetLogger().Debug("Song changed",
|
||||||
zap.Int32("game_id", id),
|
zap.Int32("soundtrack_id", id),
|
||||||
zap.String("path", path),
|
zap.String("path", path),
|
||||||
zap.String("song_name", songName),
|
zap.String("song_name", songName),
|
||||||
zap.String("song_hash", songHash))
|
zap.String("song_hash", songHash))
|
||||||
@@ -522,8 +514,17 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
|||||||
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path})
|
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path})
|
||||||
handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
} else {
|
} else {
|
||||||
err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
songUuid := uuid.New()
|
||||||
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
err = repo.AddSong(BackendCtx(), repository.AddSongParams{
|
||||||
|
SoundtrackID: id,
|
||||||
|
SongName: songName,
|
||||||
|
Path: path,
|
||||||
|
FileName: &fileName,
|
||||||
|
Hash: songHash,
|
||||||
|
Uuid: songUuid,
|
||||||
|
SoundtrackUuid: soundtrackUuid,
|
||||||
|
})
|
||||||
|
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | UUID: %s | Path: %s | SongName: %s | SongHash: %s", id, songUuid.String(), path, entry.Name(), songHash))
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,8 +552,8 @@ func handleError(funcName string, err error, msg string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHashForDir(gameDir string) string {
|
func getHashForDir(soundtrackDir string) string {
|
||||||
directory, _ := directory_checksum.ScanDirectory(gameDir, afero.NewOsFs())
|
directory, _ := directory_checksum.ScanDirectory(soundtrackDir, afero.NewOsFs())
|
||||||
hash, _ := directory.ComputeDirectoryChecksums()
|
hash, _ := directory.ComputeDirectoryChecksums()
|
||||||
|
|
||||||
return hash
|
return hash
|
||||||
@@ -573,7 +574,7 @@ func getHashForFile(path string) string {
|
|||||||
return hex.EncodeToString(hasher.Sum(nil))
|
return hex.EncodeToString(hasher.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getIdFromFileNew(file os.FileInfo) int32 {
|
func getIdFromFile(file os.FileInfo) int32 {
|
||||||
name := file.Name()
|
name := file.Name()
|
||||||
if !file.IsDir() && strings.HasSuffix(name, ".id") {
|
if !file.IsDir() && strings.HasSuffix(name, ".id") {
|
||||||
name = strings.Replace(name, ".id", "", 1)
|
name = strings.Replace(name, ".id", "", 1)
|
||||||
@@ -593,6 +594,33 @@ func isCoverImage(entry fs.FileInfo) bool {
|
|||||||
(strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".png"))
|
(strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeIdFilesInSoundtrack(soundtrackDir string) bool {
|
||||||
|
entries, err := os.ReadDir(soundtrackDir)
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to read soundtrack directory for .id file cleanup",
|
||||||
|
zap.String("path", soundtrackDir),
|
||||||
|
zap.String("error", err.Error()))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
anyRemoved := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".id") {
|
||||||
|
filePath := soundtrackDir + entry.Name()
|
||||||
|
err := os.Remove(filePath)
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to remove .id file",
|
||||||
|
zap.String("path", filePath),
|
||||||
|
zap.String("error", err.Error()))
|
||||||
|
} else {
|
||||||
|
logging.GetLogger().Info("Removed .id file", zap.String("path", filePath))
|
||||||
|
anyRemoved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return anyRemoved
|
||||||
|
}
|
||||||
|
|
||||||
func contains(s []string, searchTerm string) bool {
|
func contains(s []string, searchTerm string) bool {
|
||||||
i := sort.SearchStrings(s, searchTerm)
|
i := sort.SearchStrings(s, searchTerm)
|
||||||
return i < len(s) && s[i] == searchTerm
|
return i < len(s) && s[i] == searchTerm
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ func TestGetIdFromFileNew(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := getIdFromFileNew(tt.fileInfo)
|
result := getIdFromFile(tt.fileInfo)
|
||||||
if result != tt.expected {
|
if result != tt.expected {
|
||||||
t.Errorf("getIdFromFileNew() = %v, want %v", result, tt.expected)
|
t.Errorf("getIdFromFile() = %v, want %v", result, tt.expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ type VersionData struct {
|
|||||||
|
|
||||||
var data = []VersionData{
|
var data = []VersionData{
|
||||||
{
|
{
|
||||||
Version: "5.0.0-Beta",
|
Version: "5.0.0",
|
||||||
Changelog: []string{
|
Changelog: []string{
|
||||||
|
"#9 - Major routes refactor and expansion",
|
||||||
|
"#15 - Added API for statistics",
|
||||||
"#16 - Upgrade Echo framework from v4 to v5",
|
"#16 - Upgrade Echo framework from v4 to v5",
|
||||||
"#17 - Add Zap structured logging framework",
|
"#17 - Add Zap structured logging framework",
|
||||||
"#18 - Add OpenAPI/Swagger documentation",
|
"#18 - Add OpenAPI/Swagger documentation",
|
||||||
@@ -17,6 +19,8 @@ var data = []VersionData{
|
|||||||
"#21 - Refactor handlers into domain-specific files",
|
"#21 - Refactor handlers into domain-specific files",
|
||||||
"#22 - Change VersionData Changelog from string to string array",
|
"#22 - Change VersionData Changelog from string to string array",
|
||||||
"#23 - Update all dependencies to latest versions",
|
"#23 - Update all dependencies to latest versions",
|
||||||
|
"Added search cache backend",
|
||||||
|
"Backend sync improvements",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ func CloseDb() {
|
|||||||
Dbpool.Close()
|
Dbpool.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResetGameIdSeq() {
|
func ResetSoundtrackIdSeq() {
|
||||||
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);")
|
_, err := Dbpool.Query(Ctx, "SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to reset game ID sequence", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to reset soundtrack ID sequence", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
if err != nil && err != migrate.ErrNilVersion {
|
||||||
require.NoError(t, err)
|
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()
|
||||||
|
if err != nil && err != migrate.ErrNilVersion {
|
||||||
require.NoError(t, err)
|
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)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- Rollback: Remove UUID columns and revert rename
|
||||||
|
|
||||||
|
-- Step 1: Drop UUID-related indexes
|
||||||
|
DROP INDEX IF EXISTS idx_song_uuid;
|
||||||
|
DROP INDEX IF EXISTS idx_song_soundtrack_uuid;
|
||||||
|
DROP INDEX IF EXISTS idx_soundtrack_uuid;
|
||||||
|
DROP INDEX IF EXISTS idx_song_soundtrack_id;
|
||||||
|
DROP INDEX IF EXISTS idx_song_path;
|
||||||
|
|
||||||
|
-- Step 1.5: Remove NOT NULL constraints before dropping columns (Issue #28 rollback)
|
||||||
|
ALTER TABLE song ALTER COLUMN soundtrack_uuid DROP NOT NULL;
|
||||||
|
ALTER TABLE song ALTER COLUMN uuid DROP NOT NULL;
|
||||||
|
ALTER TABLE soundtrack ALTER COLUMN uuid DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Step 2: Drop UUID-related constraints
|
||||||
|
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_path_unique;
|
||||||
|
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_uuid_fkey;
|
||||||
|
ALTER TABLE soundtrack DROP CONSTRAINT IF EXISTS soundtrack_uuid_unique;
|
||||||
|
|
||||||
|
-- Step 3: Drop UUID columns from song table
|
||||||
|
ALTER TABLE song DROP COLUMN IF EXISTS soundtrack_uuid;
|
||||||
|
ALTER TABLE song DROP COLUMN IF EXISTS uuid;
|
||||||
|
|
||||||
|
-- Step 4: Drop UUID column from soundtrack table
|
||||||
|
ALTER TABLE soundtrack DROP COLUMN IF EXISTS uuid;
|
||||||
|
|
||||||
|
-- Step 5: Drop the uuid-based primary key
|
||||||
|
ALTER TABLE song DROP CONSTRAINT song_pkey;
|
||||||
|
|
||||||
|
-- Step 6: Restore composite primary key on song
|
||||||
|
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (soundtrack_id, path);
|
||||||
|
|
||||||
|
-- Step 7: Drop foreign key constraint
|
||||||
|
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
||||||
|
|
||||||
|
-- Step 8: Revert rename operations
|
||||||
|
-- Revert song_list table references
|
||||||
|
ALTER TABLE song_list RENAME COLUMN soundtrack_name TO game_name;
|
||||||
|
|
||||||
|
-- Revert song table: rename soundtrack_id back to game_id
|
||||||
|
ALTER TABLE song RENAME COLUMN soundtrack_id TO game_id;
|
||||||
|
|
||||||
|
-- Revert columns in game table
|
||||||
|
ALTER TABLE game RENAME COLUMN soundtrack_name TO game_name;
|
||||||
|
|
||||||
|
-- Revert primary key sequence
|
||||||
|
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
|
||||||
|
|
||||||
|
-- Revert: Rename soundtrack table back to game
|
||||||
|
ALTER TABLE soundtrack RENAME TO game;
|
||||||
|
|
||||||
|
-- Revert foreign key constraint
|
||||||
|
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey;
|
||||||
|
ALTER TABLE song ADD CONSTRAINT song_game_id_fkey
|
||||||
|
FOREIGN KEY (game_id) REFERENCES game(id);
|
||||||
|
|
||||||
|
-- Revert indexes
|
||||||
|
ALTER INDEX IF EXISTS idx_soundtrack_deleted RENAME TO idx_game_deleted;
|
||||||
|
ALTER INDEX IF EXISTS idx_soundtrack_hash RENAME TO idx_game_hash;
|
||||||
|
ALTER INDEX IF EXISTS idx_soundtrack_path RENAME TO idx_game_path;
|
||||||
|
ALTER INDEX IF EXISTS idx_soundtrack_name RENAME TO idx_game_name;
|
||||||
|
ALTER INDEX IF EXISTS idx_song_soundtrack_id RENAME TO idx_song_game_id;
|
||||||
|
ALTER INDEX IF EXISTS idx_song_soundtrack_id_song_name RENAME TO idx_song_game_id_song_name;
|
||||||
|
ALTER INDEX IF EXISTS song_list_soundtrack_name_idx RENAME TO song_list_game_name_idx;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
-- Migration: Rename game to soundtrack AND add UUID columns
|
||||||
|
-- Combines original migrations 000005 (rename), 000006 (UUID columns), and Issue #28 (Phase 2: NOT NULL constraints)
|
||||||
|
-- Phase 1 & 2: Dual-write with required UUIDs (integer IDs still exist alongside UUIDs)
|
||||||
|
|
||||||
|
-- ============ PART 1: RENAME (from original migration 000005) ============
|
||||||
|
|
||||||
|
-- Rename game table to soundtrack
|
||||||
|
ALTER TABLE game RENAME TO soundtrack;
|
||||||
|
|
||||||
|
-- Rename primary key sequence
|
||||||
|
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
|
||||||
|
|
||||||
|
-- Rename columns in soundtrack table
|
||||||
|
ALTER TABLE soundtrack RENAME COLUMN game_name TO soundtrack_name;
|
||||||
|
|
||||||
|
-- Update song table: rename game_id to soundtrack_id
|
||||||
|
ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id;
|
||||||
|
|
||||||
|
-- Update song_list table references
|
||||||
|
ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name;
|
||||||
|
|
||||||
|
-- Rename foreign key constraint
|
||||||
|
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey;
|
||||||
|
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
||||||
|
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
||||||
|
|
||||||
|
-- Rename indexes
|
||||||
|
ALTER INDEX IF EXISTS idx_game_deleted RENAME TO idx_soundtrack_deleted;
|
||||||
|
ALTER INDEX IF EXISTS idx_game_hash RENAME TO idx_soundtrack_hash;
|
||||||
|
ALTER INDEX IF EXISTS idx_game_path RENAME TO idx_soundtrack_path;
|
||||||
|
ALTER INDEX IF EXISTS idx_game_name RENAME TO idx_soundtrack_name;
|
||||||
|
ALTER INDEX IF EXISTS idx_song_game_id RENAME TO idx_song_soundtrack_id;
|
||||||
|
ALTER INDEX IF EXISTS idx_song_game_id_song_name RENAME TO idx_song_soundtrack_id_song_name;
|
||||||
|
ALTER INDEX IF EXISTS song_list_game_name_idx RENAME TO song_list_soundtrack_name_idx;
|
||||||
|
|
||||||
|
-- ============ PART 2: UUID COLUMNS (from original migration 000006) ============
|
||||||
|
|
||||||
|
-- Step 0: Enable uuid-ossp extension for UUID generation
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Add UUID column to soundtrack table
|
||||||
|
ALTER TABLE soundtrack ADD COLUMN uuid UUID DEFAULT gen_random_uuid();
|
||||||
|
|
||||||
|
-- Add unique constraint on soundtrack.uuid
|
||||||
|
ALTER TABLE soundtrack ADD CONSTRAINT soundtrack_uuid_unique UNIQUE (uuid);
|
||||||
|
|
||||||
|
-- Backfill existing soundtracks with UUIDs (should already have values from DEFAULT)
|
||||||
|
UPDATE soundtrack SET uuid = gen_random_uuid() WHERE uuid IS NULL;
|
||||||
|
|
||||||
|
-- Add index on soundtrack.uuid for query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_soundtrack_uuid ON soundtrack(uuid);
|
||||||
|
|
||||||
|
-- Add UUID columns to song table
|
||||||
|
ALTER TABLE song ADD COLUMN uuid UUID DEFAULT gen_random_uuid();
|
||||||
|
ALTER TABLE song ADD COLUMN soundtrack_uuid UUID;
|
||||||
|
|
||||||
|
-- Backfill existing songs with UUIDs
|
||||||
|
UPDATE song SET uuid = gen_random_uuid() WHERE uuid IS NULL;
|
||||||
|
|
||||||
|
-- Backfill song.soundtrack_uuid from soundtrack.uuid via JOIN
|
||||||
|
UPDATE song s
|
||||||
|
SET soundtrack_uuid = st.uuid
|
||||||
|
FROM soundtrack st
|
||||||
|
WHERE s.soundtrack_id = st.id AND s.soundtrack_uuid IS NULL;
|
||||||
|
|
||||||
|
-- Add foreign key constraint from song.soundtrack_uuid to soundtrack.uuid
|
||||||
|
ALTER TABLE song ADD CONSTRAINT song_soundtrack_uuid_fkey
|
||||||
|
FOREIGN KEY (soundtrack_uuid) REFERENCES soundtrack(uuid);
|
||||||
|
|
||||||
|
-- Add indexes on song UUID columns
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_uuid ON song(uuid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_soundtrack_uuid ON song(soundtrack_uuid);
|
||||||
|
|
||||||
|
-- Drop the composite primary key (soundtrack_id, path)
|
||||||
|
ALTER TABLE song DROP CONSTRAINT song_pkey;
|
||||||
|
|
||||||
|
-- Add new primary key on uuid column
|
||||||
|
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (uuid);
|
||||||
|
|
||||||
|
-- Add unique constraint to maintain same semantics as old PK
|
||||||
|
-- This ensures (soundtrack, path) combinations remain unique
|
||||||
|
ALTER TABLE song ADD CONSTRAINT song_soundtrack_path_unique UNIQUE (soundtrack_uuid, path);
|
||||||
|
|
||||||
|
-- Create index on soundtrack_id for query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_soundtrack_id ON song(soundtrack_id);
|
||||||
|
|
||||||
|
-- Create index on path for lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
|
||||||
|
|
||||||
|
-- ============ PART 3: MAKE UUID COLUMNS NOT NULL (Issue #28 - Phase 2) ============
|
||||||
|
|
||||||
|
-- Add NOT NULL constraint to soundtrack.uuid
|
||||||
|
ALTER TABLE soundtrack ALTER COLUMN uuid SET NOT NULL;
|
||||||
|
|
||||||
|
-- Add NOT NULL constraint to song.uuid
|
||||||
|
ALTER TABLE song ALTER COLUMN uuid SET NOT NULL;
|
||||||
|
|
||||||
|
-- Add NOT NULL constraint to song.soundtrack_uuid
|
||||||
|
ALTER TABLE song ALTER COLUMN soundtrack_uuid SET NOT NULL;
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
-- Revert: Rename soundtrack table back to game
|
|
||||||
ALTER TABLE soundtrack RENAME TO game;
|
|
||||||
|
|
||||||
-- Revert primary key sequence
|
|
||||||
ALTER SEQUENCE soundtrack_id_seq RENAME TO game_id_seq;
|
|
||||||
|
|
||||||
-- Revert columns in game table
|
|
||||||
ALTER TABLE game RENAME COLUMN soundtrack_name TO game_name;
|
|
||||||
|
|
||||||
-- Revert song table: rename soundtrack_id back to game_id
|
|
||||||
ALTER TABLE song RENAME COLUMN soundtrack_id TO game_id;
|
|
||||||
|
|
||||||
-- Revert song primary key
|
|
||||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
|
|
||||||
ALTER TABLE song ADD PRIMARY KEY (game_id, path);
|
|
||||||
ALTER TABLE song RENAME CONSTRAINT song_pkey_soundtrack TO song_pkey;
|
|
||||||
|
|
||||||
-- Revert song_list table references
|
|
||||||
ALTER TABLE song_list RENAME COLUMN soundtrack_name TO game_name;
|
|
||||||
|
|
||||||
-- Revert foreign key constraint
|
|
||||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
|
||||||
ALTER TABLE song ADD CONSTRAINT song_game_id_fkey
|
|
||||||
FOREIGN KEY (game_id) REFERENCES game(id);
|
|
||||||
|
|
||||||
-- Revert indexes
|
|
||||||
ALTER INDEX IF EXISTS idx_soundtrack_deleted RENAME TO idx_game_deleted;
|
|
||||||
ALTER INDEX IF EXISTS idx_soundtrack_hash RENAME TO idx_game_hash;
|
|
||||||
ALTER INDEX IF EXISTS idx_soundtrack_path RENAME TO idx_game_path;
|
|
||||||
ALTER INDEX IF EXISTS idx_soundtrack_name RENAME TO idx_game_name;
|
|
||||||
ALTER INDEX IF EXISTS idx_song_soundtrack_id RENAME TO idx_song_game_id;
|
|
||||||
ALTER INDEX IF EXISTS idx_song_soundtrack_id_song_name RENAME TO idx_song_game_id_song_name;
|
|
||||||
ALTER INDEX IF EXISTS song_list_soundtrack_name_idx RENAME TO song_list_game_name_idx;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
-- Rename game table to soundtrack
|
|
||||||
ALTER TABLE game RENAME TO soundtrack;
|
|
||||||
|
|
||||||
-- Rename primary key sequence
|
|
||||||
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
|
|
||||||
|
|
||||||
-- Rename columns in soundtrack table
|
|
||||||
ALTER TABLE soundtrack RENAME COLUMN game_name TO soundtrack_name;
|
|
||||||
|
|
||||||
-- Update song table: rename game_id to soundtrack_id
|
|
||||||
ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id;
|
|
||||||
|
|
||||||
-- Update song primary key
|
|
||||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
|
|
||||||
ALTER TABLE song ADD PRIMARY KEY (soundtrack_id, path);
|
|
||||||
ALTER TABLE song RENAME CONSTRAINT song_pkey TO song_pkey_soundtrack;
|
|
||||||
|
|
||||||
-- Update song_list table references
|
|
||||||
ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name;
|
|
||||||
|
|
||||||
-- Rename foreign key constraint
|
|
||||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey;
|
|
||||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
|
||||||
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
|
||||||
|
|
||||||
-- Rename indexes
|
|
||||||
ALTER INDEX IF EXISTS idx_game_deleted RENAME TO idx_soundtrack_deleted;
|
|
||||||
ALTER INDEX IF EXISTS idx_game_hash RENAME TO idx_soundtrack_hash;
|
|
||||||
ALTER INDEX IF EXISTS idx_game_path RENAME TO idx_soundtrack_path;
|
|
||||||
ALTER INDEX IF EXISTS idx_game_name RENAME TO idx_soundtrack_name;
|
|
||||||
ALTER INDEX IF EXISTS idx_song_game_id RENAME TO idx_song_soundtrack_id;
|
|
||||||
ALTER INDEX IF EXISTS idx_song_game_id_song_name RENAME TO idx_song_soundtrack_id_song_name;
|
|
||||||
ALTER INDEX IF EXISTS song_list_game_name_idx RENAME TO song_list_soundtrack_name_idx;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- Rollback: Remove id column and restore composite PK
|
|
||||||
|
|
||||||
-- Step 1: Drop indexes created in up migration
|
|
||||||
DROP INDEX IF EXISTS idx_song_soundtrack_id;
|
|
||||||
DROP INDEX IF EXISTS idx_song_path;
|
|
||||||
|
|
||||||
-- Step 2: Drop foreign key constraint
|
|
||||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
|
||||||
|
|
||||||
-- Step 3: Drop new primary key
|
|
||||||
ALTER TABLE song DROP CONSTRAINT song_pkey;
|
|
||||||
|
|
||||||
-- Step 4: Drop unique constraint on id
|
|
||||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_id_unique;
|
|
||||||
|
|
||||||
-- Step 5: Restore composite primary key
|
|
||||||
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (soundtrack_id, path);
|
|
||||||
|
|
||||||
-- Step 6: Drop the id column
|
|
||||||
ALTER TABLE song DROP COLUMN id;
|
|
||||||
|
|
||||||
-- Step 7: Recreate original foreign key (soundtrack_id references soundtrack.id)
|
|
||||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
|
||||||
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
-- Migration: Add id column to song table and change PK from composite to single column
|
|
||||||
-- This prepares the song table for eventual UUID migration
|
|
||||||
|
|
||||||
-- Step 1: Add new id column (nullable initially)
|
|
||||||
ALTER TABLE song ADD COLUMN id serial4;
|
|
||||||
|
|
||||||
-- Step 2: Create unique constraint on id (allows backfilling)
|
|
||||||
ALTER TABLE song ADD CONSTRAINT song_id_unique UNIQUE (id);
|
|
||||||
|
|
||||||
-- Step 3: Backfill existing rows with sequential IDs
|
|
||||||
-- Use DEFAULT which pulls from the sequence
|
|
||||||
UPDATE song SET id = DEFAULT WHERE id IS NULL;
|
|
||||||
|
|
||||||
-- Step 4: Verify all rows have an id
|
|
||||||
-- If this returns 0, backfill worked
|
|
||||||
-- SELECT COUNT(*) FROM song WHERE id IS NULL;
|
|
||||||
|
|
||||||
-- Step 5: Drop the composite primary key (soundtrack_id, path)
|
|
||||||
ALTER TABLE song DROP CONSTRAINT song_pkey;
|
|
||||||
|
|
||||||
-- Step 6: Add new primary key on id column
|
|
||||||
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
-- Step 7: Ensure soundtrack_id remains a foreign key to soundtrack
|
|
||||||
-- First drop existing FK if it exists (from the rename migration)
|
|
||||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
|
||||||
|
|
||||||
-- Then recreate it
|
|
||||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
|
||||||
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
|
||||||
|
|
||||||
-- Step 8: Create index on soundtrack_id for query performance
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_song_soundtrack_id ON song(soundtrack_id);
|
|
||||||
|
|
||||||
-- Step 9: Create index on path for lookups (previously part of PK)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
|
|
||||||
@@ -5,7 +5,7 @@ DELETE FROM song;
|
|||||||
DELETE FROM song WHERE soundtrack_id = $1;
|
DELETE FROM song WHERE soundtrack_id = $1;
|
||||||
|
|
||||||
-- name: AddSong :exec
|
-- name: AddSong :exec
|
||||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
|
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7);
|
||||||
|
|
||||||
-- name: CheckSong :one
|
-- name: CheckSong :one
|
||||||
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2;
|
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||||
@@ -34,11 +34,19 @@ WHERE soundtrack_id = $1 AND song_name = $2;
|
|||||||
-- name: FetchAllSongs :many
|
-- name: FetchAllSongs :many
|
||||||
SELECT * FROM song;
|
SELECT * FROM song;
|
||||||
|
|
||||||
-- name: GetSongById :one
|
-- name: GetSongByUUID :one
|
||||||
SELECT * FROM song WHERE id = $1;
|
SELECT * FROM song WHERE uuid = $1;
|
||||||
|
|
||||||
-- name: RemoveBrokenSong :exec
|
-- name: RemoveBrokenSong :exec
|
||||||
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
|
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||||
|
|
||||||
-- name: RemoveBrokenSongs :exec
|
-- name: RemoveBrokenSongs :exec
|
||||||
DELETE FROM song WHERE id = ANY($1);
|
DELETE FROM song WHERE uuid = ANY($1);
|
||||||
|
|
||||||
|
-- name: GetSongsBySoundtrackUUID :many
|
||||||
|
SELECT *
|
||||||
|
FROM song
|
||||||
|
WHERE soundtrack_uuid = $1;
|
||||||
|
|
||||||
|
-- name: GetSongUUIDsBySoundtrackId :many
|
||||||
|
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
|
||||||
@@ -29,7 +29,7 @@ UPDATE soundtrack SET deleted=NULL WHERE id=$1;
|
|||||||
SELECT id FROM soundtrack WHERE soundtrack_name = $1;
|
SELECT id FROM soundtrack WHERE soundtrack_name = $1;
|
||||||
|
|
||||||
-- name: InsertSoundtrack :one
|
-- name: InsertSoundtrack :one
|
||||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id;
|
INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid;
|
||||||
|
|
||||||
-- name: InsertSoundtrackWithExistingId :exec
|
-- name: InsertSoundtrackWithExistingId :exec
|
||||||
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
||||||
@@ -47,3 +47,12 @@ ORDER BY soundtrack_name;
|
|||||||
|
|
||||||
-- name: AddSoundtrackPlayed :exec
|
-- name: AddSoundtrackPlayed :exec
|
||||||
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetSoundtrackByUUID :one
|
||||||
|
SELECT *
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE uuid = $1
|
||||||
|
AND deleted IS NULL;
|
||||||
|
|
||||||
|
-- name: GetSoundtrackUUIDById :one
|
||||||
|
SELECT uuid FROM soundtrack WHERE id = $1;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Most played soundtracks with their songs
|
-- Most played soundtracks with their songs
|
||||||
-- name: GetMostPlayedGamesWithSongs :many
|
-- name: GetMostPlayedSoundtracksWithSongs :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -21,7 +21,7 @@ ORDER BY g.times_played DESC, g.soundtrack_name
|
|||||||
LIMIT $1;
|
LIMIT $1;
|
||||||
|
|
||||||
-- Least played soundtracks with their songs
|
-- Least played soundtracks with their songs
|
||||||
-- name: GetLeastPlayedGamesWithSongs :many
|
-- name: GetLeastPlayedSoundtracksWithSongs :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -43,7 +43,7 @@ ORDER BY g.times_played ASC, g.soundtrack_name
|
|||||||
LIMIT $1;
|
LIMIT $1;
|
||||||
|
|
||||||
-- Most played songs with their soundtrack info
|
-- Most played songs with their soundtrack info
|
||||||
-- name: GetMostPlayedSongsWithGame :many
|
-- name: GetMostPlayedSongsWithSoundtrack :many
|
||||||
SELECT
|
SELECT
|
||||||
s.soundtrack_id as soundtrack_id,
|
s.soundtrack_id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -58,7 +58,7 @@ ORDER BY s.times_played DESC, s.song_name
|
|||||||
LIMIT $1;
|
LIMIT $1;
|
||||||
|
|
||||||
-- Least played songs with their soundtrack info
|
-- Least played songs with their soundtrack info
|
||||||
-- name: GetLeastPlayedSongsWithGame :many
|
-- name: GetLeastPlayedSongsWithSoundtrack :many
|
||||||
SELECT
|
SELECT
|
||||||
s.soundtrack_id as soundtrack_id,
|
s.soundtrack_id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -72,8 +72,8 @@ WHERE g.deleted IS NULL
|
|||||||
ORDER BY s.times_played ASC, s.song_name
|
ORDER BY s.times_played ASC, s.song_name
|
||||||
LIMIT $1;
|
LIMIT $1;
|
||||||
|
|
||||||
-- Games that have never been played (times_played = 0)
|
-- Soundtracks that have never been played (times_played = 0)
|
||||||
-- name: GetNeverPlayedGames :many
|
-- name: GetNeverPlayedSoundtracks :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -93,7 +93,7 @@ GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
|
|||||||
ORDER BY g.soundtrack_name;
|
ORDER BY g.soundtrack_name;
|
||||||
|
|
||||||
-- Last played soundtracks (most recently played)
|
-- Last played soundtracks (most recently played)
|
||||||
-- name: GetLastPlayedGames :many
|
-- name: GetLastPlayedSoundtracks :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -114,7 +114,7 @@ ORDER BY g.last_played DESC
|
|||||||
LIMIT $1;
|
LIMIT $1;
|
||||||
|
|
||||||
-- Oldest played soundtracks (least recently played, but has been played at least once)
|
-- Oldest played soundtracks (least recently played, but has been played at least once)
|
||||||
-- name: GetOldestPlayedGames :many
|
-- name: GetOldestPlayedSoundtracks :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -138,8 +138,8 @@ LIMIT $1;
|
|||||||
-- name: GetStatisticsSummary :one
|
-- name: GetStatisticsSummary :one
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_soundtracks,
|
COUNT(*) as total_soundtracks,
|
||||||
SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks,
|
COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint as played_soundtracks,
|
||||||
SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_soundtracks,
|
COALESCE(SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END), 0)::bigint as never_played_soundtracks,
|
||||||
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
|
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
|
||||||
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
|
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
|
||||||
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
|
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
|
||||||
|
|||||||
@@ -7,17 +7,10 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IDMigrationStatus struct {
|
|
||||||
TableName string `json:"table_name"`
|
|
||||||
TotalRows int32 `json:"total_rows"`
|
|
||||||
MigratedRows int32 `json:"migrated_rows"`
|
|
||||||
Completed bool `json:"completed"`
|
|
||||||
StartedAt *time.Time `json:"started_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
IpAddress string `json:"ip_address"`
|
IpAddress string `json:"ip_address"`
|
||||||
@@ -34,8 +27,8 @@ type Song struct {
|
|||||||
TimesPlayed int32 `json:"times_played"`
|
TimesPlayed int32 `json:"times_played"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
FileName *string `json:"file_name"`
|
FileName *string `json:"file_name"`
|
||||||
ID pgtype.Int4 `json:"id"`
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
Uuid pgtype.UUID `json:"uuid"`
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SongList struct {
|
type SongList struct {
|
||||||
@@ -57,7 +50,7 @@ type Soundtrack struct {
|
|||||||
LastPlayed *time.Time `json:"last_played"`
|
LastPlayed *time.Time `json:"last_played"`
|
||||||
NumberOfSongs int32 `json:"number_of_songs"`
|
NumberOfSongs int32 `json:"number_of_songs"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Uuid pgtype.UUID `json:"uuid"`
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Vgmq struct {
|
type Vgmq struct {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const addHashToSong = `-- name: AddHashToSong :exec
|
const addHashToSong = `-- name: AddHashToSong :exec
|
||||||
@@ -27,7 +27,7 @@ func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addSong = `-- name: AddSong :exec
|
const addSong = `-- name: AddSong :exec
|
||||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
|
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
`
|
`
|
||||||
|
|
||||||
type AddSongParams struct {
|
type AddSongParams struct {
|
||||||
@@ -36,6 +36,8 @@ type AddSongParams struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
FileName *string `json:"file_name"`
|
FileName *string `json:"file_name"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
||||||
@@ -45,6 +47,8 @@ func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
|||||||
arg.Path,
|
arg.Path,
|
||||||
arg.FileName,
|
arg.FileName,
|
||||||
arg.Hash,
|
arg.Hash,
|
||||||
|
arg.Uuid,
|
||||||
|
arg.SoundtrackUuid,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -110,7 +114,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchAllSongs = `-- name: FetchAllSongs :many
|
const fetchAllSongs = `-- name: FetchAllSongs :many
|
||||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||||
@@ -129,8 +133,8 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
|||||||
&i.TimesPlayed,
|
&i.TimesPlayed,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.FileName,
|
&i.FileName,
|
||||||
&i.ID,
|
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
|
&i.SoundtrackUuid,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many
|
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many
|
||||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
|
||||||
FROM song
|
FROM song
|
||||||
WHERE soundtrack_id = $1
|
WHERE soundtrack_id = $1
|
||||||
`
|
`
|
||||||
@@ -164,8 +168,8 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
|
|||||||
&i.TimesPlayed,
|
&i.TimesPlayed,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.FileName,
|
&i.FileName,
|
||||||
&i.ID,
|
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
|
&i.SoundtrackUuid,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -177,12 +181,12 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSongById = `-- name: GetSongById :one
|
const getSongByUUID = `-- name: GetSongByUUID :one
|
||||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE id = $1
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE uuid = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) {
|
func (q *Queries) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) {
|
||||||
row := q.db.QueryRow(ctx, getSongById, id)
|
row := q.db.QueryRow(ctx, getSongByUUID, argUuid)
|
||||||
var i Song
|
var i Song
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.SoundtrackID,
|
&i.SoundtrackID,
|
||||||
@@ -191,14 +195,43 @@ func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error)
|
|||||||
&i.TimesPlayed,
|
&i.TimesPlayed,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.FileName,
|
&i.FileName,
|
||||||
&i.ID,
|
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
|
&i.SoundtrackUuid,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSongUUIDsBySoundtrackId = `-- name: GetSongUUIDsBySoundtrackId :many
|
||||||
|
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSongUUIDsBySoundtrackIdRow struct {
|
||||||
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
|
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSongUUIDsBySoundtrackId(ctx context.Context, soundtrackID int32) ([]GetSongUUIDsBySoundtrackIdRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getSongUUIDsBySoundtrackId, soundtrackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetSongUUIDsBySoundtrackIdRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetSongUUIDsBySoundtrackIdRow
|
||||||
|
if err := rows.Scan(&i.Uuid, &i.SoundtrackUuid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getSongWithHash = `-- name: GetSongWithHash :one
|
const getSongWithHash = `-- name: GetSongWithHash :one
|
||||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE hash = $1
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE hash = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
|
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
|
||||||
@@ -211,12 +244,47 @@ func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error
|
|||||||
&i.TimesPlayed,
|
&i.TimesPlayed,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.FileName,
|
&i.FileName,
|
||||||
&i.ID,
|
|
||||||
&i.Uuid,
|
&i.Uuid,
|
||||||
|
&i.SoundtrackUuid,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSongsBySoundtrackUUID = `-- name: GetSongsBySoundtrackUUID :many
|
||||||
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
|
||||||
|
FROM song
|
||||||
|
WHERE soundtrack_uuid = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getSongsBySoundtrackUUID, soundtrackUuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Song
|
||||||
|
for rows.Next() {
|
||||||
|
var i Song
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SoundtrackID,
|
||||||
|
&i.SongName,
|
||||||
|
&i.Path,
|
||||||
|
&i.TimesPlayed,
|
||||||
|
&i.Hash,
|
||||||
|
&i.FileName,
|
||||||
|
&i.Uuid,
|
||||||
|
&i.SoundtrackUuid,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const removeBrokenSong = `-- name: RemoveBrokenSong :exec
|
const removeBrokenSong = `-- name: RemoveBrokenSong :exec
|
||||||
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2
|
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2
|
||||||
`
|
`
|
||||||
@@ -232,11 +300,11 @@ func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongPara
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
|
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
|
||||||
DELETE FROM song WHERE id = ANY($1)
|
DELETE FROM song WHERE uuid = ANY($1)
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) RemoveBrokenSongs(ctx context.Context, id pgtype.Int4) error {
|
func (q *Queries) RemoveBrokenSongs(ctx context.Context, argUuid uuid.UUID) error {
|
||||||
_, err := q.db.Exec(ctx, removeBrokenSongs, id)
|
_, err := q.db.Exec(ctx, removeBrokenSongs, argUuid)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
|
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
|
||||||
@@ -141,6 +143,32 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
|
||||||
|
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE uuid = $1
|
||||||
|
AND deleted IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (Soundtrack, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSoundtrackByUUID, argUuid)
|
||||||
|
var i Soundtrack
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.Added,
|
||||||
|
&i.Deleted,
|
||||||
|
&i.LastChanged,
|
||||||
|
&i.Path,
|
||||||
|
&i.TimesPlayed,
|
||||||
|
&i.LastPlayed,
|
||||||
|
&i.NumberOfSongs,
|
||||||
|
&i.Hash,
|
||||||
|
&i.Uuid,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getSoundtrackNameById = `-- name: GetSoundtrackNameById :one
|
const getSoundtrackNameById = `-- name: GetSoundtrackNameById :one
|
||||||
SELECT soundtrack_name FROM soundtrack WHERE id = $1
|
SELECT soundtrack_name FROM soundtrack WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -152,21 +180,43 @@ func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string,
|
|||||||
return soundtrack_name, err
|
return soundtrack_name, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSoundtrackUUIDById = `-- name: GetSoundtrackUUIDById :one
|
||||||
|
SELECT uuid FROM soundtrack WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSoundtrackUUIDById(ctx context.Context, id int32) (uuid.UUID, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSoundtrackUUIDById, id)
|
||||||
|
var uuid uuid.UUID
|
||||||
|
err := row.Scan(&uuid)
|
||||||
|
return uuid, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertSoundtrack = `-- name: InsertSoundtrack :one
|
const insertSoundtrack = `-- name: InsertSoundtrack :one
|
||||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
|
INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertSoundtrackParams struct {
|
type InsertSoundtrackParams struct {
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) {
|
type InsertSoundtrackRow struct {
|
||||||
row := q.db.QueryRow(ctx, insertSoundtrack, arg.SoundtrackName, arg.Path, arg.Hash)
|
ID int32 `json:"id"`
|
||||||
var id int32
|
Uuid uuid.UUID `json:"uuid"`
|
||||||
err := row.Scan(&id)
|
}
|
||||||
return id, err
|
|
||||||
|
func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertSoundtrack,
|
||||||
|
arg.SoundtrackName,
|
||||||
|
arg.Path,
|
||||||
|
arg.Hash,
|
||||||
|
arg.Uuid,
|
||||||
|
)
|
||||||
|
var i InsertSoundtrackRow
|
||||||
|
err := row.Scan(&i.ID, &i.Uuid)
|
||||||
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec
|
const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const getLastPlayedGames = `-- name: GetLastPlayedGames :many
|
const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -31,7 +31,7 @@ ORDER BY g.last_played DESC
|
|||||||
LIMIT $1
|
LIMIT $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetLastPlayedGamesRow struct {
|
type GetLastPlayedSoundtracksRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
@@ -40,15 +40,15 @@ type GetLastPlayedGamesRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Last played soundtracks (most recently played)
|
// Last played soundtracks (most recently played)
|
||||||
func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLastPlayedGamesRow, error) {
|
func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, getLastPlayedGames, limit)
|
rows, err := q.db.Query(ctx, getLastPlayedSoundtracks, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetLastPlayedGamesRow
|
var items []GetLastPlayedSoundtracksRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetLastPlayedGamesRow
|
var i GetLastPlayedSoundtracksRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.SoundtrackID,
|
&i.SoundtrackID,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
@@ -66,7 +66,59 @@ func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLas
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLeastPlayedGamesWithSongs = `-- name: GetLeastPlayedGamesWithSongs :many
|
const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
|
||||||
|
SELECT
|
||||||
|
s.soundtrack_id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
s.song_name,
|
||||||
|
s.path,
|
||||||
|
s.times_played,
|
||||||
|
s.file_name
|
||||||
|
FROM song s
|
||||||
|
JOIN soundtrack g ON s.soundtrack_id = g.id
|
||||||
|
WHERE g.deleted IS NULL
|
||||||
|
ORDER BY s.times_played ASC, s.song_name
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLeastPlayedSongsWithSoundtrackRow struct {
|
||||||
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
SongName string `json:"song_name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
TimesPlayed int32 `json:"times_played"`
|
||||||
|
FileName *string `json:"file_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Least played songs with their soundtrack info
|
||||||
|
func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithSoundtrackRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getLeastPlayedSongsWithSoundtrack, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetLeastPlayedSongsWithSoundtrackRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLeastPlayedSongsWithSoundtrackRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SoundtrackID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.SongName,
|
||||||
|
&i.Path,
|
||||||
|
&i.TimesPlayed,
|
||||||
|
&i.FileName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -88,7 +140,7 @@ ORDER BY g.times_played ASC, g.soundtrack_name
|
|||||||
LIMIT $1
|
LIMIT $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetLeastPlayedGamesWithSongsRow struct {
|
type GetLeastPlayedSoundtracksWithSongsRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
@@ -97,15 +149,15 @@ type GetLeastPlayedGamesWithSongsRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Least played soundtracks with their songs
|
// Least played soundtracks with their songs
|
||||||
func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedGamesWithSongsRow, error) {
|
func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, getLeastPlayedGamesWithSongs, limit)
|
rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetLeastPlayedGamesWithSongsRow
|
var items []GetLeastPlayedSoundtracksWithSongsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetLeastPlayedGamesWithSongsRow
|
var i GetLeastPlayedSoundtracksWithSongsRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.SoundtrackID,
|
&i.SoundtrackID,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
@@ -123,7 +175,7 @@ func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLeastPlayedSongsWithGame = `-- name: GetLeastPlayedSongsWithGame :many
|
const getMostPlayedSongsWithSoundtrack = `-- name: GetMostPlayedSongsWithSoundtrack :many
|
||||||
SELECT
|
SELECT
|
||||||
s.soundtrack_id as soundtrack_id,
|
s.soundtrack_id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -134,11 +186,11 @@ SELECT
|
|||||||
FROM song s
|
FROM song s
|
||||||
JOIN soundtrack g ON s.soundtrack_id = g.id
|
JOIN soundtrack g ON s.soundtrack_id = g.id
|
||||||
WHERE g.deleted IS NULL
|
WHERE g.deleted IS NULL
|
||||||
ORDER BY s.times_played ASC, s.song_name
|
ORDER BY s.times_played DESC, s.song_name
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetLeastPlayedSongsWithGameRow struct {
|
type GetMostPlayedSongsWithSoundtrackRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SongName string `json:"song_name"`
|
SongName string `json:"song_name"`
|
||||||
@@ -147,16 +199,16 @@ type GetLeastPlayedSongsWithGameRow struct {
|
|||||||
FileName *string `json:"file_name"`
|
FileName *string `json:"file_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Least played songs with their soundtrack info
|
// Most played songs with their soundtrack info
|
||||||
func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithGameRow, error) {
|
func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithSoundtrackRow, error) {
|
||||||
rows, err := q.db.Query(ctx, getLeastPlayedSongsWithGame, limit)
|
rows, err := q.db.Query(ctx, getMostPlayedSongsWithSoundtrack, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetLeastPlayedSongsWithGameRow
|
var items []GetMostPlayedSongsWithSoundtrackRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetLeastPlayedSongsWithGameRow
|
var i GetMostPlayedSongsWithSoundtrackRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.SoundtrackID,
|
&i.SoundtrackID,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
@@ -175,7 +227,7 @@ func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMostPlayedGamesWithSongs = `-- name: GetMostPlayedGamesWithSongs :many
|
const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -197,7 +249,7 @@ ORDER BY g.times_played DESC, g.soundtrack_name
|
|||||||
LIMIT $1
|
LIMIT $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetMostPlayedGamesWithSongsRow struct {
|
type GetMostPlayedSoundtracksWithSongsRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
@@ -206,15 +258,15 @@ type GetMostPlayedGamesWithSongsRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Most played soundtracks with their songs
|
// Most played soundtracks with their songs
|
||||||
func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedGamesWithSongsRow, error) {
|
func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, getMostPlayedGamesWithSongs, limit)
|
rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetMostPlayedGamesWithSongsRow
|
var items []GetMostPlayedSoundtracksWithSongsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetMostPlayedGamesWithSongsRow
|
var i GetMostPlayedSoundtracksWithSongsRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.SoundtrackID,
|
&i.SoundtrackID,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
@@ -232,59 +284,7 @@ func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMostPlayedSongsWithGame = `-- name: GetMostPlayedSongsWithGame :many
|
const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
|
||||||
SELECT
|
|
||||||
s.soundtrack_id as soundtrack_id,
|
|
||||||
g.soundtrack_name,
|
|
||||||
s.song_name,
|
|
||||||
s.path,
|
|
||||||
s.times_played,
|
|
||||||
s.file_name
|
|
||||||
FROM song s
|
|
||||||
JOIN soundtrack g ON s.soundtrack_id = g.id
|
|
||||||
WHERE g.deleted IS NULL
|
|
||||||
ORDER BY s.times_played DESC, s.song_name
|
|
||||||
LIMIT $1
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetMostPlayedSongsWithGameRow struct {
|
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
|
||||||
SongName string `json:"song_name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
TimesPlayed int32 `json:"times_played"`
|
|
||||||
FileName *string `json:"file_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Most played songs with their soundtrack info
|
|
||||||
func (q *Queries) GetMostPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithGameRow, error) {
|
|
||||||
rows, err := q.db.Query(ctx, getMostPlayedSongsWithGame, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetMostPlayedSongsWithGameRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetMostPlayedSongsWithGameRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.SoundtrackID,
|
|
||||||
&i.SoundtrackName,
|
|
||||||
&i.SongName,
|
|
||||||
&i.Path,
|
|
||||||
&i.TimesPlayed,
|
|
||||||
&i.FileName,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNeverPlayedGames = `-- name: GetNeverPlayedGames :many
|
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -304,7 +304,7 @@ GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
|
|||||||
ORDER BY g.soundtrack_name
|
ORDER BY g.soundtrack_name
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetNeverPlayedGamesRow struct {
|
type GetNeverPlayedSoundtracksRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
@@ -312,16 +312,16 @@ type GetNeverPlayedGamesRow struct {
|
|||||||
Songs []byte `json:"songs"`
|
Songs []byte `json:"songs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Games that have never been played (times_played = 0)
|
// Soundtracks that have never been played (times_played = 0)
|
||||||
func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGamesRow, error) {
|
func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, getNeverPlayedGames)
|
rows, err := q.db.Query(ctx, getNeverPlayedSoundtracks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetNeverPlayedGamesRow
|
var items []GetNeverPlayedSoundtracksRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetNeverPlayedGamesRow
|
var i GetNeverPlayedSoundtracksRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.SoundtrackID,
|
&i.SoundtrackID,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
@@ -339,7 +339,7 @@ func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGame
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOldestPlayedGames = `-- name: GetOldestPlayedGames :many
|
const getOldestPlayedSoundtracks = `-- name: GetOldestPlayedSoundtracks :many
|
||||||
SELECT
|
SELECT
|
||||||
g.id as soundtrack_id,
|
g.id as soundtrack_id,
|
||||||
g.soundtrack_name,
|
g.soundtrack_name,
|
||||||
@@ -360,7 +360,7 @@ ORDER BY g.last_played ASC
|
|||||||
LIMIT $1
|
LIMIT $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetOldestPlayedGamesRow struct {
|
type GetOldestPlayedSoundtracksRow struct {
|
||||||
SoundtrackID int32 `json:"soundtrack_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SoundtrackName string `json:"soundtrack_name"`
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
@@ -369,15 +369,15 @@ type GetOldestPlayedGamesRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Oldest played soundtracks (least recently played, but has been played at least once)
|
// Oldest played soundtracks (least recently played, but has been played at least once)
|
||||||
func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetOldestPlayedGamesRow, error) {
|
func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, getOldestPlayedGames, limit)
|
rows, err := q.db.Query(ctx, getOldestPlayedSoundtracks, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetOldestPlayedGamesRow
|
var items []GetOldestPlayedSoundtracksRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetOldestPlayedGamesRow
|
var i GetOldestPlayedSoundtracksRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.SoundtrackID,
|
&i.SoundtrackID,
|
||||||
&i.SoundtrackName,
|
&i.SoundtrackName,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
@@ -38,5 +39,11 @@ func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
|||||||
// @Router /character [get]
|
// @Router /character [get]
|
||||||
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
||||||
character := ctx.QueryParam("name")
|
character := ctx.QueryParam("name")
|
||||||
return ctx.File(backend.GetCharacter(character))
|
characterPath := backend.GetCharacter(character)
|
||||||
|
file, err := os.Open(characterPath)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return ctx.Stream(http.StatusOK, "image/png", file)
|
||||||
}
|
}
|
||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HealthHandler struct {
|
type HealthHandler struct {
|
||||||
|
db *db.Database
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHealthHandler() *HealthHandler {
|
func NewHealthHandler(database *db.Database) *HealthHandler {
|
||||||
return &HealthHandler{}
|
return &HealthHandler{db: database}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheck godoc
|
// HealthCheck godoc
|
||||||
@@ -24,5 +25,5 @@ func NewHealthHandler() *HealthHandler {
|
|||||||
// @Success 200 {string} string "OK"
|
// @Success 200 {string} string "OK"
|
||||||
// @Router /health [get]
|
// @Router /health [get]
|
||||||
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
|
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
|
||||||
return ctx.JSON(http.StatusOK, db.Health())
|
return ctx.JSON(http.StatusOK, h.db.Health())
|
||||||
}
|
}
|
||||||
@@ -5,18 +5,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"music-server/internal/db"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHealthCheck verifies the health endpoint returns database status
|
// TestHealthCheck verifies the health endpoint returns database status
|
||||||
func TestHealthCheck(t *testing.T) {
|
func TestHealthCheck(t *testing.T) {
|
||||||
// Setup database
|
|
||||||
db.TestSetupDB(t)
|
|
||||||
defer db.TestTearDownDB(t)
|
|
||||||
|
|
||||||
e := StartTestServer(t)
|
e := StartTestServer(t)
|
||||||
|
// No explicit teardown - handled by StartTestServer's sync.Once
|
||||||
|
|
||||||
resp := MakeTestRequest(t, e, "GET", "/health")
|
resp := MakeTestRequest(t, e, "GET", "/health")
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimiter implements a simple IP-based rate limiter
|
||||||
|
type RateLimiter struct {
|
||||||
|
visits map[string]*Visit
|
||||||
|
mu sync.Mutex
|
||||||
|
limit int // requests per window
|
||||||
|
window time.Duration // time window
|
||||||
|
}
|
||||||
|
|
||||||
|
type Visit struct {
|
||||||
|
count int
|
||||||
|
firstSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter creates a new rate limiter
|
||||||
|
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||||
|
rl := &RateLimiter{
|
||||||
|
visits: make(map[string]*Visit),
|
||||||
|
limit: limit,
|
||||||
|
window: window,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cleanup goroutine
|
||||||
|
go rl.cleanup()
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup periodically removes old entries
|
||||||
|
func (rl *RateLimiter) cleanup() {
|
||||||
|
for {
|
||||||
|
time.Sleep(rl.window)
|
||||||
|
rl.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for ip, visit := range rl.visits {
|
||||||
|
if now.Sub(visit.firstSeen) > rl.window {
|
||||||
|
delete(rl.visits, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitMiddleware returns an Echo middleware for rate limiting
|
||||||
|
func RateLimitMiddleware(rl *RateLimiter) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
ip := c.RealIP()
|
||||||
|
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
visit, exists := rl.visits[ip]
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
// First visit from this IP
|
||||||
|
rl.visits[ip] = &Visit{
|
||||||
|
count: 1,
|
||||||
|
firstSeen: now,
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if now.Sub(visit.firstSeen) > rl.window {
|
||||||
|
// Reset for new window
|
||||||
|
visit.count = 1
|
||||||
|
visit.firstSeen = now
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if limit exceeded
|
||||||
|
if visit.count >= rl.limit {
|
||||||
|
logging.GetLogger().Warn("Rate limit exceeded",
|
||||||
|
zap.String("ip", ip),
|
||||||
|
zap.String("path", c.Request().URL.Path))
|
||||||
|
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||||
|
"error": "Rate limit exceeded. Please try again later.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count
|
||||||
|
visit.count++
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncCheckMiddleware blocks requests when syncing is in progress.
|
||||||
|
// It returns HTTP 423 Locked with a standard message.
|
||||||
|
// This middleware should be applied to handlers that cannot execute during sync.
|
||||||
|
func SyncCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
if backend.Syncing {
|
||||||
|
logging.GetLogger().Info("Syncing is in progress - request blocked")
|
||||||
|
return c.JSON(http.StatusLocked, "Syncing is in progress")
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,15 @@ import (
|
|||||||
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
|
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
|
// Session auth disable bypass - allow all requests through without token validation
|
||||||
|
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
|
||||||
|
if disableSessionAuth {
|
||||||
|
logging.GetLogger().Debug("Session auth disabled - bypassing token authentication",
|
||||||
|
zap.String("path", c.Request().URL.Path),
|
||||||
|
zap.String("ip", c.RealIP()))
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract token from Authorization header
|
// Extract token from Authorization header
|
||||||
authHeader := c.Request().Header.Get("Authorization")
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ func NewMusicHandler() *MusicHandler {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music [get]
|
// @Router /music [get]
|
||||||
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
song := ctx.QueryParam("song")
|
song := ctx.QueryParam("song")
|
||||||
if song == "" {
|
if song == "" {
|
||||||
return ctx.String(http.StatusBadRequest, "song can't be empty")
|
return ctx.String(http.StatusBadRequest, "song can't be empty")
|
||||||
@@ -58,10 +54,6 @@ func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/soundTest [get]
|
// @Router /music/soundTest [get]
|
||||||
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
songPath := backend.GetSoundCheckSong()
|
songPath := backend.GetSoundCheckSong()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,10 +72,6 @@ func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/reset [get]
|
// @Router /music/reset [get]
|
||||||
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
backend.Reset()
|
backend.Reset()
|
||||||
return ctx.NoContent(http.StatusOK)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
@@ -98,10 +86,6 @@ func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/rand [get]
|
// @Router /music/rand [get]
|
||||||
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
songPath := backend.GetRandomSong()
|
songPath := backend.GetRandomSong()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,10 +105,6 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/rand/low [get]
|
// @Router /music/rand/low [get]
|
||||||
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
songPath := backend.GetRandomSongLowChance()
|
songPath := backend.GetRandomSongLowChance()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -144,10 +124,6 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/rand/classic [get]
|
// @Router /music/rand/classic [get]
|
||||||
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
|
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
songPath := backend.GetRandomSongClassic()
|
songPath := backend.GetRandomSongClassic()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -193,10 +169,6 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/next [get]
|
// @Router /music/next [get]
|
||||||
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
songPath := backend.GetNextSong()
|
songPath := backend.GetNextSong()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,10 +188,6 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/previous [get]
|
// @Router /music/previous [get]
|
||||||
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
songPath := backend.GetPreviousSong()
|
songPath := backend.GetPreviousSong()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -231,7 +199,7 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
|||||||
|
|
||||||
// GetAllSoundtracks godoc
|
// GetAllSoundtracks godoc
|
||||||
// @Summary Get all soundtracks
|
// @Summary Get all soundtracks
|
||||||
// @Description Returns a list of all games in order
|
// @Description Returns a list of all soundtracks in order
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -239,17 +207,13 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/all/order [get]
|
// @Router /music/all/order [get]
|
||||||
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
soundtrackList := backend.GetAllSoundtracks()
|
soundtrackList := backend.GetAllSoundtracks()
|
||||||
return ctx.JSON(http.StatusOK, soundtrackList)
|
return ctx.JSON(http.StatusOK, soundtrackList)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllSoundtracksRandom godoc
|
// GetAllSoundtracksRandom godoc
|
||||||
// @Summary Get all soundtracks random
|
// @Summary Get all soundtracks random
|
||||||
// @Description Returns a list of all games in random order
|
// @Description Returns a list of all soundtracks in random order
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -257,10 +221,6 @@ func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/all/random [get]
|
// @Router /music/all/random [get]
|
||||||
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
soundtrackList := backend.GetAllSoundtracksRandom()
|
soundtrackList := backend.GetAllSoundtracksRandom()
|
||||||
return ctx.JSON(http.StatusOK, soundtrackList)
|
return ctx.JSON(http.StatusOK, soundtrackList)
|
||||||
}
|
}
|
||||||
@@ -277,10 +237,6 @@ func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/played [put]
|
// @Router /music/played [put]
|
||||||
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctx.JSON(http.StatusBadRequest, err.Error())
|
return ctx.JSON(http.StatusBadRequest, err.Error())
|
||||||
@@ -299,10 +255,6 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/addQue [get]
|
// @Router /music/addQue [get]
|
||||||
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
backend.AddLatestToQue()
|
backend.AddLatestToQue()
|
||||||
return ctx.NoContent(http.StatusOK)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
@@ -316,10 +268,6 @@ func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /music/addPlayed [get]
|
// @Router /music/addPlayed [get]
|
||||||
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
|
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
backend.AddLatestPlayed()
|
backend.AddLatestPlayed()
|
||||||
return ctx.NoContent(http.StatusOK)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
+160
-50
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
@@ -50,8 +51,8 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
fileServer := http.FileServer(http.FS(web.Assets))
|
fileServer := http.FileServer(http.FS(web.Assets))
|
||||||
e.GET("/assets/*", echo.WrapHandler(fileServer))
|
e.GET("/assets/*", echo.WrapHandler(fileServer))
|
||||||
|
|
||||||
e.GET("/search", echo.WrapHandler(templ.Handler(web.HelloForm())))
|
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
|
||||||
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindGameWebHandler)))
|
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
|
||||||
|
|
||||||
e.Static("/", "/frontend")
|
e.Static("/", "/frontend")
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
// ============================================
|
// ============================================
|
||||||
deprecatedMiddleware := middleware.DeprecationMiddleware
|
deprecatedMiddleware := middleware.DeprecationMiddleware
|
||||||
|
|
||||||
health := NewHealthHandler()
|
health := NewHealthHandler(s.db)
|
||||||
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
|
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
|
||||||
|
|
||||||
version := NewVersionHandler()
|
version := NewVersionHandler()
|
||||||
@@ -82,32 +83,32 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
|
|
||||||
sync := NewSyncHandler()
|
sync := NewSyncHandler()
|
||||||
syncGroup := e.Group("/sync")
|
syncGroup := e.Group("/sync")
|
||||||
syncGroup.GET("", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
syncGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
|
||||||
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
|
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
|
||||||
syncGroup.GET("/new", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
|
||||||
syncGroup.GET("/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull))
|
syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
|
||||||
syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull))
|
syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
|
||||||
syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
|
||||||
syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetDB))
|
syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB)))
|
||||||
|
|
||||||
music := NewMusicHandler()
|
music := NewMusicHandler()
|
||||||
musicGroup := e.Group("/music")
|
musicGroup := e.Group("/music")
|
||||||
musicGroup.GET("", deprecatedMiddleware(music.GetSong))
|
musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong)))
|
||||||
musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong))
|
musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong)))
|
||||||
musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic))
|
musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic)))
|
||||||
musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong))
|
musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong)))
|
||||||
musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance))
|
musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance)))
|
||||||
musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic))
|
musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic)))
|
||||||
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
|
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
|
||||||
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
|
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
|
||||||
musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong))
|
musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong)))
|
||||||
musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong))
|
musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong)))
|
||||||
musicGroup.GET("/all", deprecatedMiddleware(music.GetAllSoundtracksRandom))
|
musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
|
||||||
musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllSoundtracks))
|
musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks)))
|
||||||
musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllSoundtracksRandom))
|
musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
|
||||||
musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed))
|
musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed)))
|
||||||
musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue))
|
musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue)))
|
||||||
musicGroup.GET("/addPlayed", deprecatedMiddleware(music.AddLatestPlayed))
|
musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed)))
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// API v1 Routes with Token Authentication
|
// API v1 Routes with Token Authentication
|
||||||
@@ -116,53 +117,162 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
// Create /api/v1 group
|
// Create /api/v1 group
|
||||||
apiV1 := e.Group("/api/v1")
|
apiV1 := e.Group("/api/v1")
|
||||||
|
|
||||||
// Public endpoints - no token required
|
// Create rate limiter for token creation: 10 requests per minute per IP
|
||||||
apiV1.POST("/token", func(c *echo.Context) error {
|
tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute)
|
||||||
|
tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter)
|
||||||
|
|
||||||
|
// Token endpoints (public - no token required)
|
||||||
|
tokenGroup := apiV1.Group("/token")
|
||||||
|
tokenGroup.POST("", func(c *echo.Context) error {
|
||||||
return s.tokenHandler.CreateTokenHandler(c)
|
return s.tokenHandler.CreateTokenHandler(c)
|
||||||
})
|
}, tokenRateLimitMiddleware)
|
||||||
apiV1.DELETE("/token", func(c *echo.Context) error {
|
tokenGroup.DELETE("", func(c *echo.Context) error {
|
||||||
return s.tokenHandler.DeleteTokenHandler(c)
|
return s.tokenHandler.DeleteTokenHandler(c)
|
||||||
})
|
})
|
||||||
apiV1.POST("/token/cleanup", func(c *echo.Context) error {
|
tokenGroup.POST("/cleanup", func(c *echo.Context) error {
|
||||||
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
|
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Protected endpoints - require valid token
|
// Protected endpoints - require valid token
|
||||||
// Create token auth middleware with pool access
|
// Create token auth middleware with pool access
|
||||||
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
|
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
|
||||||
|
|
||||||
// Protected group with token authentication
|
|
||||||
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
|
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
|
||||||
|
|
||||||
// Statistics API endpoints (protected by token auth)
|
// Statistics endpoints (protected)
|
||||||
statistics := s.statisticsHandler
|
statisticsHandler := s.statisticsHandler
|
||||||
protectedV1.GET("/statistics/games/most-played", func(c *echo.Context) error {
|
statsGroup := protectedV1.Group("/statistics")
|
||||||
return statistics.GetMostPlayedGames(c)
|
statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
|
||||||
|
return statisticsHandler.GetMostPlayedSoundtracks(c)
|
||||||
})
|
})
|
||||||
protectedV1.GET("/statistics/games/least-played", func(c *echo.Context) error {
|
statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
|
||||||
return statistics.GetLeastPlayedGames(c)
|
return statisticsHandler.GetLeastPlayedSoundtracks(c)
|
||||||
})
|
})
|
||||||
protectedV1.GET("/statistics/games/never-played", func(c *echo.Context) error {
|
statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
|
||||||
return statistics.GetNeverPlayedGames(c)
|
return statisticsHandler.GetNeverPlayedSoundtracks(c)
|
||||||
})
|
})
|
||||||
protectedV1.GET("/statistics/games/last-played", func(c *echo.Context) error {
|
statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
|
||||||
return statistics.GetLastPlayedGames(c)
|
return statisticsHandler.GetLastPlayedSoundtracks(c)
|
||||||
})
|
})
|
||||||
protectedV1.GET("/statistics/games/oldest-played", func(c *echo.Context) error {
|
statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
|
||||||
return statistics.GetOldestPlayedGames(c)
|
return statisticsHandler.GetOldestPlayedSoundtracks(c)
|
||||||
})
|
})
|
||||||
protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error {
|
statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
|
||||||
return statistics.GetMostPlayedSongs(c)
|
return statisticsHandler.GetMostPlayedSongs(c)
|
||||||
})
|
})
|
||||||
protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error {
|
statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
|
||||||
return statistics.GetLeastPlayedSongs(c)
|
return statisticsHandler.GetLeastPlayedSongs(c)
|
||||||
})
|
})
|
||||||
protectedV1.GET("/statistics/summary", func(c *echo.Context) error {
|
statsGroup.GET("/summary", func(c *echo.Context) error {
|
||||||
return statistics.GetStatisticsSummary(c)
|
return statisticsHandler.GetStatisticsSummary(c)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Future: VGMQ endpoints will be added to protectedV1 group
|
// Version endpoints (protected)
|
||||||
_ = protectedV1 // Use the variable to avoid unused variable error
|
versionGroup := protectedV1.Group("/version")
|
||||||
|
versionGroup.GET("", func(c *echo.Context) error {
|
||||||
|
return version.GetLatestVersion(c)
|
||||||
|
})
|
||||||
|
versionGroup.GET("/history", func(c *echo.Context) error {
|
||||||
|
return version.GetVersionHistory(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Character endpoints (protected)
|
||||||
|
characterGroup := protectedV1.Group("/character")
|
||||||
|
characterGroup.GET("", func(c *echo.Context) error {
|
||||||
|
return character.GetCharacter(c)
|
||||||
|
})
|
||||||
|
characterGroup.GET("/list", func(c *echo.Context) error {
|
||||||
|
return character.GetCharacterList(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Download endpoints (protected)
|
||||||
|
downloadGroup := protectedV1.Group("/download")
|
||||||
|
downloadGroup.GET("", func(c *echo.Context) error {
|
||||||
|
return download.checkLatest(c)
|
||||||
|
})
|
||||||
|
downloadGroup.GET("/list", func(c *echo.Context) error {
|
||||||
|
return download.listAssetsOfLatest(c)
|
||||||
|
})
|
||||||
|
downloadGroup.GET("/windows", func(c *echo.Context) error {
|
||||||
|
return download.downloadLatestWindows(c)
|
||||||
|
})
|
||||||
|
downloadGroup.GET("/linux", func(c *echo.Context) error {
|
||||||
|
return download.downloadLatestLinux(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync endpoints (protected)
|
||||||
|
protectedSyncGroup := protectedV1.Group("/sync")
|
||||||
|
protectedSyncGroup.GET("", func(c *echo.Context) error {
|
||||||
|
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||||
|
})
|
||||||
|
protectedSyncGroup.GET("/progress", func(c *echo.Context) error {
|
||||||
|
return sync.SyncProgress(c)
|
||||||
|
})
|
||||||
|
protectedSyncGroup.GET("/new", func(c *echo.Context) error {
|
||||||
|
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||||
|
})
|
||||||
|
protectedSyncGroup.GET("/full", func(c *echo.Context) error {
|
||||||
|
return sync.SyncSoundtracksNewFull(c)
|
||||||
|
})
|
||||||
|
protectedSyncGroup.GET("/new/full", func(c *echo.Context) error {
|
||||||
|
return sync.SyncSoundtracksNewFull(c)
|
||||||
|
})
|
||||||
|
protectedSyncGroup.GET("/quick", func(c *echo.Context) error {
|
||||||
|
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||||
|
})
|
||||||
|
protectedSyncGroup.GET("/reset", func(c *echo.Context) error {
|
||||||
|
return sync.ResetDB(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Music endpoints (protected)
|
||||||
|
protectedMusicGroup := protectedV1.Group("/music")
|
||||||
|
protectedMusicGroup.GET("", func(c *echo.Context) error {
|
||||||
|
return music.GetSong(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error {
|
||||||
|
return music.GetSoundCheckSong(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/reset", func(c *echo.Context) error {
|
||||||
|
return music.ResetMusic(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/rand", func(c *echo.Context) error {
|
||||||
|
return music.GetRandomSong(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error {
|
||||||
|
return music.GetRandomSongLowChance(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error {
|
||||||
|
return music.GetRandomSongClassic(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/info", func(c *echo.Context) error {
|
||||||
|
return music.GetSongInfo(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/list", func(c *echo.Context) error {
|
||||||
|
return music.GetPlayedSongs(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/next", func(c *echo.Context) error {
|
||||||
|
return music.GetNextSong(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/previous", func(c *echo.Context) error {
|
||||||
|
return music.GetPreviousSong(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/all", func(c *echo.Context) error {
|
||||||
|
return music.GetAllSoundtracksRandom(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/all/order", func(c *echo.Context) error {
|
||||||
|
return music.GetAllSoundtracks(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/all/random", func(c *echo.Context) error {
|
||||||
|
return music.GetAllSoundtracksRandom(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.PUT("/played", func(c *echo.Context) error {
|
||||||
|
return music.PutPlayed(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/addQue", func(c *echo.Context) error {
|
||||||
|
return music.AddLatestToQue(c)
|
||||||
|
})
|
||||||
|
protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error {
|
||||||
|
return music.AddLatestPlayed(c)
|
||||||
|
})
|
||||||
|
|
||||||
routes := e.Router().Routes()
|
routes := e.Router().Routes()
|
||||||
sort.Slice(routes, func(i, j int) bool {
|
sort.Slice(routes, func(i, j int) bool {
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
host = os.Getenv("DB_HOST")
|
|
||||||
dbPort = os.Getenv("DB_PORT")
|
|
||||||
dbName = os.Getenv("DB_NAME")
|
|
||||||
username = os.Getenv("DB_USERNAME")
|
|
||||||
password = os.Getenv("DB_PASSWORD")
|
|
||||||
musicPath = os.Getenv("MUSIC_PATH")
|
musicPath = os.Getenv("MUSIC_PATH")
|
||||||
charactersPath = os.Getenv("CHARACTERS_PATH")
|
charactersPath = os.Getenv("CHARACTERS_PATH")
|
||||||
logLevel = os.Getenv("LOG_LEVEL")
|
logLevel = os.Getenv("LOG_LEVEL")
|
||||||
@@ -37,11 +32,20 @@ var (
|
|||||||
// NewServerInstance creates a new Server instance with all dependencies initialized.
|
// NewServerInstance creates a new Server instance with all dependencies initialized.
|
||||||
// Use this for dependency injection and proper lifecycle management.
|
// Use this for dependency injection and proper lifecycle management.
|
||||||
func NewServerInstance() *Server {
|
func NewServerInstance() *Server {
|
||||||
|
// Re-read environment variables at server creation time (not at package import time)
|
||||||
|
// This allows tests to override environment variables
|
||||||
|
host := os.Getenv("DB_HOST")
|
||||||
|
dbPort := os.Getenv("DB_PORT")
|
||||||
|
dbName := os.Getenv("DB_NAME")
|
||||||
|
username := os.Getenv("DB_USERNAME")
|
||||||
|
password := os.Getenv("DB_PASSWORD")
|
||||||
|
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
if logLevel == "" {
|
currentLogLevel := logLevel
|
||||||
logLevel = "info"
|
if currentLogLevel == "" {
|
||||||
|
currentLogLevel = "info"
|
||||||
}
|
}
|
||||||
logging.Init(logLevel, logJSON)
|
logging.Init(currentLogLevel, logJSON)
|
||||||
|
|
||||||
logger := logging.GetLogger()
|
logger := logging.GetLogger()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
// saveEnvVars saves the current state of relevant environment variables
|
||||||
|
func saveEnvVars() map[string]string {
|
||||||
|
vars := []string{
|
||||||
|
"DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME",
|
||||||
|
"MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL",
|
||||||
|
}
|
||||||
|
saved := make(map[string]string)
|
||||||
|
for _, v := range vars {
|
||||||
|
saved[v] = os.Getenv(v)
|
||||||
|
}
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreEnvVars restores environment variables from a saved state
|
||||||
|
func restoreEnvVars(saved map[string]string) {
|
||||||
|
for k, v := range saved {
|
||||||
|
if v == "" {
|
||||||
|
os.Unsetenv(k)
|
||||||
|
} else {
|
||||||
|
os.Setenv(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTableExists returns true if the specified table exists in the database
|
||||||
|
func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = $1
|
||||||
|
)
|
||||||
|
`, tableName).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database
|
||||||
|
func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error {
|
||||||
|
// Copy the SQL dump file to the container
|
||||||
|
basename := filepath.Base(dumpPath)
|
||||||
|
targetPath := "/tmp/" + basename
|
||||||
|
if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy dump file to container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute psql to load the dump as the postgres superuser
|
||||||
|
// We execute psql inside the container, so we use localhost and port 5432 (the container's internal port)
|
||||||
|
// The postgres user is the superuser created by the Docker image
|
||||||
|
// Use sh instead of bash as postgres:15-alpine uses Alpine Linux
|
||||||
|
cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"}
|
||||||
|
|
||||||
|
exitCode, reader, err := container.Exec(ctx, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to execute psql: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the output
|
||||||
|
output, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read psql output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode != 0 {
|
||||||
|
return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRoleInContainer creates a role in the PostgreSQL container
|
||||||
|
func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error {
|
||||||
|
// Get the mapped port for the container
|
||||||
|
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get mapped port: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the postgres database to create the role
|
||||||
|
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
|
||||||
|
mappedPort.Port())
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, connStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to postgres database: %w", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
// Check if role already exists
|
||||||
|
var exists bool
|
||||||
|
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists)
|
||||||
|
if err == nil && exists {
|
||||||
|
return nil // Role already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any error from the role check (role might not exist, which is expected)
|
||||||
|
// Create the role
|
||||||
|
_, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create role %s: %w", roleName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDatabaseInContainer creates a new database in the PostgreSQL container
|
||||||
|
func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error {
|
||||||
|
// Get the mapped port for the container
|
||||||
|
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get mapped port: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a small delay to ensure PostgreSQL is fully ready
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Connect to the default 'postgres' database to create the new database
|
||||||
|
// Use localhost and the mapped port
|
||||||
|
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
|
||||||
|
mappedPort.Port())
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, connStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to postgres database: %w", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
// Check if database already exists and drop it if so
|
||||||
|
var exists int
|
||||||
|
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
|
||||||
|
if err == nil && exists == 1 {
|
||||||
|
_, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to drop existing database: %w", err)
|
||||||
|
}
|
||||||
|
t.Logf("Dropped existing database %s", dbname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new database
|
||||||
|
_, err = pool.Exec(ctx, "CREATE DATABASE "+dbname)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Created database %s", dbname)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPostgreSQLContainer starts a PostgreSQL container and returns it
|
||||||
|
func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) {
|
||||||
|
req := testcontainers.ContainerRequest{
|
||||||
|
Image: "postgres:15-alpine",
|
||||||
|
ExposedPorts: []string{"5432/tcp"},
|
||||||
|
Env: map[string]string{
|
||||||
|
"POSTGRES_PASSWORD": "postgres",
|
||||||
|
"POSTGRES_DB": "postgres",
|
||||||
|
},
|
||||||
|
WaitingFor: wait.ForLog("database system is ready to accept connections"),
|
||||||
|
}
|
||||||
|
|
||||||
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
|
ContainerRequest: req,
|
||||||
|
Started: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return container, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work
|
||||||
|
func TestMusicRefDump(t *testing.T) {
|
||||||
|
testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work
|
||||||
|
func TestMusicTestDump(t *testing.T) {
|
||||||
|
testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work
|
||||||
|
func TestMusicProdDump(t *testing.T) {
|
||||||
|
testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// testSQLDump is the common test function for all SQL dump tests
|
||||||
|
func testSQLDump(t *testing.T, dumpFile, dbname string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Save original environment variables
|
||||||
|
originalEnv := saveEnvVars()
|
||||||
|
defer restoreEnvVars(originalEnv)
|
||||||
|
|
||||||
|
// Start PostgreSQL container
|
||||||
|
t.Log("Starting PostgreSQL container...")
|
||||||
|
container, err := startPostgreSQLContainer(t, ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() {
|
||||||
|
t.Log("Stopping container...")
|
||||||
|
container.Terminate(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Get container connection details
|
||||||
|
endpoint, err := container.Endpoint(ctx, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Container running at: %s", endpoint)
|
||||||
|
|
||||||
|
// Create database for this test
|
||||||
|
t.Logf("Creating database %s...", dbname)
|
||||||
|
err = createDatabaseInContainer(t, ctx, container, dbname)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create common roles that might be referenced in dump files
|
||||||
|
err = createRoleInContainer(t, ctx, container, "sansan", "postgres")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the SQL dump
|
||||||
|
// The dump files are in the project root
|
||||||
|
// Get the absolute path to the dump file
|
||||||
|
absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
|
||||||
|
// Try relative to current working directory
|
||||||
|
absDumpPath = filepath.Join(".", dumpFile)
|
||||||
|
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
|
||||||
|
absDumpPath = dumpFile // Try as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Loading SQL dump from: %s", absDumpPath)
|
||||||
|
err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PRE-MIGRATION CHECK
|
||||||
|
// ============================================
|
||||||
|
t.Log("Running pre-migration checks...")
|
||||||
|
|
||||||
|
// For connecting to the database from the test, we need to use the container's endpoint
|
||||||
|
// But the container's endpoint might be localhost:random_port
|
||||||
|
// We need to parse it to get host and port
|
||||||
|
// endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456"
|
||||||
|
// Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port
|
||||||
|
// Let's extract the mapped port
|
||||||
|
|
||||||
|
// Get the mapped port from the container
|
||||||
|
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Container mapped port: %s", mappedPort)
|
||||||
|
|
||||||
|
// Use localhost as the host since we're on the same machine
|
||||||
|
dbHost := "localhost"
|
||||||
|
dbPort := mappedPort.Port()
|
||||||
|
|
||||||
|
// Create a connection to the database to check pre-migration state
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
dbHost, dbPort, "postgres", "postgres", dbname)
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, connStr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
// Check that game table exists (pre-migration state)
|
||||||
|
gameExists, err := checkTableExists(ctx, pool, "game")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, gameExists, "Before migration: game table should exist")
|
||||||
|
|
||||||
|
// Check that soundtrack table does NOT exist
|
||||||
|
soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist")
|
||||||
|
|
||||||
|
t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not")
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// START APPLICATION (triggers migrations)
|
||||||
|
// ============================================
|
||||||
|
t.Log("Starting application...")
|
||||||
|
|
||||||
|
// Set environment variables for the server
|
||||||
|
// The application needs to connect to the container's mapped port
|
||||||
|
os.Setenv("DB_HOST", dbHost)
|
||||||
|
os.Setenv("DB_PORT", dbPort)
|
||||||
|
os.Setenv("DB_USERNAME", "postgres")
|
||||||
|
os.Setenv("DB_PASSWORD", "postgres")
|
||||||
|
os.Setenv("DB_NAME", dbname)
|
||||||
|
os.Setenv("MUSIC_PATH", "./testMusic")
|
||||||
|
os.Setenv("CHARACTERS_PATH", "./testCharacters")
|
||||||
|
|
||||||
|
// Use different ports for each test to allow for sequential execution
|
||||||
|
// and to make port detection reliable
|
||||||
|
portMap := map[string]string{
|
||||||
|
"music_server_ref_test": "8082",
|
||||||
|
"music_server_test_dump_test": "8083",
|
||||||
|
"music_server_prod_test": "8084",
|
||||||
|
}
|
||||||
|
appPort := portMap[dbname]
|
||||||
|
if appPort == "" {
|
||||||
|
appPort = "8085" // fallback
|
||||||
|
}
|
||||||
|
os.Setenv("PORT", appPort)
|
||||||
|
|
||||||
|
os.Setenv("DISABLE_SESSION_AUTH", "true")
|
||||||
|
os.Setenv("LOG_LEVEL", "error")
|
||||||
|
|
||||||
|
// Create server instance (this runs migrations automatically)
|
||||||
|
serverInstance := NewServerInstance()
|
||||||
|
httpServer := serverInstance.HTTPServer()
|
||||||
|
|
||||||
|
// Start HTTP server in background
|
||||||
|
go func() {
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
t.Logf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
t.Log("Stopping HTTP server...")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
httpServer.Shutdown(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for server to start by polling /health endpoint
|
||||||
|
// Use the fixed port we configured for the application
|
||||||
|
healthURL := fmt.Sprintf("http://localhost:%s/health", appPort)
|
||||||
|
|
||||||
|
maxAttempts := 30
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
resp, err := http.Get(healthURL)
|
||||||
|
if err == nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
var healthData map[string]string
|
||||||
|
json.NewDecoder(resp.Body).Decode(&healthData)
|
||||||
|
if healthData["status"] == "up" {
|
||||||
|
t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attempt == maxAttempts {
|
||||||
|
require.Fail(t, "Server did not become healthy within timeout")
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// POST-MIGRATION CHECK
|
||||||
|
// ============================================
|
||||||
|
t.Log("Running post-migration checks...")
|
||||||
|
|
||||||
|
// Check that game table no longer exists
|
||||||
|
gameExistsAfter, err := checkTableExists(ctx, pool, "game")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, gameExistsAfter, "After migration: game table should NOT exist")
|
||||||
|
|
||||||
|
// Check that soundtrack table now exists
|
||||||
|
soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist")
|
||||||
|
|
||||||
|
// Verify data was preserved
|
||||||
|
var soundtrackCount int
|
||||||
|
err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0")
|
||||||
|
t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HEALTH CHECK
|
||||||
|
// ============================================
|
||||||
|
t.Log("Running health check...")
|
||||||
|
|
||||||
|
resp, err := http.Get(healthURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var healthData map[string]string
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&healthData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "up", healthData["status"])
|
||||||
|
|
||||||
|
t.Log("✓ Health check passed")
|
||||||
|
t.Log("✓ Test completed successfully!")
|
||||||
|
}
|
||||||
@@ -23,20 +23,20 @@ func NewStatisticsHandler() *StatisticsHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMostPlayedGames returns top N most played games with songs
|
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
|
||||||
// GET /api/v1/statistics/games/most-played
|
// GET /api/v1/statistics/soundtracks/most-played
|
||||||
//
|
//
|
||||||
// @Summary Get most played games
|
// @Summary Get most played soundtracks
|
||||||
// @Description Returns the top N most played games with their songs
|
// @Description Returns the top N most played soundtracks with their songs
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param limit query int false "Number of results (default: 10)"
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
// @Success 200 {array} backend.GameWithSongs
|
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/games/most-played [get]
|
// @Router /api/v1/statistics/soundtracks/most-played [get]
|
||||||
func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
|
||||||
limit := 10 // default
|
limit := 10 // default
|
||||||
limitStr := ctx.QueryParam("limit")
|
limitStr := ctx.QueryParam("limit")
|
||||||
if limitStr != "" {
|
if limitStr != "" {
|
||||||
@@ -51,28 +51,28 @@ func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
games, err := h.statsBackend.GetMostPlayedGamesWithSongs(int32(limit))
|
soundtracks, err := h.statsBackend.GetMostPlayedSoundtracksWithSongs(int32(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to get most played games", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to get most played soundtracks", zap.String("error", err.Error()))
|
||||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
}
|
}
|
||||||
return ctx.JSON(http.StatusOK, games)
|
return ctx.JSON(http.StatusOK, soundtracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeastPlayedGames returns top N least played games with songs
|
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
|
||||||
// GET /api/v1/statistics/games/least-played
|
// GET /api/v1/statistics/soundtracks/least-played
|
||||||
//
|
//
|
||||||
// @Summary Get least played games
|
// @Summary Get least played soundtracks
|
||||||
// @Description Returns the top N least played games with their songs
|
// @Description Returns the top N least played soundtracks with their songs
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param limit query int false "Number of results (default: 10)"
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
// @Success 200 {array} backend.GameWithSongs
|
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/games/least-played [get]
|
// @Router /api/v1/statistics/soundtracks/least-played [get]
|
||||||
func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
|
||||||
limit := 10
|
limit := 10
|
||||||
limitStr := ctx.QueryParam("limit")
|
limitStr := ctx.QueryParam("limit")
|
||||||
if limitStr != "" {
|
if limitStr != "" {
|
||||||
@@ -86,19 +86,19 @@ func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
games, err := h.statsBackend.GetLeastPlayedGamesWithSongs(int32(limit))
|
soundtracks, err := h.statsBackend.GetLeastPlayedSoundtracksWithSongs(int32(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to get least played games", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to get least played soundtracks", zap.String("error", err.Error()))
|
||||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
}
|
}
|
||||||
return ctx.JSON(http.StatusOK, games)
|
return ctx.JSON(http.StatusOK, soundtracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMostPlayedSongs returns top N most played songs with game info
|
// GetMostPlayedSongs returns top N most played songs with soundtrack info
|
||||||
// GET /api/v1/statistics/songs/most-played
|
// GET /api/v1/statistics/songs/most-played
|
||||||
//
|
//
|
||||||
// @Summary Get most played songs
|
// @Summary Get most played songs
|
||||||
// @Description Returns the top N most played songs with their game info
|
// @Description Returns the top N most played songs with their soundtrack info
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -121,7 +121,7 @@ func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
songs, err := h.statsBackend.GetMostPlayedSongsWithGame(int32(limit))
|
songs, err := h.statsBackend.GetMostPlayedSongsWithSoundtrack(int32(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error()))
|
||||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
@@ -129,11 +129,11 @@ func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
|
|||||||
return ctx.JSON(http.StatusOK, songs)
|
return ctx.JSON(http.StatusOK, songs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeastPlayedSongs returns top N least played songs with game info
|
// GetLeastPlayedSongs returns top N least played songs with soundtrack info
|
||||||
// GET /api/v1/statistics/songs/least-played
|
// GET /api/v1/statistics/songs/least-played
|
||||||
//
|
//
|
||||||
// @Summary Get least played songs
|
// @Summary Get least played songs
|
||||||
// @Description Returns the top N least played songs with their game info
|
// @Description Returns the top N least played songs with their soundtrack info
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -156,7 +156,7 @@ func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
songs, err := h.statsBackend.GetLeastPlayedSongsWithGame(int32(limit))
|
songs, err := h.statsBackend.GetLeastPlayedSongsWithSoundtrack(int32(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
|
||||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
@@ -164,40 +164,40 @@ func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
|
|||||||
return ctx.JSON(http.StatusOK, songs)
|
return ctx.JSON(http.StatusOK, songs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNeverPlayedGames returns games that have never been played
|
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||||
// GET /api/v1/statistics/games/never-played
|
// GET /api/v1/statistics/soundtracks/never-played
|
||||||
//
|
//
|
||||||
// @Summary Get never played games
|
// @Summary Get never played soundtracks
|
||||||
// @Description Returns all games that have never been played (times_played = 0)
|
// @Description Returns all soundtracks that have never been played (times_played = 0)
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} backend.GameWithSongs
|
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/games/never-played [get]
|
// @Router /api/v1/statistics/soundtracks/never-played [get]
|
||||||
func (h *StatisticsHandler) GetNeverPlayedGames(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
|
||||||
games, err := h.statsBackend.GetNeverPlayedGames()
|
soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to get never played games", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to get never played soundtracks", zap.String("error", err.Error()))
|
||||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
}
|
}
|
||||||
return ctx.JSON(http.StatusOK, games)
|
return ctx.JSON(http.StatusOK, soundtracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLastPlayedGames returns most recently played games
|
// GetLastPlayedSoundtracks returns most recently played soundtracks
|
||||||
// GET /api/v1/statistics/games/last-played
|
// GET /api/v1/statistics/soundtracks/last-played
|
||||||
//
|
//
|
||||||
// @Summary Get last played games
|
// @Summary Get last played soundtracks
|
||||||
// @Description Returns the most recently played games
|
// @Description Returns the most recently played soundtracks
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param limit query int false "Number of results (default: 10)"
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
// @Success 200 {array} backend.GameWithSongs
|
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/games/last-played [get]
|
// @Router /api/v1/statistics/soundtracks/last-played [get]
|
||||||
func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
|
||||||
limit := 10
|
limit := 10
|
||||||
limitStr := ctx.QueryParam("limit")
|
limitStr := ctx.QueryParam("limit")
|
||||||
if limitStr != "" {
|
if limitStr != "" {
|
||||||
@@ -211,28 +211,28 @@ func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
games, err := h.statsBackend.GetLastPlayedGames(int32(limit))
|
soundtracks, err := h.statsBackend.GetLastPlayedSoundtracks(int32(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to get last played games", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to get last played soundtracks", zap.String("error", err.Error()))
|
||||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
}
|
}
|
||||||
return ctx.JSON(http.StatusOK, games)
|
return ctx.JSON(http.StatusOK, soundtracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOldestPlayedGames returns least recently played games
|
// GetOldestPlayedSoundtracks returns least recently played soundtracks
|
||||||
// GET /api/v1/statistics/games/oldest-played
|
// GET /api/v1/statistics/soundtracks/oldest-played
|
||||||
//
|
//
|
||||||
// @Summary Get oldest played games
|
// @Summary Get oldest played soundtracks
|
||||||
// @Description Returns the least recently played games (that have been played at least once)
|
// @Description Returns the least recently played soundtracks (that have been played at least once)
|
||||||
// @Tags statistics
|
// @Tags statistics
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param limit query int false "Number of results (default: 10)"
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
// @Success 200 {array} backend.GameWithSongs
|
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/statistics/games/oldest-played [get]
|
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
|
||||||
func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error {
|
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
|
||||||
limit := 10
|
limit := 10
|
||||||
limitStr := ctx.QueryParam("limit")
|
limitStr := ctx.QueryParam("limit")
|
||||||
if limitStr != "" {
|
if limitStr != "" {
|
||||||
@@ -246,12 +246,12 @@ func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
games, err := h.statsBackend.GetOldestPlayedGames(int32(limit))
|
soundtracks, err := h.statsBackend.GetOldestPlayedSoundtracks(int32(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Failed to get oldest played games", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Failed to get oldest played soundtracks", zap.String("error", err.Error()))
|
||||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
}
|
}
|
||||||
return ctx.JSON(http.StatusOK, games)
|
return ctx.JSON(http.StatusOK, soundtracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatisticsSummary returns overall statistics
|
// GetStatisticsSummary returns overall statistics
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"music-server/internal/db"
|
"music-server/internal/db"
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -73,6 +74,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 +91,9 @@ func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
|
|||||||
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// We inserted 5 soundtracks, so total should be at least 5
|
// After sync with /sync/new, only soundtracks matching filesystem remain
|
||||||
// (there might be existing data)
|
// testMusic has 3 soundtracks
|
||||||
require.GreaterOrEqual(t, summary.TotalGames, int64(5))
|
require.Equal(t, int64(3), summary.TotalSoundtracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// insertTestData inserts 5 test soundtracks with songs into the database
|
// insertTestData inserts 5 test soundtracks with songs into the database
|
||||||
@@ -117,11 +123,12 @@ func insertTestData(t *testing.T) {
|
|||||||
SoundtrackName: st.name,
|
SoundtrackName: st.name,
|
||||||
Path: st.path,
|
Path: st.path,
|
||||||
Hash: "test-hash-" + st.name,
|
Hash: "test-hash-" + st.name,
|
||||||
|
Uuid: uuid.New(),
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
|
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get soundtrack IDs
|
// Get soundtrack IDs and UUIDs
|
||||||
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
|
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
|
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
|
||||||
@@ -129,13 +136,14 @@ func insertTestData(t *testing.T) {
|
|||||||
// Insert songs for each soundtrack
|
// Insert songs for each soundtrack
|
||||||
songData := []struct {
|
songData := []struct {
|
||||||
soundtrackID int32
|
soundtrackID int32
|
||||||
|
soundtrackUuid uuid.UUID
|
||||||
songs []string
|
songs []string
|
||||||
}{
|
}{
|
||||||
{soundtrackIDs[0].ID, []string{"Song A", "Song B"}},
|
{soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}},
|
||||||
{soundtrackIDs[1].ID, []string{"Song C", "Song D"}},
|
{soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}},
|
||||||
{soundtrackIDs[2].ID, []string{"Song E"}},
|
{soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}},
|
||||||
{soundtrackIDs[3].ID, []string{"Song F", "Song G", "Song H"}},
|
{soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}},
|
||||||
{soundtrackIDs[4].ID, []string{"Song I"}},
|
{soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []string{"Song I"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sd := range songData {
|
for _, sd := range songData {
|
||||||
@@ -144,6 +152,8 @@ func insertTestData(t *testing.T) {
|
|||||||
SoundtrackID: sd.soundtrackID,
|
SoundtrackID: sd.soundtrackID,
|
||||||
SongName: songName,
|
SongName: songName,
|
||||||
Path: "/path/to/" + songName + ".mp3",
|
Path: "/path/to/" + songName + ".mp3",
|
||||||
|
Uuid: uuid.New(),
|
||||||
|
SoundtrackUuid: sd.soundtrackUuid,
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Failed to insert song: %s", songName)
|
require.NoError(t, err, "Failed to insert song: %s", songName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
|||||||
|
|
||||||
// SyncSoundtracksNewOnlyChanges godoc
|
// SyncSoundtracksNewOnlyChanges godoc
|
||||||
// @Summary Sync soundtracks with only changes
|
// @Summary Sync soundtracks with only changes
|
||||||
// @Description Starts syncing games with only new changes
|
// @Description Starts syncing soundtracks with only new changes
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -44,18 +44,15 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /sync [get]
|
// @Router /sync [get]
|
||||||
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Warn("Syncing is already in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
logging.GetLogger().Info("Starting sync with only changes")
|
logging.GetLogger().Info("Starting sync with only changes")
|
||||||
go backend.SyncSoundtracksNewOnlyChanges()
|
backend.Syncing = true
|
||||||
|
go backend.SyncSoundtracksOnlyChanges()
|
||||||
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
|
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncSoundtracksNewFull godoc
|
// SyncSoundtracksNewFull godoc
|
||||||
// @Summary Sync all games fully
|
// @Summary Sync all soundtracks fully
|
||||||
// @Description Starts a full sync of all games
|
// @Description Starts a full sync of all soundtracks
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -63,18 +60,15 @@ func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /sync/full [get]
|
// @Router /sync/full [get]
|
||||||
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Warn("Syncing is already in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
logging.GetLogger().Info("Starting full sync")
|
logging.GetLogger().Info("Starting full sync")
|
||||||
go backend.SyncSoundtracksNewFull()
|
backend.Syncing = true
|
||||||
|
go backend.SyncSoundtracksFull()
|
||||||
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
|
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetDB godoc
|
// ResetDB godoc
|
||||||
// @Summary Reset soundtracks database
|
// @Summary Reset soundtracks database
|
||||||
// @Description Resets the games database by deleting all games and songs
|
// @Description Resets the soundtracks database by deleting all soundtracks and songs
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -82,10 +76,6 @@ func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
|||||||
// @Failure 423 {string} string "Syncing is in progress"
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
// @Router /sync/reset [get]
|
// @Router /sync/reset [get]
|
||||||
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
|
||||||
logging.GetLogger().Warn("Cannot reset - syncing is in progress")
|
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
|
||||||
}
|
|
||||||
logging.GetLogger().Info("Resetting soundtracks database")
|
logging.GetLogger().Info("Resetting soundtracks database")
|
||||||
backend.ResetDB()
|
backend.ResetDB()
|
||||||
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
|
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSyncPopulatesDatabase verifies that sync populates the database with games
|
// TestSyncPopulatesDatabase verifies that sync populates the database with soundtracks
|
||||||
func TestSyncPopulatesDatabase(t *testing.T) {
|
func TestSyncPopulatesDatabase(t *testing.T) {
|
||||||
db.TestSetupDB(t)
|
db.TestSetupDB(t)
|
||||||
defer db.TestTearDownDB(t)
|
defer db.TestTearDownDB(t)
|
||||||
@@ -74,12 +74,12 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
|||||||
// Clear any existing data first
|
// Clear any existing data first
|
||||||
db.TestClearDatabase(t)
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
// Before sync - should have no games
|
// Before sync - should have no soundtracks
|
||||||
repo := repository.New(backend.BackendPool())
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(soundtracksBefore)
|
||||||
t.Logf("Games before sync: %d", beforeCount)
|
t.Logf("Soundtracks before sync: %d", beforeCount)
|
||||||
assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
|
assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
|
||||||
|
|
||||||
// Run sync
|
// Run sync
|
||||||
@@ -91,14 +91,14 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
|||||||
t.Error("Sync did not complete within timeout")
|
t.Error("Sync did not complete within timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
// After sync - should have games
|
// After sync - should have soundtracks
|
||||||
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(soundtracksAfter)
|
||||||
t.Logf("Games after sync: %d", afterCount)
|
t.Logf("Soundtracks after sync: %d", afterCount)
|
||||||
|
|
||||||
// Should have more games than before (unless database was already populated)
|
// Should have more soundtracks than before (unless database was already populated)
|
||||||
assert.True(t, afterCount > 0, "Database should have games after sync")
|
assert.True(t, afterCount > 0, "Database should have soundtracks after sync")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSyncMakesDifference verifies that sync actually changes the database state
|
// TestSyncMakesDifference verifies that sync actually changes the database state
|
||||||
@@ -111,11 +111,11 @@ func TestSyncMakesDifference(t *testing.T) {
|
|||||||
// Clear any existing data first
|
// Clear any existing data first
|
||||||
db.TestClearDatabase(t)
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
// Before sync - should have no games
|
// Before sync - should have no soundtracks
|
||||||
repo := repository.New(backend.BackendPool())
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync")
|
assert.Equal(t, 0, len(soundtracksBefore), "Should have no soundtracks before sync")
|
||||||
|
|
||||||
// Run sync
|
// Run sync
|
||||||
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
@@ -126,10 +126,10 @@ func TestSyncMakesDifference(t *testing.T) {
|
|||||||
t.Error("Sync did not complete within timeout")
|
t.Error("Sync did not complete within timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
// After sync - should have games
|
// After sync - should have soundtracks
|
||||||
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, len(gamesAfter) > 0, "Should have games after sync")
|
assert.True(t, len(soundtracksAfter) > 0, "Should have soundtracks after sync")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSyncProgress verifies the sync progress endpoint
|
// TestSyncProgress verifies the sync progress endpoint
|
||||||
@@ -183,8 +183,8 @@ func TestSyncProgress(t *testing.T) {
|
|||||||
assert.True(t, foundComplete, "Should have seen completion")
|
assert.True(t, foundComplete, "Should have seen completion")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSyncGamesNewOnlyChanges verifies the incremental sync endpoint
|
// TestSyncSoundtracksNewOnlyChanges verifies the incremental sync endpoint
|
||||||
func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
func TestSyncSoundtracksNewOnlyChanges(t *testing.T) {
|
||||||
db.TestSetupDB(t)
|
db.TestSetupDB(t)
|
||||||
defer db.TestTearDownDB(t)
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
@@ -200,8 +200,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
|||||||
|
|
||||||
// Get initial count
|
// Get initial count
|
||||||
repo := repository.New(backend.BackendPool())
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(soundtracksBefore)
|
||||||
|
|
||||||
// Run incremental sync (should not change count if nothing changed)
|
// Run incremental sync (should not change count if nothing changed)
|
||||||
resp := MakeTestRequest(t, e, "GET", "/sync/new")
|
resp := MakeTestRequest(t, e, "GET", "/sync/new")
|
||||||
@@ -211,16 +211,16 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
|||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
// Count should be the same
|
// Count should be the same
|
||||||
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(soundtracksAfter)
|
||||||
|
|
||||||
// Note: This might not be exactly equal due to timing, but should be close
|
// Note: This might not be exactly equal due to timing, but should be close
|
||||||
t.Logf("Games before incremental sync: %d, after: %d", beforeCount, afterCount)
|
t.Logf("Soundtracks before incremental sync: %d, after: %d", beforeCount, afterCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestResetGames verifies the reset endpoint clears the database
|
// TestResetSoundtracks verifies the reset endpoint clears the database
|
||||||
// RUN THIS LAST
|
// RUN THIS LAST
|
||||||
func TestResetGames(t *testing.T) {
|
func TestResetSoundtracks(t *testing.T) {
|
||||||
db.TestSetupDB(t)
|
db.TestSetupDB(t)
|
||||||
defer db.TestTearDownDB(t)
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
@@ -228,8 +228,8 @@ func TestResetGames(t *testing.T) {
|
|||||||
|
|
||||||
// First ensure we have data
|
// First ensure we have data
|
||||||
repo := repository.New(backend.BackendPool())
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(soundtracksBefore)
|
||||||
|
|
||||||
if beforeCount == 0 {
|
if beforeCount == 0 {
|
||||||
// Run sync to populate
|
// Run sync to populate
|
||||||
@@ -238,12 +238,12 @@ func TestResetGames(t *testing.T) {
|
|||||||
t.Error("Sync did not complete within timeout")
|
t.Error("Sync did not complete within timeout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gamesBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
beforeCount = len(gamesBefore)
|
beforeCount = len(soundtracksBefore)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Games before reset: %d", beforeCount)
|
t.Logf("Soundtracks before reset: %d", beforeCount)
|
||||||
assert.True(t, beforeCount > 0, "Should have games to reset")
|
assert.True(t, beforeCount > 0, "Should have soundtracks to reset")
|
||||||
|
|
||||||
// Call reset
|
// Call reset
|
||||||
resp := MakeTestRequest(t, e, "GET", "/sync/reset")
|
resp := MakeTestRequest(t, e, "GET", "/sync/reset")
|
||||||
@@ -253,16 +253,16 @@ func TestResetGames(t *testing.T) {
|
|||||||
// Note: reset might take a moment to propagate
|
// Note: reset might take a moment to propagate
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(soundtracksAfter)
|
||||||
|
|
||||||
t.Logf("Games after reset: %d", afterCount)
|
t.Logf("Soundtracks after reset: %d", afterCount)
|
||||||
assert.Equal(t, 0, afterCount, "Database should be empty after reset")
|
assert.Equal(t, 0, afterCount, "Database should be empty after reset")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSyncGamesNewFull verifies the full sync endpoint
|
// TestSyncSoundtracksNewFull verifies the full sync endpoint
|
||||||
// RUN THIS LAST (before TestResetGames)
|
// RUN THIS LAST (before TestResetSoundtracks)
|
||||||
func TestSyncGamesNewFull(t *testing.T) {
|
func TestSyncSoundtracksNewFull(t *testing.T) {
|
||||||
db.TestSetupDB(t)
|
db.TestSetupDB(t)
|
||||||
defer db.TestTearDownDB(t)
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
@@ -282,8 +282,8 @@ func TestSyncGamesNewFull(t *testing.T) {
|
|||||||
|
|
||||||
// Verify database is populated
|
// Verify database is populated
|
||||||
repo := repository.New(backend.BackendPool())
|
repo := repository.New(backend.BackendPool())
|
||||||
games, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, len(games) > 0, "Database should be populated after full sync")
|
assert.True(t, len(soundtracks) > 0, "Database should be populated after full sync")
|
||||||
t.Logf("Full sync populated %d games", len(games))
|
t.Logf("Full sync populated %d soundtracks", len(soundtracks))
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,7 @@ func StartTestServer(t *testing.T) *echo.Echo {
|
|||||||
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()
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,22 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenRequest represents a request to generate a new token
|
// TokenRequest represents a request to generate a new token
|
||||||
type TokenRequest struct {
|
type TokenRequest struct {
|
||||||
|
Password string `json:"password"` // Required: API password
|
||||||
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +55,14 @@ func (h *TokenHandler) generateToken() (string, error) {
|
|||||||
// POST /api/v1/token
|
// POST /api/v1/token
|
||||||
//
|
//
|
||||||
// @Summary Create session token
|
// @Summary Create session token
|
||||||
// @Description Returns a new session token for API access
|
// @Description Returns a new session token for API access. Requires valid API password.
|
||||||
// @Tags auth
|
// @Tags auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body TokenRequest true "Client type"
|
// @Param request body TokenRequest true "Password and client type"
|
||||||
// @Success 200 {object} TokenResponse
|
// @Success 200 {object} TokenResponse
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/token [post]
|
// @Router /api/v1/token [post]
|
||||||
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||||
@@ -68,6 +71,27 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if session auth is disabled
|
||||||
|
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
|
||||||
|
apiPassword := os.Getenv("API_PASSWORD")
|
||||||
|
|
||||||
|
// Password validation logic:
|
||||||
|
// - If session auth is disabled, skip validation
|
||||||
|
// - If API_PASSWORD is empty, allow any password (open access mode)
|
||||||
|
// - If API_PASSWORD is set, require exact password match
|
||||||
|
if !disableSessionAuth {
|
||||||
|
if apiPassword != "" {
|
||||||
|
// API password is set - require exact match
|
||||||
|
if req.Password != apiPassword {
|
||||||
|
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
||||||
|
zap.String("ip", c.RealIP()),
|
||||||
|
zap.String("user_agent", c.Request().UserAgent()))
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If API_PASSWORD is empty, allow any password through (open access mode)
|
||||||
|
}
|
||||||
|
|
||||||
if req.ClientType == "" {
|
if req.ClientType == "" {
|
||||||
req.ClientType = "web"
|
req.ClientType = "web"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import (
|
|||||||
// ensureSyncRan ensures that sync has been run before testing music endpoints
|
// ensureSyncRan ensures that sync has been run before testing music endpoints
|
||||||
func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
||||||
repo := repository.New(backend.BackendPool())
|
repo := repository.New(backend.BackendPool())
|
||||||
games, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
if len(games) == 0 {
|
if len(soundtracks) == 0 {
|
||||||
// Run sync
|
// Run sync
|
||||||
t.Log("No games found, running sync first...")
|
t.Log("No soundtracks found, running sync first...")
|
||||||
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetAllGames verifies the /music/all/order endpoint
|
// TestGetAllSoundtracks verifies the /music/all/order endpoint
|
||||||
func TestZGetAllGames(t *testing.T) {
|
func TestZGetAllSoundtracks(t *testing.T) {
|
||||||
db.TestSetupDB(t)
|
db.TestSetupDB(t)
|
||||||
defer db.TestTearDownDB(t)
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
@@ -45,15 +45,15 @@ func TestZGetAllGames(t *testing.T) {
|
|||||||
resp := MakeTestRequest(t, e, "GET", "/music/all/order")
|
resp := MakeTestRequest(t, e, "GET", "/music/all/order")
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
var games []string
|
var soundtracks []string
|
||||||
err := json.Unmarshal(resp.Body.Bytes(), &games)
|
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, games, "Should have games after sync")
|
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
|
||||||
t.Logf("Found %d games", len(games))
|
t.Logf("Found %d soundtracks", len(soundtracks))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetAllGamesRandom verifies the /music/all/random endpoint
|
// TestGetAllSoundtracksRandom verifies the /music/all/random endpoint
|
||||||
func TestZGetAllGamesRandom(t *testing.T) {
|
func TestZGetAllSoundtracksRandom(t *testing.T) {
|
||||||
db.TestSetupDB(t)
|
db.TestSetupDB(t)
|
||||||
defer db.TestTearDownDB(t)
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
@@ -65,17 +65,17 @@ func TestZGetAllGamesRandom(t *testing.T) {
|
|||||||
resp := MakeTestRequest(t, e, "GET", "/music/all/random")
|
resp := MakeTestRequest(t, e, "GET", "/music/all/random")
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
var games []string
|
var soundtracks []string
|
||||||
err := json.Unmarshal(resp.Body.Bytes(), &games)
|
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, games, "Should have games after sync")
|
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
|
||||||
|
|
||||||
// Verify it's shuffled (not in original order)
|
// Verify it's shuffled (not in original order)
|
||||||
// We can't easily verify randomness, but we can check it's the same length
|
// We can't easily verify randomness, but we can check it's the same length
|
||||||
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
|
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
|
||||||
var gamesOrdered []string
|
var soundtracksOrdered []string
|
||||||
json.Unmarshal(resp2.Body.Bytes(), &gamesOrdered)
|
json.Unmarshal(resp2.Body.Bytes(), &soundtracksOrdered)
|
||||||
assert.Equal(t, len(games), len(gamesOrdered), "Random and ordered should have same count")
|
assert.Equal(t, len(soundtracks), len(soundtracksOrdered), "Random and ordered should have same count")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetRandomSong verifies the /music/rand endpoint
|
// TestGetRandomSong verifies the /music/rand endpoint
|
||||||
@@ -153,7 +153,7 @@ func TestZGetSongInfo(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
// Note: CurrentlyPlaying might be false if no song is currently set
|
// Note: CurrentlyPlaying might be false if no song is currently set
|
||||||
// Just verify we got a valid response
|
// Just verify we got a valid response
|
||||||
t.Logf("Song info: Game=%s, Song=%s", info.Game, info.Song)
|
t.Logf("Song info: Soundtrack=%s, Song=%s", info.Soundtrack, info.Song)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetPlayedSongs verifies the /music/list endpoint
|
// TestGetPlayedSongs verifies the /music/list endpoint
|
||||||
|
|||||||
@@ -84,60 +84,88 @@ build-run: build
|
|||||||
@go run cmd/main.go
|
@go run cmd/main.go
|
||||||
|
|
||||||
test: build
|
test: build
|
||||||
@echo "Testing..."
|
@echo "Starting test database container..."
|
||||||
@go test ./... -v
|
@docker 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:
|
||||||
@echo "Cleaning..."
|
@echo "Cleaning..."
|
||||||
@rm -f main
|
@rm -f main
|
||||||
|
|
||||||
podman-build:
|
docker-build:
|
||||||
@echo "Building Docker image with podman..."
|
@echo "Building Docker image with docker..."
|
||||||
@podman build -t music-server .
|
@docker build -t music-server .
|
||||||
|
|
||||||
podman-run:
|
docker-run:
|
||||||
@podman-compose up --build
|
@docker compose up --build
|
||||||
|
|
||||||
podman-down:
|
docker-down:
|
||||||
@podman-compose down
|
@docker compose down
|
||||||
|
|
||||||
# Run integration tests with podman
|
# Run integration tests with docker
|
||||||
# Starts a test PostgreSQL container, runs tests, then cleans up
|
# Starts a test PostgreSQL container, runs tests, then cleans up
|
||||||
test-integration:
|
test-integration:
|
||||||
@echo "Starting test database container..."
|
@echo "Cleaning old test database..."
|
||||||
@podman-compose -f compose.test.yaml up -d
|
@docker compose -f compose.test.yaml down -v
|
||||||
|
@echo "Starting fresh test database container..."
|
||||||
|
@docker compose -f compose.test.yaml up -d
|
||||||
@sleep 10
|
@sleep 10
|
||||||
@echo "Running integration tests..."
|
@echo "Running integration tests..."
|
||||||
@DB_HOST=localhost DB_PORT=5433 DB_USERNAME=testuser DB_PASSWORD=testpass DB_NAME=music_server_test MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters PORT=8081 LOG_LEVEL=debug LOG_JSON=false go test -v -timeout 30m -p 1 -parallel 1 ./internal/...
|
@DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net DB_HOST=localhost DB_PORT=5433 DB_USERNAME=testuser DB_PASSWORD=testpass DB_NAME=music_server_test MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters PORT=8081 LOG_LEVEL=debug LOG_JSON=false go test -v -timeout 30m -p 1 -parallel 1 ./internal/...
|
||||||
|
|
||||||
# Alternative: Run integration tests using testcontainers with podman provider
|
# Alternative: Run integration tests using testcontainers with docker provider
|
||||||
test-integration-tc:
|
test-integration-tc:
|
||||||
@echo "Running integration tests with testcontainers (podman provider)..."
|
@echo "Creating test network (if it doesn't exist)..."
|
||||||
@TESTCONTAINERS_PROVIDER=podman go test -v -timeout 30m .
|
@docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
|
||||||
|
@echo "Running integration tests with testcontainers (docker provider)..."
|
||||||
|
@DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 30m .
|
||||||
|
|
||||||
|
# Run SQL dump migration tests
|
||||||
|
# Tests that the application can start with each SQL dump and migrations work correctly
|
||||||
|
test-sql-dump:
|
||||||
|
@echo "Creating test network (if it doesn't exist)..."
|
||||||
|
@docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
|
||||||
|
@echo "Running SQL dump migration tests..."
|
||||||
|
@DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 15m -run "TestMusic.*Dump" ./internal/server/
|
||||||
|
|
||||||
|
# Run individual SQL dump tests
|
||||||
|
test-sql-dump-ref:
|
||||||
|
@echo "Creating test network (if it doesn't exist)..."
|
||||||
|
@docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
|
||||||
|
@echo "Running SQL dump migration test for ref dump..."
|
||||||
|
@DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicRefDump" ./internal/server/
|
||||||
|
|
||||||
|
test-sql-dump-test:
|
||||||
|
@echo "Creating test network (if it doesn't exist)..."
|
||||||
|
@docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
|
||||||
|
@echo "Running SQL dump migration test for test dump..."
|
||||||
|
@DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicTestDump" ./internal/server/
|
||||||
|
|
||||||
|
test-sql-dump-prod:
|
||||||
|
@echo "Creating test network (if it doesn't exist)..."
|
||||||
|
@docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net
|
||||||
|
@echo "Running SQL dump migration test for prod dump..."
|
||||||
|
@DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicProdDump" ./internal/server/
|
||||||
|
|
||||||
# Stop and remove test database container
|
# Stop and remove test database container
|
||||||
test-integration-down:
|
test-integration-down:
|
||||||
@echo "Stopping test database container..."
|
@echo "Stopping test database container..."
|
||||||
@podman-compose -f compose.test.yaml down -v
|
@docker compose -f compose.test.yaml down -v
|
||||||
|
|
||||||
# Create DB container
|
# Build and run with Docker Compose
|
||||||
docker-run:
|
build-run-docker:
|
||||||
@if docker compose up --build 2>/dev/null; then \
|
@echo "Building and running with Docker Compose..."
|
||||||
: ; \
|
@docker compose up --build
|
||||||
else \
|
|
||||||
echo "Falling back to Docker Compose V1"; \
|
|
||||||
docker-compose up --build; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Shutdown DB container
|
# Shutdown containers
|
||||||
docker-down:
|
stop-docker:
|
||||||
@if docker compose down 2>/dev/null; then \
|
@echo "Stopping containers..."
|
||||||
: ; \
|
@docker compose down
|
||||||
else \
|
|
||||||
echo "Falling back to Docker Compose V1"; \
|
|
||||||
docker-compose down; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
watch-templ:
|
watch-templ:
|
||||||
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."
|
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."
|
||||||
|
|||||||
@@ -39,3 +39,12 @@ sql:
|
|||||||
go_type:
|
go_type:
|
||||||
import: "time"
|
import: "time"
|
||||||
type: "Time"
|
type: "Time"
|
||||||
|
- db_type: "uuid"
|
||||||
|
go_type:
|
||||||
|
import: "github.com/google/uuid"
|
||||||
|
type: "UUID"
|
||||||
|
- db_type: "uuid"
|
||||||
|
nullable: true
|
||||||
|
go_type:
|
||||||
|
import: "github.com/google/uuid"
|
||||||
|
type: "UUID"
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Reference in new issue
Block a user