Compare commits
19 Commits
9256b7fe4b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| dbef39b828 | |||
| 4e5bdc4ee2 | |||
| 0894d65ec5 | |||
| 4033899a68 | |||
| c6a07e69e7 | |||
| 6d4a034753 | |||
| b0418b4f38 | |||
| 176848bb6d | |||
| fb387901cf | |||
| 0f29c33b1a | |||
| cec408187d | |||
| c60f40d7e3 | |||
| 2f407f6eef | |||
| 4c2db11cc5 | |||
| 06cbad708d | |||
| 89e884fae9 | |||
| 24a9111333 | |||
| 6cc014ffa3 | |||
| 8f8b555ea5 |
+17
-11
@@ -1,26 +1,35 @@
|
|||||||
|
# Stage 1: Build frontend
|
||||||
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
WORKDIR /app
|
||||||
|
RUN git clone https://gitea.sanplex.xyz/Sansan/MusicFrontend.git
|
||||||
|
WORKDIR /app/MusicFrontend
|
||||||
|
RUN npm install
|
||||||
|
RUN npm run build
|
||||||
|
# Generate config.js with empty API_HOSTNAME (relative paths)
|
||||||
|
RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > dist/config.js
|
||||||
|
|
||||||
|
# Stage 2: Build backend
|
||||||
FROM golang:1.25-alpine as build_go
|
FROM golang:1.25-alpine as build_go
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
|
||||||
RUN templ generate
|
RUN templ generate
|
||||||
|
|
||||||
RUN go build -o main cmd/main.go
|
RUN go build -o main cmd/main.go
|
||||||
|
|
||||||
# Stage 2, distribution container
|
# Stage 3: Final image
|
||||||
FROM golang:1.25-alpine
|
FROM golang:1.25-alpine
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME /sorted
|
VOLUME /sorted
|
||||||
VOLUME /frontend
|
|
||||||
VOLUME /characters
|
VOLUME /characters
|
||||||
|
|
||||||
|
COPY --from=build_go /app/main .
|
||||||
|
COPY --from=frontend-builder /app/MusicFrontend/dist /frontend
|
||||||
|
COPY ./songs/ ./songs/
|
||||||
|
|
||||||
ENV PORT 8080
|
ENV PORT 8080
|
||||||
ENV DB_HOST ""
|
ENV DB_HOST ""
|
||||||
ENV DB_PORT ""
|
ENV DB_PORT ""
|
||||||
@@ -30,7 +39,4 @@ ENV DB_NAME ""
|
|||||||
ENV MUSIC_PATH ""
|
ENV MUSIC_PATH ""
|
||||||
ENV CHARACTERS_PATH ""
|
ENV CHARACTERS_PATH ""
|
||||||
|
|
||||||
COPY --from=build_go /app/main .
|
|
||||||
COPY ./songs/ ./songs/
|
|
||||||
|
|
||||||
CMD ./main
|
CMD ./main
|
||||||
|
|||||||
+676
-39
@@ -23,6 +23,539 @@ var doc = `{
|
|||||||
"host": "{{.Host}}",
|
"host": "{{.Host}}",
|
||||||
"basePath": "{{.BasePath}}",
|
"basePath": "{{.BasePath}}",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/api/v1/statistics/games/last-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the most recently played games",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get last played games",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/games/least-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N least played games with their songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get least played games",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/games/most-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N most played games with their songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get most played games",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/games/never-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns all games that have never been played (times_played = 0)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get never played games",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/games/oldest-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the least recently played games (that have been played at least once)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get oldest played games",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/songs/least-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N least played songs with their game info",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get least played songs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/songs/most-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N most played songs with their game info",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get most played songs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/summary": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns overall statistics about the music library",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get statistics summary",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/backend.StatisticsSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/token": {
|
||||||
|
"post": {
|
||||||
|
"description": "Returns a new session token for API access",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Create session token",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Client type",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.TokenRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.TokenResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"description": "Deletes the current session token",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Invalidate session token",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Bearer token",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/token/cleanup": {
|
||||||
|
"post": {
|
||||||
|
"description": "Removes all expired session tokens from the database",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Cleanup expired sessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Bearer token",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/character": {
|
"/character": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the image for a specific character",
|
"description": "Returns the image for a specific character",
|
||||||
@@ -81,29 +614,6 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/dbtest": {
|
|
||||||
"get": {
|
|
||||||
"description": "Tests the database connection",
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database"
|
|
||||||
],
|
|
||||||
"summary": "Test database connection",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "TestedDB",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/download": {
|
"/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Checks for the latest version of the application",
|
"description": "Checks for the latest version of the application",
|
||||||
@@ -324,7 +834,7 @@ var doc = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Get all games",
|
"summary": "Get all soundtracks",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -357,7 +867,7 @@ var doc = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Get all games random",
|
"summary": "Get all soundtracks random",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -697,10 +1207,10 @@ var doc = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
],
|
],
|
||||||
"summary": "Sync games with only changes",
|
"summary": "Sync soundtracks with only changes",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Start syncing games",
|
"description": "Start syncing soundtracks",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -729,7 +1239,7 @@ var doc = `{
|
|||||||
"summary": "Sync all games fully",
|
"summary": "Sync all games fully",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Start syncing games full",
|
"description": "Start syncing soundtracks full",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -779,10 +1289,10 @@ var doc = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
],
|
],
|
||||||
"summary": "Reset games database",
|
"summary": "Reset soundtracks database",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Games and songs are deleted from the database",
|
"description": "Soundtracks and songs are deleted from the database",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -798,7 +1308,7 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"/version": {
|
"/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get string by ID",
|
"description": "get latest version info",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -806,9 +1316,9 @@ var doc = `{
|
|||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"accounts"
|
"version"
|
||||||
],
|
],
|
||||||
"summary": "Getting the version of the backend",
|
"summary": "Getting the latest version of the backend",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -824,27 +1334,154 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/version/history": {
|
||||||
|
"get": {
|
||||||
|
"description": "get version history",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"summary": "Getting the version history of the backend",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.VersionData"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
|
"backend.GameWithSongs": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"game_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"game_last_played": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"game_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"game_played": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"songs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.SongInfoForStats": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"game_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"game_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"song_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"times_played": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.StatisticsSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"avg_game_plays": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"max_game_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"min_game_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"never_played_games": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"played_games": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_game_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_games": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"backend.VersionData": {
|
"backend.VersionData": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"changelog": {
|
"changelog": {
|
||||||
"type": "string",
|
|
||||||
"example": "account name"
|
|
||||||
},
|
|
||||||
"history": {
|
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/backend.VersionData"
|
"type": "string"
|
||||||
}
|
},
|
||||||
|
"example": [
|
||||||
|
"[\"Initial release\"",
|
||||||
|
"\"Bug fixes\"]"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "1.0.0"
|
"example": "1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"server.TokenRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"client_type": {
|
||||||
|
"description": "Optional: \"web\", \"mobile\", \"api\"",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.TokenResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"client_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|||||||
+676
-39
@@ -4,6 +4,539 @@
|
|||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/api/v1/statistics/games/last-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the most recently played games",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get last played games",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/games/least-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N least played games with their songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get least played games",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/games/most-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N most played games with their songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get most played games",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/games/never-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns all games that have never been played (times_played = 0)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get never played games",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/games/oldest-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the least recently played games (that have been played at least once)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get oldest played games",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.GameWithSongs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/songs/least-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N least played songs with their game info",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get least played songs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/songs/most-played": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the top N most played songs with their game info",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get most played songs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results (default: 10)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/statistics/summary": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns overall statistics about the music library",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"statistics"
|
||||||
|
],
|
||||||
|
"summary": "Get statistics summary",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/backend.StatisticsSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/token": {
|
||||||
|
"post": {
|
||||||
|
"description": "Returns a new session token for API access",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Create session token",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Client type",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.TokenRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/server.TokenResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"description": "Deletes the current session token",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Invalidate session token",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Bearer token",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/token/cleanup": {
|
||||||
|
"post": {
|
||||||
|
"description": "Removes all expired session tokens from the database",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Cleanup expired sessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Bearer token",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/character": {
|
"/character": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns the image for a specific character",
|
"description": "Returns the image for a specific character",
|
||||||
@@ -62,29 +595,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/dbtest": {
|
|
||||||
"get": {
|
|
||||||
"description": "Tests the database connection",
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database"
|
|
||||||
],
|
|
||||||
"summary": "Test database connection",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "TestedDB",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/download": {
|
"/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Checks for the latest version of the application",
|
"description": "Checks for the latest version of the application",
|
||||||
@@ -305,7 +815,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Get all games",
|
"summary": "Get all soundtracks",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -338,7 +848,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"music"
|
"music"
|
||||||
],
|
],
|
||||||
"summary": "Get all games random",
|
"summary": "Get all soundtracks random",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -678,10 +1188,10 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
],
|
],
|
||||||
"summary": "Sync games with only changes",
|
"summary": "Sync soundtracks with only changes",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Start syncing games",
|
"description": "Start syncing soundtracks",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -710,7 +1220,7 @@
|
|||||||
"summary": "Sync all games fully",
|
"summary": "Sync all games fully",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Start syncing games full",
|
"description": "Start syncing soundtracks full",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -760,10 +1270,10 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"sync"
|
"sync"
|
||||||
],
|
],
|
||||||
"summary": "Reset games database",
|
"summary": "Reset soundtracks database",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Games and songs are deleted from the database",
|
"description": "Soundtracks and songs are deleted from the database",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -779,7 +1289,7 @@
|
|||||||
},
|
},
|
||||||
"/version": {
|
"/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get string by ID",
|
"description": "get latest version info",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -787,9 +1297,9 @@
|
|||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"accounts"
|
"version"
|
||||||
],
|
],
|
||||||
"summary": "Getting the version of the backend",
|
"summary": "Getting the latest version of the backend",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -805,27 +1315,154 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/version/history": {
|
||||||
|
"get": {
|
||||||
|
"description": "get version history",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"summary": "Getting the version history of the backend",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.VersionData"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
|
"backend.GameWithSongs": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"game_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"game_last_played": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"game_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"game_played": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"songs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/backend.SongInfoForStats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.SongInfoForStats": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"game_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"game_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"song_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"times_played": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend.StatisticsSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"avg_game_plays": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"max_game_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"min_game_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"never_played_games": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"played_games": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_game_plays": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_games": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"backend.VersionData": {
|
"backend.VersionData": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"changelog": {
|
"changelog": {
|
||||||
"type": "string",
|
|
||||||
"example": "account name"
|
|
||||||
},
|
|
||||||
"history": {
|
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/backend.VersionData"
|
"type": "string"
|
||||||
}
|
},
|
||||||
|
"example": [
|
||||||
|
"[\"Initial release\"",
|
||||||
|
"\"Bug fixes\"]"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "1.0.0"
|
"example": "1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"server.TokenRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"client_type": {
|
||||||
|
"description": "Optional: \"web\", \"mobile\", \"api\"",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server.TokenResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"client_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+448
-29
@@ -1,20 +1,433 @@
|
|||||||
definitions:
|
definitions:
|
||||||
|
backend.GameWithSongs:
|
||||||
|
properties:
|
||||||
|
game_id:
|
||||||
|
type: integer
|
||||||
|
game_last_played:
|
||||||
|
type: string
|
||||||
|
game_name:
|
||||||
|
type: string
|
||||||
|
game_played:
|
||||||
|
type: integer
|
||||||
|
songs:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SongInfoForStats'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
backend.SongInfoForStats:
|
||||||
|
properties:
|
||||||
|
file_name:
|
||||||
|
type: string
|
||||||
|
game_id:
|
||||||
|
type: integer
|
||||||
|
game_name:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
song_name:
|
||||||
|
type: string
|
||||||
|
times_played:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
backend.StatisticsSummary:
|
||||||
|
properties:
|
||||||
|
avg_game_plays:
|
||||||
|
type: number
|
||||||
|
max_game_plays:
|
||||||
|
type: integer
|
||||||
|
min_game_plays:
|
||||||
|
type: integer
|
||||||
|
never_played_games:
|
||||||
|
type: integer
|
||||||
|
played_games:
|
||||||
|
type: integer
|
||||||
|
total_game_plays:
|
||||||
|
type: integer
|
||||||
|
total_games:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
backend.VersionData:
|
backend.VersionData:
|
||||||
properties:
|
properties:
|
||||||
changelog:
|
changelog:
|
||||||
example: account name
|
example:
|
||||||
type: string
|
- '["Initial release"'
|
||||||
history:
|
- '"Bug fixes"]'
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/backend.VersionData'
|
type: string
|
||||||
type: array
|
type: array
|
||||||
version:
|
version:
|
||||||
example: 1.0.0
|
example: 1.0.0
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
server.TokenRequest:
|
||||||
|
properties:
|
||||||
|
client_type:
|
||||||
|
description: 'Optional: "web", "mobile", "api"'
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
server.TokenResponse:
|
||||||
|
properties:
|
||||||
|
client_type:
|
||||||
|
type: string
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
paths:
|
paths:
|
||||||
|
/api/v1/statistics/games/last-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the most recently played games
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.GameWithSongs'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get last played games
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/games/least-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the top N least played games with their songs
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.GameWithSongs'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get least played games
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/games/most-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the top N most played games with their songs
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.GameWithSongs'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get most played games
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/games/never-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns all games that have never been played (times_played = 0)
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.GameWithSongs'
|
||||||
|
type: array
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get never played games
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/games/oldest-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the least recently played games (that have been played
|
||||||
|
at least once)
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.GameWithSongs'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get oldest played games
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/songs/least-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the top N least played songs with their game info
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SongInfoForStats'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get least played songs
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/songs/most-played:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the top N most played songs with their game info
|
||||||
|
parameters:
|
||||||
|
- description: 'Number of results (default: 10)'
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.SongInfoForStats'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get most played songs
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/statistics/summary:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns overall statistics about the music library
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/backend.StatisticsSummary'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Get statistics summary
|
||||||
|
tags:
|
||||||
|
- statistics
|
||||||
|
/api/v1/token:
|
||||||
|
delete:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Deletes the current session token
|
||||||
|
parameters:
|
||||||
|
- description: Bearer token
|
||||||
|
in: header
|
||||||
|
name: Authorization
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Invalidate session token
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns a new session token for API access
|
||||||
|
parameters:
|
||||||
|
- description: Client type
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/server.TokenRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/server.TokenResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Create session token
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
|
/api/v1/token/cleanup:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Removes all expired session tokens from the database
|
||||||
|
parameters:
|
||||||
|
- description: Bearer token
|
||||||
|
in: header
|
||||||
|
name: Authorization
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
summary: Cleanup expired sessions
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
/character:
|
/character:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
@@ -53,21 +466,6 @@ paths:
|
|||||||
summary: Get list of characters
|
summary: Get list of characters
|
||||||
tags:
|
tags:
|
||||||
- characters
|
- characters
|
||||||
/dbtest:
|
|
||||||
get:
|
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Tests the database connection
|
|
||||||
produces:
|
|
||||||
- application/json
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: TestedDB
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
summary: Test database connection
|
|
||||||
tags:
|
|
||||||
- database
|
|
||||||
/download:
|
/download:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
@@ -223,7 +621,7 @@ paths:
|
|||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
summary: Get all games
|
summary: Get all soundtracks
|
||||||
tags:
|
tags:
|
||||||
- music
|
- music
|
||||||
/music/all/random:
|
/music/all/random:
|
||||||
@@ -245,7 +643,7 @@ paths:
|
|||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
summary: Get all games random
|
summary: Get all soundtracks random
|
||||||
tags:
|
tags:
|
||||||
- music
|
- music
|
||||||
/music/info:
|
/music/info:
|
||||||
@@ -459,14 +857,14 @@ paths:
|
|||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Start syncing games
|
description: Start syncing soundtracks
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
"423":
|
"423":
|
||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
summary: Sync games with only changes
|
summary: Sync soundtracks with only changes
|
||||||
tags:
|
tags:
|
||||||
- sync
|
- sync
|
||||||
/sync/full:
|
/sync/full:
|
||||||
@@ -478,7 +876,7 @@ paths:
|
|||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Start syncing games full
|
description: Start syncing soundtracks full
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
"423":
|
"423":
|
||||||
@@ -513,21 +911,21 @@ paths:
|
|||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Games and songs are deleted from the database
|
description: Soundtracks and songs are deleted from the database
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
"423":
|
"423":
|
||||||
description: Syncing is in progress
|
description: Syncing is in progress
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
summary: Reset games database
|
summary: Reset soundtracks database
|
||||||
tags:
|
tags:
|
||||||
- sync
|
- sync
|
||||||
/version:
|
/version:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: get string by ID
|
description: get latest version info
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -539,7 +937,28 @@ paths:
|
|||||||
description: Not Found
|
description: Not Found
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
summary: Getting the version of the backend
|
summary: Getting the latest version of the backend
|
||||||
tags:
|
tags:
|
||||||
- accounts
|
- version
|
||||||
|
/version/history:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: get version history
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/backend.VersionData'
|
||||||
|
type: array
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Getting the version history of the backend
|
||||||
|
tags:
|
||||||
|
- version
|
||||||
swagger: "2.0"
|
swagger: "2.0"
|
||||||
|
|||||||
+14
-8
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"music-server/internal/server"
|
"music-server/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,9 +18,11 @@ import (
|
|||||||
// @description This is a sample server Petstore server.
|
// @description This is a sample server Petstore server.
|
||||||
// @termsOfService http://swagger.io/terms/
|
// @termsOfService http://swagger.io/terms/
|
||||||
|
|
||||||
|
//
|
||||||
// @contact.name Sebastian Olsson
|
// @contact.name Sebastian Olsson
|
||||||
// @contact.email zarnor91@gmail.com
|
// @contact.email zarnor91@gmail.com
|
||||||
|
|
||||||
|
//
|
||||||
// @license.name Apache 2.0
|
// @license.name Apache 2.0
|
||||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||||
|
|
||||||
@@ -34,16 +35,17 @@ func main() {
|
|||||||
pprof.StartCPUProfile(f)
|
pprof.StartCPUProfile(f)
|
||||||
defer pprof.StopCPUProfile()*/
|
defer pprof.StopCPUProfile()*/
|
||||||
|
|
||||||
server := server.NewServer()
|
appServer := server.NewServerInstance()
|
||||||
|
httpServer := appServer.HTTPServer()
|
||||||
|
|
||||||
// Create a done channel to signal when the shutdown is complete
|
// Create a done channel to signal when the shutdown is complete
|
||||||
done := make(chan bool, 1)
|
done := make(chan bool, 1)
|
||||||
|
|
||||||
// Run graceful shutdown in a separate goroutine
|
// Run graceful shutdown in a separate goroutine
|
||||||
go gracefulShutdown(server, done)
|
go gracefulShutdown(appServer, httpServer, done)
|
||||||
|
|
||||||
logging.GetLogger().Info("Server starting", zap.String("address", server.Addr))
|
logging.GetLogger().Info("Server starting", zap.String("address", httpServer.Addr))
|
||||||
err := server.ListenAndServe()
|
err := httpServer.ListenAndServe()
|
||||||
if err != nil && err != http.ErrServerClosed {
|
if err != nil && err != http.ErrServerClosed {
|
||||||
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ func main() {
|
|||||||
logging.GetLogger().Info("Graceful shutdown complete")
|
logging.GetLogger().Info("Graceful shutdown complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
func gracefulShutdown(appServer *server.Server, httpServer *http.Server, done chan bool) {
|
||||||
// Create context that listens for the interrupt signal from the OS.
|
// Create context that listens for the interrupt signal from the OS.
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -62,13 +64,17 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
|||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
|
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
|
||||||
db.CloseDb()
|
|
||||||
|
// Close database connection
|
||||||
|
if appServer != nil && appServer.DB() != nil {
|
||||||
|
appServer.DB().Close()
|
||||||
|
}
|
||||||
|
|
||||||
// The context is used to inform the server it has 5 seconds to finish
|
// The context is used to inform the server it has 5 seconds to finish
|
||||||
// the request it is currently handling
|
// the request it is currently handling
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := apiServer.Shutdown(ctx); err != nil {
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -30,7 +30,7 @@ func FindGameWebHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func search(searchText string) {
|
func search(searchText string) {
|
||||||
games_added = nil
|
games_added = nil
|
||||||
games := backend.GetAllGames()
|
games := backend.GetAllSoundtracks()
|
||||||
for _, game := range games {
|
for _, game := range games {
|
||||||
if is_match_exact(searchText, game) {
|
if is_match_exact(searchText, game) {
|
||||||
add_game(game)
|
add_game(game)
|
||||||
|
|||||||
+23
-1
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
templ HelloForm() {
|
templ HelloForm() {
|
||||||
@Base() {
|
@Base() {
|
||||||
|
<button id="dark-mode-toggle">🌙</button>
|
||||||
<div id="search-container">
|
<div id="search-container">
|
||||||
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#games-container"/>
|
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#games-container"/>
|
||||||
<button type="button" id="clear" name="clear">Clear</button>
|
<button type="button" id="clear" name="clear">Clear</button>
|
||||||
@@ -12,8 +13,29 @@ templ HelloForm() {
|
|||||||
if (document.readyState == 'complete') {
|
if (document.readyState == 'complete') {
|
||||||
htmx.ajax('POST', '/find', '#games-container');
|
htmx.ajax('POST', '/find', '#games-container');
|
||||||
document.getElementById("search_term").focus();
|
document.getElementById("search_term").focus();
|
||||||
|
|
||||||
|
// Initialize dark mode from localStorage (default to dark)
|
||||||
|
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||||
|
if (savedTheme === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
document.getElementById('dark-mode-toggle').textContent = '☀️';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dark mode toggle functionality
|
||||||
|
document.getElementById("dark-mode-toggle").addEventListener("click", function() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const currentTheme = html.getAttribute('data-theme');
|
||||||
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
|
||||||
|
html.setAttribute('data-theme', newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
|
||||||
|
// Update toggle button text
|
||||||
|
this.textContent = newTheme === 'dark' ? '☀️' : '🌙';
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("clear").addEventListener("click", function (event) {
|
document.getElementById("clear").addEventListener("click", function (event) {
|
||||||
document.getElementById("search_term").value = "";
|
document.getElementById("search_term").value = "";
|
||||||
htmx.ajax('POST', '/find', '#games-container');
|
htmx.ajax('POST', '/find', '#games-container');
|
||||||
@@ -26,7 +48,7 @@ templ HelloForm() {
|
|||||||
templ FoundGames(games []string) {
|
templ FoundGames(games []string) {
|
||||||
for _, game := range games {
|
for _, game := range games {
|
||||||
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
||||||
<p>{ game }</p>
|
<p class="game-text">{ game }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global variables - these are initialized by InitBackend
|
||||||
|
var (
|
||||||
|
backendPool *pgxpool.Pool
|
||||||
|
repo *repository.Queries
|
||||||
|
backendCtx context.Context = context.Background()
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitBackend initializes the backend package with the database pool.
|
||||||
|
// This should be called once at application startup.
|
||||||
|
func InitBackend(pool *pgxpool.Pool) {
|
||||||
|
backendPool = pool
|
||||||
|
repo = repository.New(pool)
|
||||||
|
backendCtx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendCtx returns the context used by backend operations.
|
||||||
|
// This is exposed for use by the backend functions.
|
||||||
|
func BackendCtx() context.Context {
|
||||||
|
return backendCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendRepo returns the repository queries instance.
|
||||||
|
// This is exposed for use by the backend functions.
|
||||||
|
func BackendRepo() *repository.Queries {
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendPool returns the underlying database pool.
|
||||||
|
// This is exposed for test utilities that need direct pool access.
|
||||||
|
func BackendPool() *pgxpool.Pool {
|
||||||
|
return backendPool
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"music-server/internal/logging"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"music-server/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetCharacterList() []string {
|
func GetCharacterList() []string {
|
||||||
@@ -30,10 +30,10 @@ func GetCharacterList() []string {
|
|||||||
|
|
||||||
func GetCharacter(character string) string {
|
func GetCharacter(character string) string {
|
||||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
charactersPath := os.Getenv("CHARACTERS_PATH")
|
||||||
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath))
|
|
||||||
// Clean the path - remove trailing slashes and then add one for consistency
|
// Clean the path - remove trailing slashes and then add one for consistency
|
||||||
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
||||||
charactersPath += "/"
|
charactersPath += "/"
|
||||||
|
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath+character))
|
||||||
return charactersPath + character
|
return charactersPath + character
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"music-server/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDB() {
|
|
||||||
db.Testf()
|
|
||||||
}
|
|
||||||
|
|
||||||
type VersionData struct {
|
|
||||||
Version string `json:"version" example:"1.0.0"`
|
|
||||||
Changelog string `json:"changelog" example:"account name"`
|
|
||||||
History []VersionData `json:"history"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetVersionHistory() VersionData {
|
|
||||||
data := VersionData{Version: "4.5.0",
|
|
||||||
Changelog: "#1 - Created request to check newest version of the app\n" +
|
|
||||||
"#2 - Added request to download the newest version of the app\n" +
|
|
||||||
"#3 - Added request to check progress during sync\n" +
|
|
||||||
"#4 - Now blocking all request while sync is in progress\n" +
|
|
||||||
"#5 - Implemented ants for thread pooling\n" +
|
|
||||||
"#6 - Changed the sync request to now only start the sync",
|
|
||||||
History: []VersionData{
|
|
||||||
{
|
|
||||||
Version: "4.0.0",
|
|
||||||
Changelog: "Changed framework from gin to Echo\n" +
|
|
||||||
"Reorganized the code\n" +
|
|
||||||
"Implemented sqlc\n" +
|
|
||||||
"Added support to send character images from the server\n" +
|
|
||||||
"Added function to create a new database of no one exists",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "3.2",
|
|
||||||
Changelog: "Upgraded Go version and the version of all dependencies. Fixed som more bugs.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "3.1",
|
|
||||||
Changelog: "Fixed some bugs with songs not found made the application crash. Now checking if song exists and if not, remove song from DB and find another one. Frontend is now decoupled from the backend.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "3.0",
|
|
||||||
Changelog: "Changed routing framework from mux to Gin. Swagger doc is now included in the application. A fronted can now be hosted from the application.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.3.0",
|
|
||||||
Changelog: "Images should not be included in the database, removes songs where the path doesn't work.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.2.0",
|
|
||||||
Changelog: "Changed the structure of the whole application, should be no changes to functionality.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.4",
|
|
||||||
Changelog: "Game list should now be sorted, a new endpoint with the game list in random order have been added.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.3",
|
|
||||||
Changelog: "Added a check to see if song exists before returning it, if not a new song will be picked up.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.2",
|
|
||||||
Changelog: "Added test server to swagger file.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.1",
|
|
||||||
Changelog: "Fixed bug where wrong song was showed as currently played.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.0",
|
|
||||||
Changelog: "Added /addQue to add the last received song to the songQue. " +
|
|
||||||
"Changed /rand and /rand/low to not add song to the que. " +
|
|
||||||
"Changed /next to not call /rand when the end of the que is reached, instead the last song in the que will be resent.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.0.3",
|
|
||||||
Changelog: "Another small change that should fix the caching problem.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.0.2",
|
|
||||||
Changelog: "Hopefully fixed the caching problem with random.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.0.1",
|
|
||||||
Changelog: "Fixed CORS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.0.0",
|
|
||||||
Changelog: "Rebuilt the application in Go.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
+34
-33
@@ -2,7 +2,6 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"os"
|
"os"
|
||||||
@@ -23,23 +22,25 @@ type SongInfo struct {
|
|||||||
|
|
||||||
var currentSong = -1
|
var currentSong = -1
|
||||||
|
|
||||||
var gamesNew []repository.Game
|
var gamesNew []repository.Soundtrack
|
||||||
|
|
||||||
var songQueNew []repository.Song
|
var songQueNew []repository.Song
|
||||||
|
|
||||||
var lastFetchedNew repository.Song
|
var lastFetchedNew repository.Song
|
||||||
var repo *repository.Queries
|
|
||||||
|
|
||||||
func initRepo() {
|
func initRepo() {
|
||||||
if repo == nil {
|
// This function is kept for backward compatibility
|
||||||
repo = repository.New(db.Dbpool)
|
// but now uses the backend package's initialized repo
|
||||||
|
// If not initialized, this will panic intentionally
|
||||||
|
if BackendRepo() == nil {
|
||||||
|
panic("backend not initialized - call backend.InitBackend() first")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAllGames() []repository.Game {
|
func getAllGames() []repository.Soundtrack {
|
||||||
if len(gamesNew) == 0 {
|
if len(gamesNew) == 0 {
|
||||||
initRepo()
|
initRepo()
|
||||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||||
}
|
}
|
||||||
return gamesNew
|
return gamesNew
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ func Reset() {
|
|||||||
songQueNew = nil
|
songQueNew = nil
|
||||||
currentSong = -1
|
currentSong = -1
|
||||||
initRepo()
|
initRepo()
|
||||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddLatestToQue() {
|
func AddLatestToQue() {
|
||||||
@@ -76,8 +77,8 @@ func AddLatestPlayed() {
|
|||||||
currentSongData := songQueNew[currentSong]
|
currentSongData := songQueNew[currentSong]
|
||||||
|
|
||||||
initRepo()
|
initRepo()
|
||||||
repo.AddGamePlayed(db.Ctx, currentSongData.GameID)
|
BackendRepo().AddSoundtrackPlayed(BackendCtx(), currentSongData.SoundtrackID)
|
||||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
|
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: currentSongData.SoundtrackID, SongName: currentSongData.SongName})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetPlayed(songNumber int) {
|
func SetPlayed(songNumber int) {
|
||||||
@@ -86,8 +87,8 @@ func SetPlayed(songNumber int) {
|
|||||||
}
|
}
|
||||||
songData := songQueNew[songNumber]
|
songData := songQueNew[songNumber]
|
||||||
initRepo()
|
initRepo()
|
||||||
repo.AddGamePlayed(db.Ctx, songData.GameID)
|
BackendRepo().AddSoundtrackPlayed(BackendCtx(), songData.SoundtrackID)
|
||||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
|
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: songData.SoundtrackID, SongName: songData.SongName})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRandomSong() string {
|
func GetRandomSong() string {
|
||||||
@@ -104,7 +105,7 @@ func GetRandomSong() string {
|
|||||||
func GetRandomSongLowChance() string {
|
func GetRandomSongLowChance() string {
|
||||||
getAllGames()
|
getAllGames()
|
||||||
|
|
||||||
var listOfGames []repository.Game
|
var listOfGames []repository.Soundtrack
|
||||||
|
|
||||||
var averagePlayed = getAveragePlayed()
|
var averagePlayed = getAveragePlayed()
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ func GetRandomSongClassic() string {
|
|||||||
|
|
||||||
var listOfAllSongs []repository.Song
|
var listOfAllSongs []repository.Song
|
||||||
for _, game := range gamesNew {
|
for _, game := range gamesNew {
|
||||||
songList, _ := repo.FindSongsFromGame(db.Ctx, game.ID)
|
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID)
|
||||||
listOfAllSongs = append(listOfAllSongs, songList...)
|
listOfAllSongs = append(listOfAllSongs, songList...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,13 +139,13 @@ 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 := repo.GetGameById(db.Ctx, song.GameID)
|
gameData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
repo.RemoveBrokenSong(db.Ctx, 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.GameName),
|
zap.String("game", gameData.SoundtrackName),
|
||||||
zap.String("filename", *song.FileName))
|
zap.String("filename", *song.FileName))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -153,10 +154,10 @@ func GetRandomSongClassic() string {
|
|||||||
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 && gameData.Path+*song.FileName != song.Path) {
|
||||||
//File not found
|
//File not found
|
||||||
repo.RemoveBrokenSong(db.Ctx, 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.GameName),
|
zap.String("game", gameData.SoundtrackName),
|
||||||
zap.String("filename", *song.FileName))
|
zap.String("filename", *song.FileName))
|
||||||
} else {
|
} else {
|
||||||
songFound = true
|
songFound = true
|
||||||
@@ -179,7 +180,7 @@ func GetSongInfo() SongInfo {
|
|||||||
currentGameData := getCurrentGame(currentSongData)
|
currentGameData := getCurrentGame(currentSongData)
|
||||||
|
|
||||||
return SongInfo{
|
return SongInfo{
|
||||||
Game: currentGameData.GameName,
|
Game: currentGameData.SoundtrackName,
|
||||||
GamePlayed: currentGameData.TimesPlayed,
|
GamePlayed: currentGameData.TimesPlayed,
|
||||||
Song: currentSongData.SongName,
|
Song: currentSongData.SongName,
|
||||||
SongPlayed: currentSongData.TimesPlayed,
|
SongPlayed: currentSongData.TimesPlayed,
|
||||||
@@ -194,7 +195,7 @@ func GetPlayedSongs() []SongInfo {
|
|||||||
for i, song := range songQueNew {
|
for i, song := range songQueNew {
|
||||||
gameData := getCurrentGame(song)
|
gameData := getCurrentGame(song)
|
||||||
songList = append(songList, SongInfo{
|
songList = append(songList, SongInfo{
|
||||||
Game: gameData.GameName,
|
Game: gameData.SoundtrackName,
|
||||||
GamePlayed: gameData.TimesPlayed,
|
GamePlayed: gameData.TimesPlayed,
|
||||||
Song: song.SongName,
|
Song: song.SongName,
|
||||||
SongPlayed: song.TimesPlayed,
|
SongPlayed: song.TimesPlayed,
|
||||||
@@ -216,22 +217,22 @@ func GetSong(song string) string {
|
|||||||
return songData.Path
|
return songData.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllGames() []string {
|
func GetAllSoundtracks() []string {
|
||||||
getAllGames()
|
getAllGames()
|
||||||
|
|
||||||
var jsonArray []string
|
var jsonArray []string
|
||||||
for _, game := range gamesNew {
|
for _, game := range gamesNew {
|
||||||
jsonArray = append(jsonArray, game.GameName)
|
jsonArray = append(jsonArray, game.SoundtrackName)
|
||||||
}
|
}
|
||||||
return jsonArray
|
return jsonArray
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllGamesRandom() []string {
|
func GetAllSoundtracksRandom() []string {
|
||||||
getAllGames()
|
getAllGames()
|
||||||
|
|
||||||
var jsonArray []string
|
var jsonArray []string
|
||||||
for _, game := range gamesNew {
|
for _, game := range gamesNew {
|
||||||
jsonArray = append(jsonArray, game.GameName)
|
jsonArray = append(jsonArray, game.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
|
||||||
@@ -265,12 +266,12 @@ func GetPreviousSong() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSongFromList(games []repository.Game) repository.Song {
|
func getSongFromList(games []repository.Soundtrack) repository.Song {
|
||||||
songFound := false
|
songFound := false
|
||||||
var song repository.Song
|
var song repository.Song
|
||||||
for !songFound {
|
for !songFound {
|
||||||
game := getRandomGame(games)
|
game := getRandomGame(games)
|
||||||
songs, _ := repo.FindSongsFromGame(db.Ctx, game.ID)
|
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID)
|
||||||
if len(songs) == 0 {
|
if len(songs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -281,10 +282,10 @@ func getSongFromList(games []repository.Game) repository.Song {
|
|||||||
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 && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
||||||
//File not found
|
//File not found
|
||||||
repo.RemoveBrokenSong(db.Ctx, 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.GameName),
|
zap.String("game", game.SoundtrackName),
|
||||||
zap.Any("filename", song.FileName))
|
zap.Any("filename", song.FileName))
|
||||||
} else {
|
} else {
|
||||||
songFound = true
|
songFound = true
|
||||||
@@ -298,13 +299,13 @@ func getSongFromList(games []repository.Game) repository.Song {
|
|||||||
return song
|
return song
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCurrentGame(currentSongData repository.Song) repository.Game {
|
func getCurrentGame(currentSongData repository.Song) repository.Soundtrack {
|
||||||
for _, game := range gamesNew {
|
for _, game := range gamesNew {
|
||||||
if game.ID == currentSongData.GameID {
|
if game.ID == currentSongData.SoundtrackID {
|
||||||
return game
|
return game
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return repository.Game{}
|
return repository.Soundtrack{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAveragePlayed() int32 {
|
func getAveragePlayed() int32 {
|
||||||
@@ -316,6 +317,6 @@ func getAveragePlayed() int32 {
|
|||||||
return sum / int32(len(gamesNew))
|
return sum / int32(len(gamesNew))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRandomGame(listOfGames []repository.Game) repository.Game {
|
func getRandomGame(listOfGames []repository.Soundtrack) repository.Soundtrack {
|
||||||
return listOfGames[rand.Intn(len(listOfGames))]
|
return listOfGames[rand.Intn(len(listOfGames))]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
|
|
||||||
// Test the average calculation logic directly without database access
|
// Test the average calculation logic directly without database access
|
||||||
func TestCalculateAverage(t *testing.T) {
|
func TestCalculateAverage(t *testing.T) {
|
||||||
games := []repository.Game{
|
games := []repository.Soundtrack{
|
||||||
{GameName: "Game1", TimesPlayed: 10},
|
{SoundtrackName: "Game1", TimesPlayed: 10},
|
||||||
{GameName: "Game2", TimesPlayed: 20},
|
{SoundtrackName: "Game2", TimesPlayed: 20},
|
||||||
{GameName: "Game3", TimesPlayed: 30},
|
{SoundtrackName: "Game3", TimesPlayed: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
var sum int32
|
var sum int32
|
||||||
@@ -28,7 +28,7 @@ func TestCalculateAverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateAverageEmpty(t *testing.T) {
|
func TestCalculateAverageEmpty(t *testing.T) {
|
||||||
games := []repository.Game{}
|
games := []repository.Soundtrack{}
|
||||||
|
|
||||||
if len(games) == 0 {
|
if len(games) == 0 {
|
||||||
result := int32(0)
|
result := int32(0)
|
||||||
@@ -52,8 +52,8 @@ func TestCalculateAverageEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateAverageSingle(t *testing.T) {
|
func TestCalculateAverageSingle(t *testing.T) {
|
||||||
games := []repository.Game{
|
games := []repository.Soundtrack{
|
||||||
{GameName: "Game1", TimesPlayed: 42},
|
{SoundtrackName: "Game1", TimesPlayed: 42},
|
||||||
}
|
}
|
||||||
|
|
||||||
var sum int32
|
var sum int32
|
||||||
@@ -69,10 +69,10 @@ func TestCalculateAverageSingle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRandomGame(t *testing.T) {
|
func TestGetRandomGame(t *testing.T) {
|
||||||
games := []repository.Game{
|
games := []repository.Soundtrack{
|
||||||
{GameName: "Game1", TimesPlayed: 10},
|
{SoundtrackName: "Game1", TimesPlayed: 10},
|
||||||
{GameName: "Game2", TimesPlayed: 20},
|
{SoundtrackName: "Game2", TimesPlayed: 20},
|
||||||
{GameName: "Game3", TimesPlayed: 30},
|
{SoundtrackName: "Game3", TimesPlayed: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set seed for reproducible tests
|
// Set seed for reproducible tests
|
||||||
@@ -80,93 +80,93 @@ func TestGetRandomGame(t *testing.T) {
|
|||||||
|
|
||||||
result := games[rand.Intn(len(games))]
|
result := games[rand.Intn(len(games))]
|
||||||
|
|
||||||
if result.GameName == "" {
|
if result.SoundtrackName == "" {
|
||||||
t.Error("random game selection returned empty game")
|
t.Error("random game selection returned empty game")
|
||||||
}
|
}
|
||||||
|
|
||||||
found := false
|
found := false
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
if g.GameName == result.GameName {
|
if g.SoundtrackName == result.SoundtrackName {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Errorf("random game selection returned game not in list: %v", result.GameName)
|
t.Errorf("random game selection returned game not in list: %v", result.SoundtrackName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindGameByID(t *testing.T) {
|
func TestFindGameByID(t *testing.T) {
|
||||||
games := []repository.Game{
|
games := []repository.Soundtrack{
|
||||||
{ID: 1, GameName: "Game1", TimesPlayed: 10},
|
{ID: 1, SoundtrackName: "Game1", TimesPlayed: 10},
|
||||||
{ID: 2, GameName: "Game2", TimesPlayed: 20},
|
{ID: 2, SoundtrackName: "Game2", TimesPlayed: 20},
|
||||||
{ID: 3, GameName: "Game3", TimesPlayed: 30},
|
{ID: 3, SoundtrackName: "Game3", TimesPlayed: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
games []repository.Game
|
games []repository.Soundtrack
|
||||||
gameID int32
|
gameID int32
|
||||||
expected repository.Game
|
expected repository.Soundtrack
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "existing game",
|
name: "existing game",
|
||||||
games: games,
|
games: games,
|
||||||
gameID: 2,
|
gameID: 2,
|
||||||
expected: repository.Game{ID: 2, GameName: "Game2", TimesPlayed: 20},
|
expected: repository.Soundtrack{ID: 2, SoundtrackName: "Game2", TimesPlayed: 20},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-existing game",
|
name: "non-existing game",
|
||||||
games: games,
|
games: games,
|
||||||
gameID: 99,
|
gameID: 99,
|
||||||
expected: repository.Game{},
|
expected: repository.Soundtrack{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
var result repository.Game
|
var result repository.Soundtrack
|
||||||
for _, game := range tt.games {
|
for _, game := range tt.games {
|
||||||
if game.ID == tt.gameID {
|
if game.ID == tt.gameID {
|
||||||
result = game
|
result = game
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result.ID != tt.expected.ID || result.GameName != tt.expected.GameName {
|
if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName {
|
||||||
t.Errorf("findGameByID() = %v, want %v", result, tt.expected)
|
t.Errorf("findGameByID() = %v, want %v", result, tt.expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractGameNames(t *testing.T) {
|
func TestExtractSoundtrackNames(t *testing.T) {
|
||||||
games := []repository.Game{
|
games := []repository.Soundtrack{
|
||||||
{GameName: "Game1", TimesPlayed: 10},
|
{SoundtrackName: "Game1", TimesPlayed: 10},
|
||||||
{GameName: "Game2", TimesPlayed: 20},
|
{SoundtrackName: "Game2", TimesPlayed: 20},
|
||||||
{GameName: "Game3", TimesPlayed: 30},
|
{SoundtrackName: "Game3", TimesPlayed: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []string
|
var result []string
|
||||||
for _, game := range games {
|
for _, game := range games {
|
||||||
result = append(result, game.GameName)
|
result = append(result, game.SoundtrackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"Game1", "Game2", "Game3"}
|
expected := []string{"Game1", "Game2", "Game3"}
|
||||||
|
|
||||||
if len(result) != len(expected) {
|
if len(result) != len(expected) {
|
||||||
t.Errorf("extractGameNames() length = %d, want %d", len(result), len(expected))
|
t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, v := range result {
|
for i, v := range result {
|
||||||
if v != expected[i] {
|
if v != expected[i] {
|
||||||
t.Errorf("extractGameNames()[%d] = %v, want %v", i, v, expected[i])
|
t.Errorf("extractSoundtrackNames()[%d] = %v, want %v", i, v, expected[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShuffleGameNames(t *testing.T) {
|
func TestShuffleSoundtrackNames(t *testing.T) {
|
||||||
games := []string{"Game1", "Game2", "Game3"}
|
games := []string{"Game1", "Game2", "Game3"}
|
||||||
|
|
||||||
// Test that shuffle doesn't lose any elements
|
// Test that shuffle doesn't lose any elements
|
||||||
@@ -181,7 +181,7 @@ func TestShuffleGameNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(games) != len(original) {
|
if len(games) != len(original) {
|
||||||
t.Errorf("shuffleGameNames() changed length from %d to %d", len(original), len(games))
|
t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(games))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +195,7 @@ func TestShuffleGameNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Errorf("shuffleGameNames() lost element: %v", orig)
|
t.Errorf("shuffleSoundtrackNames() lost element: %v", orig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GameWithSongs represents a game with its songs for statistics
|
||||||
|
type GameWithSongs struct {
|
||||||
|
SoundtrackID int32 `json:"game_id"`
|
||||||
|
SoundtrackName string `json:"game_name"`
|
||||||
|
SoundtrackPlayed int32 `json:"game_played"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"game_last_played,omitempty"`
|
||||||
|
Songs []SongInfoForStats `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SongInfoForStats represents a song with game info for statistics
|
||||||
|
type SongInfoForStats struct {
|
||||||
|
SoundtrackID int32 `json:"game_id"`
|
||||||
|
SoundtrackName string `json:"game_name"`
|
||||||
|
SongName string `json:"song_name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
TimesPlayed int32 `json:"times_played"`
|
||||||
|
FileName *string `json:"file_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatisticsSummary holds overall statistics
|
||||||
|
type StatisticsSummary struct {
|
||||||
|
TotalGames int64 `json:"total_games"`
|
||||||
|
PlayedGames int64 `json:"played_games"`
|
||||||
|
NeverPlayedGames int64 `json:"never_played_games"`
|
||||||
|
TotalGamePlays int64 `json:"total_game_plays"`
|
||||||
|
AvgGamePlays float64 `json:"avg_game_plays"`
|
||||||
|
MaxGamePlays int64 `json:"max_game_plays"`
|
||||||
|
MinGamePlays int64 `json:"min_game_plays"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatisticsHandler manages statistics operations
|
||||||
|
type StatisticsHandler struct {
|
||||||
|
// Uses the global backend repo initialized via InitBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatisticsHandler creates a new StatisticsHandler
|
||||||
|
func NewStatisticsHandler() *StatisticsHandler {
|
||||||
|
return &StatisticsHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMostPlayedGamesWithSongs returns the top N most played games with their songs
|
||||||
|
func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) {
|
||||||
|
queries := BackendRepo()
|
||||||
|
ctx := BackendCtx()
|
||||||
|
|
||||||
|
// Get raw results
|
||||||
|
rows, err := queries.GetMostPlayedGamesWithSongs(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to GameWithSongs
|
||||||
|
var result []GameWithSongs
|
||||||
|
for _, row := range rows {
|
||||||
|
var songs []SongInfoForStats
|
||||||
|
if row.Songs != nil {
|
||||||
|
// Parse JSON songs array
|
||||||
|
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||||
|
// Fallback: if JSON parsing fails, create empty song entries
|
||||||
|
songs = make([]SongInfoForStats, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, GameWithSongs{
|
||||||
|
SoundtrackID: row.SoundtrackID,
|
||||||
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
|
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||||
|
Songs: songs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLeastPlayedGamesWithSongs returns the top N least played games with their songs
|
||||||
|
func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) {
|
||||||
|
queries := BackendRepo()
|
||||||
|
ctx := BackendCtx()
|
||||||
|
|
||||||
|
rows, err := queries.GetLeastPlayedGamesWithSongs(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []GameWithSongs
|
||||||
|
for _, row := range rows {
|
||||||
|
var songs []SongInfoForStats
|
||||||
|
if row.Songs != nil {
|
||||||
|
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||||
|
songs = make([]SongInfoForStats, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, GameWithSongs{
|
||||||
|
SoundtrackID: row.SoundtrackID,
|
||||||
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
|
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||||
|
Songs: songs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMostPlayedSongsWithGame returns the top N most played songs with their game info
|
||||||
|
func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) {
|
||||||
|
queries := BackendRepo()
|
||||||
|
ctx := BackendCtx()
|
||||||
|
|
||||||
|
rows, err := queries.GetMostPlayedSongsWithGame(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []SongInfoForStats
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, SongInfoForStats{
|
||||||
|
SoundtrackID: row.SoundtrackID,
|
||||||
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
SongName: row.SongName,
|
||||||
|
Path: row.Path,
|
||||||
|
TimesPlayed: row.TimesPlayed,
|
||||||
|
FileName: row.FileName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLeastPlayedSongsWithGame returns the top N least played songs with their game info
|
||||||
|
func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) {
|
||||||
|
queries := BackendRepo()
|
||||||
|
ctx := BackendCtx()
|
||||||
|
|
||||||
|
rows, err := queries.GetLeastPlayedSongsWithGame(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []SongInfoForStats
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, SongInfoForStats{
|
||||||
|
SoundtrackID: row.SoundtrackID,
|
||||||
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
SongName: row.SongName,
|
||||||
|
Path: row.Path,
|
||||||
|
TimesPlayed: row.TimesPlayed,
|
||||||
|
FileName: row.FileName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNeverPlayedGames returns games that have never been played
|
||||||
|
func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) {
|
||||||
|
queries := BackendRepo()
|
||||||
|
ctx := BackendCtx()
|
||||||
|
|
||||||
|
rows, err := queries.GetNeverPlayedGames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []GameWithSongs
|
||||||
|
for _, row := range rows {
|
||||||
|
var songs []SongInfoForStats
|
||||||
|
if row.Songs != nil {
|
||||||
|
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||||
|
songs = make([]SongInfoForStats, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, GameWithSongs{
|
||||||
|
SoundtrackID: row.SoundtrackID,
|
||||||
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
|
SoundtrackLastPlayed: nil,
|
||||||
|
Songs: songs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastPlayedGames returns the most recently played games
|
||||||
|
func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, error) {
|
||||||
|
queries := BackendRepo()
|
||||||
|
ctx := BackendCtx()
|
||||||
|
|
||||||
|
rows, err := queries.GetLastPlayedGames(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []GameWithSongs
|
||||||
|
for _, row := range rows {
|
||||||
|
var songs []SongInfoForStats
|
||||||
|
if row.Songs != nil {
|
||||||
|
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||||
|
songs = make([]SongInfoForStats, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, GameWithSongs{
|
||||||
|
SoundtrackID: row.SoundtrackID,
|
||||||
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
|
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||||
|
Songs: songs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOldestPlayedGames returns the least recently played games
|
||||||
|
func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs, error) {
|
||||||
|
queries := BackendRepo()
|
||||||
|
ctx := BackendCtx()
|
||||||
|
|
||||||
|
rows, err := queries.GetOldestPlayedGames(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []GameWithSongs
|
||||||
|
for _, row := range rows {
|
||||||
|
var songs []SongInfoForStats
|
||||||
|
if row.Songs != nil {
|
||||||
|
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||||
|
songs = make([]SongInfoForStats, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, GameWithSongs{
|
||||||
|
SoundtrackID: row.SoundtrackID,
|
||||||
|
SoundtrackName: row.SoundtrackName,
|
||||||
|
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||||
|
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||||
|
Songs: songs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatisticsSummary returns overall statistics
|
||||||
|
func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
|
||||||
|
queries := BackendRepo()
|
||||||
|
ctx := BackendCtx()
|
||||||
|
|
||||||
|
row, err := queries.GetStatisticsSummary(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StatisticsSummary{
|
||||||
|
TotalGames: int64(row.TotalSoundtracks),
|
||||||
|
PlayedGames: int64(row.PlayedSoundtracks),
|
||||||
|
NeverPlayedGames: int64(row.NeverPlayedSoundtracks),
|
||||||
|
TotalGamePlays: int64(row.TotalSoundtrackPlays),
|
||||||
|
AvgGamePlays: float64(row.AvgSoundtrackPlays),
|
||||||
|
MaxGamePlays: int64(row.MaxSoundtrackPlays),
|
||||||
|
MinGamePlays: int64(row.MinSoundtrackPlays),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log helper for statistics operations
|
||||||
|
func logStatisticsError(err error, operation string) {
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Statistics error",
|
||||||
|
zap.String("operation", operation),
|
||||||
|
zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-66
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"os"
|
"os"
|
||||||
@@ -31,16 +30,22 @@ var start time.Time
|
|||||||
var totalTime time.Duration
|
var totalTime time.Duration
|
||||||
var timeSpent time.Duration
|
var timeSpent time.Duration
|
||||||
|
|
||||||
var allGames []repository.Game
|
var allGames []repository.Soundtrack
|
||||||
var gamesBeforeSync []repository.Game
|
var gamesBeforeSync []repository.Soundtrack
|
||||||
var gamesAfterSync []repository.Game
|
var gamesAfterSync []repository.Soundtrack
|
||||||
var gamesAdded []string
|
var gamesAdded []string
|
||||||
var gamesReAdded []string
|
var gamesReAdded []string
|
||||||
var gamesChangedTitle map[string]string
|
var gamesChangedTitle map[string]string
|
||||||
var gamesChangedContent []string
|
var gamesChangedContent []string
|
||||||
var gamesRemoved []string
|
var gamesRemoved []string
|
||||||
var catchedErrors []string
|
var catchedErrors []string
|
||||||
var brokenSongs []string
|
|
||||||
|
type brokenSong struct {
|
||||||
|
SoundtrackID int32
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
var brokenSongs []brokenSong
|
||||||
var pool *ants.Pool
|
var pool *ants.Pool
|
||||||
var poolSong *ants.Pool
|
var poolSong *ants.Pool
|
||||||
|
|
||||||
@@ -80,8 +85,8 @@ func (gs GameStatus) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResetDB() {
|
func ResetDB() {
|
||||||
repo.ClearSongs(db.Ctx)
|
repo.ClearSongs(BackendCtx())
|
||||||
repo.ClearGames(db.Ctx)
|
repo.ClearSoundtracks(BackendCtx())
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncProgress() ProgressResponse {
|
func SyncProgress() ProgressResponse {
|
||||||
@@ -125,13 +130,13 @@ func SyncResult() SyncResponse {
|
|||||||
for _, beforeGame := range gamesBeforeSync {
|
for _, beforeGame := range gamesBeforeSync {
|
||||||
var found = false
|
var found = false
|
||||||
for _, afterGame := range gamesAfterSync {
|
for _, afterGame := range gamesAfterSync {
|
||||||
if beforeGame.GameName == afterGame.GameName {
|
if beforeGame.SoundtrackName == afterGame.SoundtrackName {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.GameName)
|
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.SoundtrackName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,19 +175,17 @@ func SyncResult() SyncResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncGamesNewFull() {
|
func SyncSoundtracksNewFull() {
|
||||||
syncGamesNew(true)
|
syncGamesNew(true)
|
||||||
Reset()
|
Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncGamesNewOnlyChanges() {
|
func SyncSoundtracksNewOnlyChanges() {
|
||||||
syncGamesNew(false)
|
syncGamesNew(false)
|
||||||
Reset()
|
Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncGamesNew(full bool) {
|
func syncGamesNew(full bool) {
|
||||||
Syncing = true
|
|
||||||
|
|
||||||
musicPath := os.Getenv("MUSIC_PATH")
|
musicPath := os.Getenv("MUSIC_PATH")
|
||||||
fmt.Printf("dir: %s\n", musicPath)
|
fmt.Printf("dir: %s\n", musicPath)
|
||||||
logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath))
|
logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath))
|
||||||
@@ -194,7 +197,7 @@ func syncGamesNew(full bool) {
|
|||||||
|
|
||||||
initRepo()
|
initRepo()
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
foldersToSkip := []string{".sync", "dist", "old", "characters"}
|
foldersToSkip := []string{".sync", "characters", "dist", "old"}
|
||||||
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
|
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@@ -206,20 +209,19 @@ func syncGamesNew(full bool) {
|
|||||||
catchedErrors = nil
|
catchedErrors = nil
|
||||||
brokenSongs = nil
|
brokenSongs = nil
|
||||||
|
|
||||||
gamesBeforeSync, err = repo.FindAllGames(db.Ctx)
|
gamesBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
|
||||||
handleError("FindAllGames Before", err, "")
|
handleError("FindAllSoundtracks Before", err, "")
|
||||||
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
|
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
|
||||||
|
|
||||||
allGames, err = repo.GetAllGamesIncludingDeleted(db.Ctx)
|
allGames, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx())
|
||||||
handleError("GetAllGamesIncludingDeleted", err, "")
|
handleError("GetAllSoundtracksIncludingDeleted", err, "")
|
||||||
err = repo.SetGameDeletionDate(db.Ctx)
|
err = repo.SetSoundtrackDeletionDate(BackendCtx())
|
||||||
handleError("SetGameDeletionDate", err, "")
|
handleError("SetSoundtrackDeletionDate", err, "")
|
||||||
|
|
||||||
directories, err := os.ReadDir(musicPath)
|
directories, err := os.ReadDir(musicPath)
|
||||||
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()
|
||||||
@@ -237,8 +239,8 @@ func syncGamesNew(full bool) {
|
|||||||
syncWg.Wait()
|
syncWg.Wait()
|
||||||
checkBrokenSongsNew()
|
checkBrokenSongsNew()
|
||||||
|
|
||||||
gamesAfterSync, err = repo.FindAllGames(db.Ctx)
|
gamesAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
|
||||||
handleError("FindAllGames After", err, "")
|
handleError("FindAllSoundtracks After", err, "")
|
||||||
|
|
||||||
finished := time.Now()
|
finished := time.Now()
|
||||||
totalTime = finished.Sub(start)
|
totalTime = finished.Sub(start)
|
||||||
@@ -249,7 +251,7 @@ func syncGamesNew(full bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkBrokenSongsNew() {
|
func checkBrokenSongsNew() {
|
||||||
allSongs, err := repo.FetchAllSongs(db.Ctx)
|
allSongs, err := repo.FetchAllSongs(BackendCtx())
|
||||||
handleError("FetchAllSongs", err, "")
|
handleError("FetchAllSongs", err, "")
|
||||||
var brokenWg sync.WaitGroup
|
var brokenWg sync.WaitGroup
|
||||||
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
|
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
|
||||||
@@ -263,8 +265,10 @@ func checkBrokenSongsNew() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
brokenWg.Wait()
|
brokenWg.Wait()
|
||||||
err = repo.RemoveBrokenSongs(db.Ctx, brokenSongs)
|
for _, bs := range brokenSongs {
|
||||||
handleError("RemoveBrokenSongs", err, "")
|
err = repo.RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: bs.SoundtrackID, Path: bs.Path})
|
||||||
|
handleError("RemoveBrokenSong", err, "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkBrokenSongNew(song repository.Song) {
|
func checkBrokenSongNew(song repository.Song) {
|
||||||
@@ -272,7 +276,7 @@ func checkBrokenSongNew(song repository.Song) {
|
|||||||
openFile, err := os.Open(song.Path)
|
openFile, err := os.Open(song.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//File not found
|
//File not found
|
||||||
brokenSongs = append(brokenSongs, song.Path)
|
brokenSongs = append(brokenSongs, brokenSong{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||||
logging.GetLogger().Warn("Broken song found", zap.String("path", song.Path))
|
logging.GetLogger().Warn("Broken song found", zap.String("path", song.Path))
|
||||||
} else {
|
} else {
|
||||||
err = openFile.Close()
|
err = openFile.Close()
|
||||||
@@ -289,33 +293,33 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
dirHash := getHashForDir(gameDir)
|
dirHash := getHashForDir(gameDir)
|
||||||
|
|
||||||
var status GameStatus = NewGame
|
var status GameStatus = NewGame
|
||||||
var oldGame repository.Game
|
var oldGame repository.Soundtrack
|
||||||
var id int32 = -1
|
var id int32 = -1
|
||||||
|
|
||||||
//fmt.Printf("Games before: %d\n", len(gamesBeforeSync))
|
//fmt.Printf("Games before: %d\n", len(gamesBeforeSync))
|
||||||
|
|
||||||
for _, currentGame := range allGames {
|
for _, currentGame := range allGames {
|
||||||
oldGame = currentGame
|
oldGame = currentGame
|
||||||
//fmt.Printf("%s | %s\n", oldGame.GameName, oldGame.Hash)
|
//fmt.Printf("%s | %s\n", oldGame.SoundtrackName, oldGame.Hash)
|
||||||
if oldGame.GameName == file.Name() && oldGame.Hash == dirHash {
|
if oldGame.SoundtrackName == file.Name() && oldGame.Hash == dirHash {
|
||||||
status = NotChanged
|
status = NotChanged
|
||||||
id = oldGame.ID
|
id = oldGame.ID
|
||||||
//fmt.Printf("Game not changed\n")
|
//fmt.Printf("Game not changed\n")
|
||||||
break
|
break
|
||||||
} else if oldGame.GameName == file.Name() && oldGame.Hash != dirHash {
|
} else if oldGame.SoundtrackName == file.Name() && oldGame.Hash != dirHash {
|
||||||
status = GameChanged
|
status = GameChanged
|
||||||
id = oldGame.ID
|
id = oldGame.ID
|
||||||
//fmt.Printf("Game changed\n")
|
//fmt.Printf("Game changed\n")
|
||||||
break
|
break
|
||||||
} else if oldGame.GameName != file.Name() && oldGame.Hash == dirHash {
|
} else if oldGame.SoundtrackName != file.Name() && oldGame.Hash == dirHash {
|
||||||
status = TitleChanged
|
status = TitleChanged
|
||||||
id = oldGame.ID
|
id = oldGame.ID
|
||||||
//fmt.Printf("GameName changed\n")
|
//fmt.Printf("SoundtrackName changed\n")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if full {
|
if full && status != NewGame {
|
||||||
status = TitleChanged
|
status = TitleChanged
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(gameDir)
|
entries, err := os.ReadDir(gameDir)
|
||||||
@@ -336,8 +340,8 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = repo.InsertGameWithExistingId(db.Ctx, repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
|
err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, SoundtrackName: file.Name(), Path: gameDir, Hash: dirHash})
|
||||||
handleError("InsertGameWithExistingId", err, "")
|
handleError("InsertSoundtrackWithExistingId", err, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Debug("Game already exists, removing old ID file",
|
logging.GetLogger().Debug("Game already exists, removing old ID file",
|
||||||
zap.Int32("id", id),
|
zap.Int32("id", id),
|
||||||
@@ -370,24 +374,24 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
zap.String("game", file.Name()),
|
zap.String("game", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
err = repo.UpdateGameHash(db.Ctx, repository.UpdateGameHashParams{Hash: dirHash, ID: id})
|
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
|
||||||
handleError("UpdateGameHash", err, "")
|
handleError("UpdateSoundtrackHash", err, "")
|
||||||
gamesChangedContent = append(gamesChangedContent, file.Name())
|
gamesChangedContent = append(gamesChangedContent, file.Name())
|
||||||
newCheckSongs(entries, gameDir, id)
|
newCheckSongs(entries, gameDir, id)
|
||||||
case TitleChanged:
|
case TitleChanged:
|
||||||
logging.GetLogger().Debug("Game title changed",
|
logging.GetLogger().Debug("Game title changed",
|
||||||
zap.Int32("id", id),
|
zap.Int32("id", id),
|
||||||
zap.String("oldName", oldGame.GameName),
|
zap.String("oldName", oldGame.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.UpdateGameName(db.Ctx, repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
||||||
handleError("UpdateGameName", err, "")
|
handleError("UpdateSoundtrackName", err, "")
|
||||||
newCheckSongs(entries, gameDir, id)
|
newCheckSongs(entries, gameDir, id)
|
||||||
if gamesChangedTitle == nil {
|
if gamesChangedTitle == nil {
|
||||||
gamesChangedTitle = make(map[string]string)
|
gamesChangedTitle = make(map[string]string)
|
||||||
}
|
}
|
||||||
gamesChangedTitle[oldGame.GameName] = file.Name()
|
gamesChangedTitle[oldGame.SoundtrackName] = file.Name()
|
||||||
case NotChanged:
|
case NotChanged:
|
||||||
var found bool = false
|
var found bool = false
|
||||||
for _, beforeGame := range gamesBeforeSync {
|
for _, beforeGame := range gamesBeforeSync {
|
||||||
@@ -416,8 +420,8 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
zap.String("game", file.Name()),
|
zap.String("game", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
err = repo.RemoveDeletionDate(db.Ctx, id)
|
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
|
||||||
handleError("RemoveDeletionDate", err, "")
|
handleError("RemoveSoundtrackDeletionDate", err, "")
|
||||||
}
|
}
|
||||||
foldersSynced++
|
foldersSynced++
|
||||||
logging.GetLogger().Debug("Sync progress",
|
logging.GetLogger().Debug("Sync progress",
|
||||||
@@ -428,14 +432,14 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
|
|
||||||
func insertGameNew(name string, path string, hash string) int32 {
|
func insertGameNew(name string, path string, hash string) int32 {
|
||||||
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
||||||
id, err := repo.InsertGame(db.Ctx, repository.InsertGameParams{GameName: name, Path: path, Hash: hash})
|
id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash})
|
||||||
handleError("InsertGame", err, "")
|
handleError("InsertSoundtrack", err, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
||||||
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
||||||
logging.GetLogger().Debug("Resetting game ID sequence")
|
logging.GetLogger().Debug("Resetting game ID sequence")
|
||||||
_, err = repo.ResetGameIdSeq(db.Ctx)
|
_, err = repo.ResetSoundtrackIdSeq(BackendCtx())
|
||||||
handleError("ResetGameIdSeq", err, "")
|
handleError("ResetSoundtrackIdSeq", err, "")
|
||||||
id = insertGameNew(name, path, hash)
|
id = insertGameNew(name, path, hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,8 +482,8 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
|||||||
fileName := entry.Name()
|
fileName := entry.Name()
|
||||||
songName, _ := strings.CutSuffix(fileName, ".mp3")
|
songName, _ := strings.CutSuffix(fileName, ".mp3")
|
||||||
|
|
||||||
song, err := repo.GetSongWithHash(db.Ctx, songHash)
|
song, err := repo.GetSongWithHash(BackendCtx(), songHash)
|
||||||
handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("GetSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if song.SongName == songName && song.Path == path {
|
if song.SongName == songName && song.Path == path {
|
||||||
return false
|
return false
|
||||||
@@ -491,32 +495,32 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
|||||||
zap.String("song_name", songName),
|
zap.String("song_name", songName),
|
||||||
zap.String("song_hash", songHash))
|
zap.String("song_hash", songHash))
|
||||||
|
|
||||||
count, err := repo.CheckSongWithHash(db.Ctx, songHash)
|
count, err := repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||||
handleError("CheckSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
handleError("CheckSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
count2, err := repo.CheckSong(db.Ctx, path)
|
count2, err := repo.CheckSong(BackendCtx(), repository.CheckSongParams{SoundtrackID: id, Path: path})
|
||||||
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||||
if count2 > 0 {
|
if count2 > 0 {
|
||||||
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path})
|
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path})
|
||||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %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))
|
||||||
count, err = repo.CheckSongWithHash(db.Ctx, songHash)
|
count, err = repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||||
handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("CheckSongWithHash 2", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//count, _ := repo.CheckSong(ctx, path)
|
//count, _ := repo.CheckSong(ctx, path)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
err = repo.UpdateSong(db.Ctx, repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
|
err = repo.UpdateSong(BackendCtx(), repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
|
||||||
handleError("UpdateSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("UpdateSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
} else {
|
} else {
|
||||||
count2, err := repo.CheckSong(db.Ctx, path)
|
count2, err := repo.CheckSong(BackendCtx(), repository.CheckSongParams{SoundtrackID: id, Path: path})
|
||||||
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
if count2 > 0 {
|
if count2 > 0 {
|
||||||
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path})
|
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path})
|
||||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %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(db.Ctx, repository.AddSongParams{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
||||||
handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
type VersionData struct {
|
||||||
|
Version string `json:"version" example:"1.0.0"`
|
||||||
|
Changelog []string `json:"changelog" example:"[\"Initial release\",\"Bug fixes\"]"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = []VersionData{
|
||||||
|
{
|
||||||
|
Version: "5.0.0-Beta",
|
||||||
|
Changelog: []string{
|
||||||
|
"#16 - Upgrade Echo framework from v4 to v5",
|
||||||
|
"#17 - Add Zap structured logging framework",
|
||||||
|
"#18 - Add OpenAPI/Swagger documentation",
|
||||||
|
"#19 - Replace Tailwind CSS with pure CSS",
|
||||||
|
"#20 - Change domain from sanplex.tech to sanplex.xyz",
|
||||||
|
"#21 - Refactor handlers into domain-specific files",
|
||||||
|
"#22 - Change VersionData Changelog from string to string array",
|
||||||
|
"#23 - Update all dependencies to latest versions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "4.5.0",
|
||||||
|
Changelog: []string{
|
||||||
|
"#1 - Created request to check newest version of the app",
|
||||||
|
"#2 - Added request to download the newest version of the app",
|
||||||
|
"#3 - Added request to check progress during sync",
|
||||||
|
"#4 - Now blocking all request while sync is in progress",
|
||||||
|
"#5 - Implemented ants for thread pooling",
|
||||||
|
"#6 - Changed the sync request to now only start the sync",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "4.0.0",
|
||||||
|
Changelog: []string{
|
||||||
|
"Changed framework from gin to Echo",
|
||||||
|
"Reorganized the code",
|
||||||
|
"Implemented sqlc",
|
||||||
|
"Added support to send character images from the server",
|
||||||
|
"Added function to create a new database of no one exists",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "3.2",
|
||||||
|
Changelog: []string{"Upgraded Go version and the version of all dependencies. Fixed som more bugs."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "3.1",
|
||||||
|
Changelog: []string{"Fixed some bugs with songs not found made the application crash. Now checking if song exists and if not, remove song from DB and find another one. Frontend is now decoupled from the backend."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "3.0",
|
||||||
|
Changelog: []string{"Changed routing framework from mux to Gin. Swagger doc is now included in the application. A fronted can now be hosted from the application."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.3.0",
|
||||||
|
Changelog: []string{"Images should not be included in the database, removes songs where the path doesn't work."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.2.0",
|
||||||
|
Changelog: []string{"Changed the structure of the whole application, should be no changes to functionality."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.4",
|
||||||
|
Changelog: []string{"Game list should now be sorted, a new endpoint with the game list in random order have been added."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.3",
|
||||||
|
Changelog: []string{"Added a check to see if song exists before returning it, if not a new song will be picked up."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.2",
|
||||||
|
Changelog: []string{"Added test server to swagger file."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.1",
|
||||||
|
Changelog: []string{"Fixed bug where wrong song was showed as currently played."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.0",
|
||||||
|
Changelog: []string{
|
||||||
|
"Added /addQue to add the last received song to the songQue.",
|
||||||
|
"Changed /rand and /rand/low to not add song to the que.",
|
||||||
|
"Changed /next to not call /rand when the end of the que is reached, instead the last song in the que will be resent.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.0.3",
|
||||||
|
Changelog: []string{"Another small change that should fix the caching problem."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.0.2",
|
||||||
|
Changelog: []string{"Hopefully fixed the caching problem with random."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.0.1",
|
||||||
|
Changelog: []string{"Fixed CORS"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.0.0",
|
||||||
|
Changelog: []string{"Rebuilt the application in Go."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLatestVersion() VersionData {
|
||||||
|
return data[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetVersionHistory() []VersionData {
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database holds the database connection pool and context
|
||||||
|
type Database struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
Ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabase creates a new Database instance with connection pool
|
||||||
|
func NewDatabase(host, port, user, password, dbname string) (*Database, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
host, port, user, password, dbname)
|
||||||
|
|
||||||
|
logging.GetLogger().Debug("Database connection info",
|
||||||
|
zap.String("host", host),
|
||||||
|
zap.String("port", port),
|
||||||
|
zap.String("dbname", dbname))
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, psqlInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
var success string
|
||||||
|
err = pool.QueryRow(ctx, "select 'Successfully connected!'").Scan(&success)
|
||||||
|
if err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("database query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.GetLogger().Info("Database connected", zap.String("status", success))
|
||||||
|
|
||||||
|
return &Database{Pool: pool, Ctx: ctx}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection pool
|
||||||
|
func (db *Database) Close() {
|
||||||
|
if db.Pool != nil {
|
||||||
|
logging.GetLogger().Info("Closing database connection")
|
||||||
|
db.Pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health checks the health of the database connection by pinging the database.
|
||||||
|
// It returns a map with keys indicating various health statistics.
|
||||||
|
func (db *Database) Health() map[string]string {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stats := make(map[string]string)
|
||||||
|
|
||||||
|
// Ping the database
|
||||||
|
err := db.Pool.Ping(ctx)
|
||||||
|
if err != nil {
|
||||||
|
stats["status"] = "down"
|
||||||
|
stats["error"] = err.Error()
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
stats["status"] = "up"
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations runs all pending database migrations to the latest version.
|
||||||
|
// Uses the existing pool to extract connection details.
|
||||||
|
func (db *Database) RunMigrations() error {
|
||||||
|
// Extract connection info from pool config
|
||||||
|
connConfig := db.Pool.Config().ConnConfig
|
||||||
|
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||||
|
connConfig.User,
|
||||||
|
connConfig.Password,
|
||||||
|
connConfig.Host,
|
||||||
|
connConfig.Port,
|
||||||
|
connConfig.Database)
|
||||||
|
|
||||||
|
logging.GetLogger().Debug("Migration info", zap.String("url", migrationURL))
|
||||||
|
|
||||||
|
sqlDb, err := sql.Open("postgres", migrationURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open database for migration: %w", err)
|
||||||
|
}
|
||||||
|
defer sqlDb.Close()
|
||||||
|
|
||||||
|
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration driver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := iofs.New(MigrationsFs, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create migrator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current version for logging
|
||||||
|
version, _, err := m.Version()
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
|
||||||
|
|
||||||
|
// Run all pending migrations to latest version
|
||||||
|
err = m.Up()
|
||||||
|
if err != nil {
|
||||||
|
if err == migrate.ErrNoChange {
|
||||||
|
logging.GetLogger().Info("Database already up to date")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("migration failed: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get new version after migration
|
||||||
|
versionAfter, _, _ := m.Version()
|
||||||
|
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.GetLogger().Info("Migration completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+17
-25
@@ -20,6 +20,9 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO: DEPRECATED - Remove these global variables once all code is migrated to use Database struct
|
||||||
|
// Use database.go's Database struct instead. These globals remain for backward compatibility
|
||||||
|
// with legacy code paths. New code should use the Database struct from database.go.
|
||||||
var Dbpool *pgxpool.Pool
|
var Dbpool *pgxpool.Pool
|
||||||
var Ctx = context.Background()
|
var Ctx = context.Background()
|
||||||
|
|
||||||
@@ -53,21 +56,6 @@ func CloseDb() {
|
|||||||
Dbpool.Close()
|
Dbpool.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func Testf() {
|
|
||||||
rows, dbErr := Dbpool.Query(Ctx, "select game_name from game")
|
|
||||||
if dbErr != nil {
|
|
||||||
logging.GetLogger().Fatal("Query failed", zap.String("error", dbErr.Error()))
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var gameName string
|
|
||||||
dbErr = rows.Scan(&gameName)
|
|
||||||
if dbErr != nil {
|
|
||||||
logging.GetLogger().Error("Row scan failed", zap.String("error", dbErr.Error()))
|
|
||||||
}
|
|
||||||
logging.GetLogger().Debug("Game found", zap.String("name", gameName))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResetGameIdSeq() {
|
func ResetGameIdSeq() {
|
||||||
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);")
|
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -136,19 +124,23 @@ func Migrate_db(host string, port string, user string, password string, dbname s
|
|||||||
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
|
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
|
||||||
//}
|
//}
|
||||||
|
|
||||||
err = m.Migrate(2)
|
// Use Up() to apply all pending migrations instead of Migrate(2)
|
||||||
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
|
err = m.Up()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
|
if err == migrate.ErrNoChange {
|
||||||
|
logging.GetLogger().Info("Database already up to date")
|
||||||
|
} else {
|
||||||
|
logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
versionAfter, _, err := m.Version()
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
|
||||||
|
} else {
|
||||||
|
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
versionAfter, _, err := m.Version()
|
|
||||||
if err != nil {
|
|
||||||
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.GetLogger().Info("Migration version after", zap.Uint("version", versionAfter))
|
|
||||||
|
|
||||||
logging.GetLogger().Info("Migration completed")
|
logging.GetLogger().Info("Migration completed")
|
||||||
|
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMigrationsStepByStep tests applying migrations incrementally
|
||||||
|
// Then adding data manually, then completing migrations
|
||||||
|
func TestMigrationsStepByStep(t *testing.T) {
|
||||||
|
host := os.Getenv("DB_HOST")
|
||||||
|
port := os.Getenv("DB_PORT")
|
||||||
|
user := os.Getenv("DB_USERNAME")
|
||||||
|
password := os.Getenv("DB_PASSWORD")
|
||||||
|
// Use a unique database name for this test
|
||||||
|
dbname := "music_server_migration_test"
|
||||||
|
|
||||||
|
if host == "" || port == "" || user == "" || password == "" {
|
||||||
|
t.Skip("Test database environment variables not set (DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up: drop database if it exists
|
||||||
|
cleanupDB(t, host, port, user, password, dbname)
|
||||||
|
defer cleanupDB(t, host, port, user, password, dbname)
|
||||||
|
|
||||||
|
// Create the database
|
||||||
|
createTestDB(t, host, port, user, password, dbname)
|
||||||
|
|
||||||
|
// Step 1: Apply first 4 migrations (before soundtrack rename)
|
||||||
|
// This creates: game, song, vgmq, song_list tables
|
||||||
|
// And sessions table with indexes
|
||||||
|
t.Run("ApplyFirst4Migrations", func(t *testing.T) {
|
||||||
|
applyMigrations(t, host, port, user, password, dbname, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 2: Add data manually to game and song tables
|
||||||
|
t.Run("AddManualData", func(t *testing.T) {
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
host, port, user, password, dbname)
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Insert 5 games manually
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
gameName := fmt.Sprintf("Manual Game %d", i)
|
||||||
|
path := fmt.Sprintf("/manual/path/game%d", i)
|
||||||
|
hash := fmt.Sprintf("hash-%d", i)
|
||||||
|
|
||||||
|
_, err := db.Exec(`INSERT INTO game (game_name, path, hash, added)
|
||||||
|
VALUES ($1, $2, $3, NOW())`,
|
||||||
|
gameName, path, hash)
|
||||||
|
require.NoError(t, err, "Failed to insert game %d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert songs for each game
|
||||||
|
songs := []struct {
|
||||||
|
gameID int
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{1, "Song A", "/path/a.mp3"},
|
||||||
|
{1, "Song B", "/path/b.mp3"},
|
||||||
|
{2, "Song C", "/path/c.mp3"},
|
||||||
|
{2, "Song D", "/path/d.mp3"},
|
||||||
|
{3, "Song E", "/path/e.mp3"},
|
||||||
|
{4, "Song F", "/path/f.mp3"},
|
||||||
|
{4, "Song G", "/path/g.mp3"},
|
||||||
|
{4, "Song H", "/path/h.mp3"},
|
||||||
|
{5, "Song I", "/path/i.mp3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range songs {
|
||||||
|
_, err := db.Exec(`INSERT INTO song (game_id, song_name, path, hash)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name))
|
||||||
|
require.NoError(t, err, "Failed to insert song %s", s.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify data was inserted
|
||||||
|
var gameCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM game").Scan(&gameCount)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 5, gameCount, "Expected 5 games")
|
||||||
|
|
||||||
|
var songCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 9, songCount, "Expected 9 songs")
|
||||||
|
|
||||||
|
t.Log("✓ Manually inserted 5 games with 9 songs")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 3: Apply migration 5 (rename game→soundtrack)
|
||||||
|
t.Run("ApplyMigration5", func(t *testing.T) {
|
||||||
|
// Apply the remaining migrations (just migration 5)
|
||||||
|
applyMigrations(t, host, port, user, password, dbname, 1)
|
||||||
|
|
||||||
|
// Verify tables were renamed
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
host, port, user, password, dbname)
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Check that soundtrack table exists
|
||||||
|
var soundtrackCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 5, soundtrackCount, "Expected 5 soundtracks after migration")
|
||||||
|
|
||||||
|
// Check that game table no longer exists
|
||||||
|
_, err = db.Exec("SELECT 1 FROM game LIMIT 1")
|
||||||
|
require.Error(t, err, "game table should not exist after migration")
|
||||||
|
|
||||||
|
// Check that song table has soundtrack_id column
|
||||||
|
var songCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 9, songCount, "Expected 9 songs after migration")
|
||||||
|
|
||||||
|
// Verify data integrity: soundtrack_name values
|
||||||
|
rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
expectedNames := []string{"Manual Game 1", "Manual Game 2", "Manual Game 3", "Manual Game 4", "Manual Game 5"}
|
||||||
|
actualNames := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
err := rows.Scan(&name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
actualNames = append(actualNames, name)
|
||||||
|
}
|
||||||
|
require.Equal(t, expectedNames, actualNames, "Soundtrack names should match original game names")
|
||||||
|
|
||||||
|
t.Log("✓ Migration 5 applied successfully, data preserved")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupDB drops the test database
|
||||||
|
func cleanupDB(t *testing.T, host, port, user, password, dbname string) {
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||||
|
host, port, user, password)
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Warning: could not connect to cleanup DB: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Check if database exists before dropping
|
||||||
|
var exists int
|
||||||
|
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
t.Logf("Warning: could not check if DB exists: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists == 1 {
|
||||||
|
_, err = db.Exec("DROP DATABASE " + dbname + " WITH (FORCE)")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Warning: could not drop DB: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestDB creates a fresh test database
|
||||||
|
func createTestDB(t *testing.T, host, port, user, password, dbname string) {
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||||
|
host, port, user, password)
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Drop if exists
|
||||||
|
cleanupDB(t, host, port, user, password, dbname)
|
||||||
|
|
||||||
|
// Create database
|
||||||
|
_, err = db.Exec("CREATE DATABASE " + dbname)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Enable UUID extension if needed
|
||||||
|
connStrDB := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
host, port, user, password, dbname)
|
||||||
|
db2, err := sql.Open("postgres", connStrDB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db2.Close()
|
||||||
|
|
||||||
|
_, err = db2.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Note: uuid-ossp extension may not be available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMigrations applies n migrations to the database using Go migrate library
|
||||||
|
func applyMigrations(t *testing.T, host, port, user, password, dbname string, steps int) {
|
||||||
|
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||||
|
user, password, host, port, dbname)
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", migrationURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
m, err := migrate.NewWithDatabaseInstance(
|
||||||
|
"file://migrations",
|
||||||
|
"postgres", driver)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get current version
|
||||||
|
version, _, err := m.Version()
|
||||||
|
if err != nil && err != migrate.ErrNilVersion {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
if err == migrate.ErrNilVersion {
|
||||||
|
version = 0
|
||||||
|
}
|
||||||
|
t.Logf("Current migration version: %d", version)
|
||||||
|
|
||||||
|
// Apply exactly 'steps' migrations
|
||||||
|
if steps > 0 {
|
||||||
|
err = m.Steps(steps)
|
||||||
|
if err != nil && err != migrate.ErrNoChange {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
} else if steps < 0 {
|
||||||
|
err = m.Steps(steps)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get new version
|
||||||
|
newVersion, _, err := m.Version()
|
||||||
|
if err != nil && err != migrate.ErrNilVersion {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
if err == migrate.ErrNilVersion {
|
||||||
|
newVersion = 0
|
||||||
|
}
|
||||||
|
t.Logf("Migration version after applying %d steps: %d", steps, newVersion)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Drop indexes for sessions table
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_expires;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_token;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_ip;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_created;
|
||||||
|
|
||||||
|
-- Drop sessions table
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
|
||||||
|
-- Drop performance indexes for song_list
|
||||||
|
DROP INDEX IF EXISTS idx_song_list_match_date;
|
||||||
|
DROP INDEX IF EXISTS idx_song_list_match_id;
|
||||||
|
|
||||||
|
-- Drop performance indexes for song
|
||||||
|
DROP INDEX IF EXISTS idx_song_hash;
|
||||||
|
DROP INDEX IF EXISTS idx_song_path;
|
||||||
|
DROP INDEX IF EXISTS idx_song_game_id;
|
||||||
|
DROP INDEX IF EXISTS idx_song_game_id_song_name;
|
||||||
|
|
||||||
|
-- Drop performance indexes for game
|
||||||
|
DROP INDEX IF EXISTS idx_game_deleted;
|
||||||
|
DROP INDEX IF EXISTS idx_game_hash;
|
||||||
|
DROP INDEX IF EXISTS idx_game_path;
|
||||||
|
DROP INDEX IF EXISTS idx_game_name;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- PERFORMANCE INDEXES FOR EXISTING TABLES
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Game table indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_deleted ON game(deleted) WHERE deleted IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_hash ON game(hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_path ON game(path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_name ON game(game_name);
|
||||||
|
|
||||||
|
-- Song table indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_hash ON song(hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_game_id ON song(game_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_game_id_song_name ON song(game_id, song_name);
|
||||||
|
|
||||||
|
-- Song_list table indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_list_match_date ON song_list(match_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_list_match_id ON song_list(match_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SESSIONS TABLE FOR TOKEN MANAGEMENT
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Create sessions table for tracking client tokens
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token VARCHAR(64) PRIMARY KEY,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL,
|
||||||
|
client_type VARCHAR(20) DEFAULT 'web',
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for fast lookup and cleanup
|
||||||
|
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
||||||
|
CREATE INDEX idx_sessions_token ON sessions(token);
|
||||||
|
CREATE INDEX idx_sessions_ip ON sessions(ip_address);
|
||||||
|
CREATE INDEX idx_sessions_created ON sessions(created_at);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Rename game table to soundtrack
|
||||||
|
ALTER TABLE game RENAME TO soundtrack;
|
||||||
|
|
||||||
|
-- Rename primary key sequence
|
||||||
|
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
|
||||||
|
|
||||||
|
-- Rename columns in soundtrack table
|
||||||
|
ALTER TABLE soundtrack RENAME COLUMN game_name TO soundtrack_name;
|
||||||
|
|
||||||
|
-- Update song table: rename game_id to soundtrack_id
|
||||||
|
ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id;
|
||||||
|
|
||||||
|
-- Update song primary key
|
||||||
|
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
|
||||||
|
ALTER TABLE song ADD PRIMARY KEY (soundtrack_id, path);
|
||||||
|
|
||||||
|
-- Update song_list table references
|
||||||
|
ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name;
|
||||||
|
|
||||||
|
-- Rename foreign key constraint
|
||||||
|
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey;
|
||||||
|
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
||||||
|
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
||||||
|
|
||||||
|
-- Rename indexes
|
||||||
|
ALTER INDEX IF EXISTS idx_game_deleted RENAME TO idx_soundtrack_deleted;
|
||||||
|
ALTER INDEX IF EXISTS idx_game_hash RENAME TO idx_soundtrack_hash;
|
||||||
|
ALTER INDEX IF EXISTS idx_game_path RENAME TO idx_soundtrack_path;
|
||||||
|
ALTER INDEX IF EXISTS idx_game_name RENAME TO idx_soundtrack_name;
|
||||||
|
ALTER INDEX IF EXISTS idx_song_game_id RENAME TO idx_song_soundtrack_id;
|
||||||
|
ALTER INDEX IF EXISTS idx_song_game_id_song_name RENAME TO idx_song_soundtrack_id_song_name;
|
||||||
|
ALTER INDEX IF EXISTS song_list_game_name_idx RENAME TO song_list_soundtrack_name_idx;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- 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);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- 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);
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
-- name: ResetGameIdSeq :one
|
|
||||||
SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);
|
|
||||||
|
|
||||||
-- name: GetGameNameById :one
|
|
||||||
SELECT game_name FROM game WHERE id = $1;
|
|
||||||
|
|
||||||
-- name: GetGameById :one
|
|
||||||
SELECT *
|
|
||||||
FROM game
|
|
||||||
WHERE id = $1
|
|
||||||
AND deleted IS NULL;
|
|
||||||
|
|
||||||
-- name: SetGameDeletionDate :exec
|
|
||||||
UPDATE game SET deleted=now() WHERE deleted IS NULL;
|
|
||||||
|
|
||||||
-- name: ClearGames :exec
|
|
||||||
DELETE FROM game;
|
|
||||||
|
|
||||||
-- name: UpdateGameName :exec
|
|
||||||
UPDATE game SET game_name=sqlc.arg(name), path=sqlc.arg(path), last_changed=now() WHERE id=sqlc.arg(id);
|
|
||||||
|
|
||||||
-- name: UpdateGameHash :exec
|
|
||||||
UPDATE game SET hash=sqlc.arg(hash), last_changed=now() WHERE id=sqlc.arg(id);
|
|
||||||
|
|
||||||
-- name: RemoveDeletionDate :exec
|
|
||||||
UPDATE game SET deleted=NULL WHERE id=$1;
|
|
||||||
|
|
||||||
-- name: GetIdByGameName :one
|
|
||||||
SELECT id FROM game WHERE game_name = $1;
|
|
||||||
|
|
||||||
-- name: InsertGame :one
|
|
||||||
INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id;
|
|
||||||
|
|
||||||
-- name: InsertGameWithExistingId :exec
|
|
||||||
INSERT INTO game (id, game_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
|
||||||
|
|
||||||
-- name: FindAllGames :many
|
|
||||||
SELECT *
|
|
||||||
FROM game
|
|
||||||
WHERE deleted IS NULL
|
|
||||||
ORDER BY game_name;
|
|
||||||
|
|
||||||
-- name: GetAllGamesIncludingDeleted :many
|
|
||||||
SELECT *
|
|
||||||
FROM game
|
|
||||||
ORDER BY game_name;
|
|
||||||
|
|
||||||
-- name: AddGamePlayed :exec
|
|
||||||
UPDATE game SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- name: CreateSession :one
|
||||||
|
INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING token, ip_address, user_agent, client_type, expires_at, created_at;
|
||||||
|
|
||||||
|
-- name: GetSession :one
|
||||||
|
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE token = $1
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE token = $1;
|
||||||
|
|
||||||
|
-- name: DeleteExpiredSessions :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE expires_at < NOW();
|
||||||
|
|
||||||
|
-- name: ListSessions :many
|
||||||
|
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY created_at DESC;
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
-- name: ClearSongs :exec
|
-- name: ClearSongs :exec
|
||||||
DELETE FROM song;
|
DELETE FROM song;
|
||||||
|
|
||||||
-- name: ClearSongsByGameId :exec
|
-- name: ClearSongsBySoundtrackId :exec
|
||||||
DELETE FROM song WHERE game_id = $1;
|
DELETE FROM song WHERE soundtrack_id = $1;
|
||||||
|
|
||||||
-- name: AddSong :exec
|
-- name: AddSong :exec
|
||||||
INSERT INTO song(game_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
|
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
|
||||||
|
|
||||||
-- name: CheckSong :one
|
-- name: CheckSong :one
|
||||||
SELECT COUNT(*) FROM song WHERE path = $1;
|
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||||
|
|
||||||
-- name: CheckSongWithHash :one
|
-- name: CheckSongWithHash :one
|
||||||
SELECT COUNT(*) FROM song WHERE hash = $1;
|
SELECT COUNT(*) FROM song WHERE hash = $1;
|
||||||
@@ -20,22 +20,25 @@ SELECT * FROM song WHERE hash = $1;
|
|||||||
UPDATE song SET song_name=$1, file_name=$2, path=$3 where hash=$4;
|
UPDATE song SET song_name=$1, file_name=$2, path=$3 where hash=$4;
|
||||||
|
|
||||||
-- name: AddHashToSong :exec
|
-- name: AddHashToSong :exec
|
||||||
UPDATE song SET hash=$1 where path=$2;
|
UPDATE song SET hash=$1 where soundtrack_id = $2 AND path = $3;
|
||||||
|
|
||||||
-- name: FindSongsFromGame :many
|
-- name: FindSongsFromSoundtrack :many
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM song
|
FROM song
|
||||||
WHERE game_id = $1;
|
WHERE soundtrack_id = $1;
|
||||||
|
|
||||||
-- name: AddSongPlayed :exec
|
-- name: AddSongPlayed :exec
|
||||||
UPDATE song SET times_played = times_played + 1
|
UPDATE song SET times_played = times_played + 1
|
||||||
WHERE game_id = $1 AND song_name = $2;
|
WHERE soundtrack_id = $1 AND song_name = $2;
|
||||||
|
|
||||||
-- name: FetchAllSongs :many
|
-- name: FetchAllSongs :many
|
||||||
SELECT * FROM song;
|
SELECT * FROM song;
|
||||||
|
|
||||||
|
-- name: GetSongById :one
|
||||||
|
SELECT * FROM song WHERE id = $1;
|
||||||
|
|
||||||
-- name: RemoveBrokenSong :exec
|
-- name: RemoveBrokenSong :exec
|
||||||
DELETE FROM song WHERE path = $1;
|
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||||
|
|
||||||
-- name: RemoveBrokenSongs :exec
|
-- name: RemoveBrokenSongs :exec
|
||||||
DELETE FROM song where path = any (sqlc.slice('paths'));
|
DELETE FROM song WHERE id = ANY($1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- name: InsertSongInList :exec
|
-- name: InsertSongInList :exec
|
||||||
INSERT INTO song_list (match_date, match_id, song_no, game_name, song_name)
|
INSERT INTO song_list (match_date, match_id, song_no, soundtrack_name, song_name)
|
||||||
VALUES ($1, $2, $3, $4, $5);
|
VALUES ($1, $2, $3, $4, $5);
|
||||||
|
|
||||||
-- name: GetSongList :many
|
-- name: GetSongList :many
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- name: ResetSoundtrackIdSeq :one
|
||||||
|
SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);
|
||||||
|
|
||||||
|
-- name: GetSoundtrackNameById :one
|
||||||
|
SELECT soundtrack_name FROM soundtrack WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetSoundtrackById :one
|
||||||
|
SELECT *
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted IS NULL;
|
||||||
|
|
||||||
|
-- name: SetSoundtrackDeletionDate :exec
|
||||||
|
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL;
|
||||||
|
|
||||||
|
-- name: ClearSoundtracks :exec
|
||||||
|
DELETE FROM soundtrack;
|
||||||
|
|
||||||
|
-- name: UpdateSoundtrackName :exec
|
||||||
|
UPDATE soundtrack SET soundtrack_name=sqlc.arg(name), path=sqlc.arg(path), last_changed=now() WHERE id=sqlc.arg(id);
|
||||||
|
|
||||||
|
-- name: UpdateSoundtrackHash :exec
|
||||||
|
UPDATE soundtrack SET hash=sqlc.arg(hash), last_changed=now() WHERE id=sqlc.arg(id);
|
||||||
|
|
||||||
|
-- name: RemoveSoundtrackDeletionDate :exec
|
||||||
|
UPDATE soundtrack SET deleted=NULL WHERE id=$1;
|
||||||
|
|
||||||
|
-- name: GetIdBySoundtrackName :one
|
||||||
|
SELECT id FROM soundtrack WHERE soundtrack_name = $1;
|
||||||
|
|
||||||
|
-- name: InsertSoundtrack :one
|
||||||
|
INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id;
|
||||||
|
|
||||||
|
-- name: InsertSoundtrackWithExistingId :exec
|
||||||
|
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
||||||
|
|
||||||
|
-- name: FindAllSoundtracks :many
|
||||||
|
SELECT *
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE deleted IS NULL
|
||||||
|
ORDER BY soundtrack_name;
|
||||||
|
|
||||||
|
-- name: GetAllSoundtracksIncludingDeleted :many
|
||||||
|
SELECT *
|
||||||
|
FROM soundtrack
|
||||||
|
ORDER BY soundtrack_name;
|
||||||
|
|
||||||
|
-- name: AddSoundtrackPlayed :exec
|
||||||
|
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
-- Most played soundtracks with their songs
|
||||||
|
-- name: GetMostPlayedGamesWithSongs :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.last_played as soundtrack_last_played,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played,
|
||||||
|
'file_name', s.file_name
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||||
|
ORDER BY g.times_played DESC, g.soundtrack_name
|
||||||
|
LIMIT $1;
|
||||||
|
|
||||||
|
-- Least played soundtracks with their songs
|
||||||
|
-- name: GetLeastPlayedGamesWithSongs :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.last_played as soundtrack_last_played,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played,
|
||||||
|
'file_name', s.file_name
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||||
|
ORDER BY g.times_played ASC, g.soundtrack_name
|
||||||
|
LIMIT $1;
|
||||||
|
|
||||||
|
-- Most played songs with their soundtrack info
|
||||||
|
-- name: GetMostPlayedSongsWithGame :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;
|
||||||
|
|
||||||
|
-- Least played songs with their soundtrack info
|
||||||
|
-- name: GetLeastPlayedSongsWithGame :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;
|
||||||
|
|
||||||
|
-- Games that have never been played (times_played = 0)
|
||||||
|
-- name: GetNeverPlayedGames :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.added,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL AND g.times_played = 0
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
|
||||||
|
ORDER BY g.soundtrack_name;
|
||||||
|
|
||||||
|
-- Last played soundtracks (most recently played)
|
||||||
|
-- name: GetLastPlayedGames :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.last_played as soundtrack_last_played,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||||
|
ORDER BY g.last_played DESC
|
||||||
|
LIMIT $1;
|
||||||
|
|
||||||
|
-- Oldest played soundtracks (least recently played, but has been played at least once)
|
||||||
|
-- name: GetOldestPlayedGames :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.last_played as soundtrack_last_played,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||||
|
ORDER BY g.last_played ASC
|
||||||
|
LIMIT $1;
|
||||||
|
|
||||||
|
-- Get statistics summary
|
||||||
|
-- name: GetStatisticsSummary :one
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_soundtracks,
|
||||||
|
COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint as played_soundtracks,
|
||||||
|
COALESCE(SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END), 0)::bigint as never_played_soundtracks,
|
||||||
|
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
|
||||||
|
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
|
||||||
|
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
|
||||||
|
COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE deleted IS NULL;
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.31.1
|
|
||||||
// source: game.sql
|
|
||||||
|
|
||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
const addGamePlayed = `-- name: AddGamePlayed :exec
|
|
||||||
UPDATE game SET times_played = times_played + 1, last_played = now() WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) AddGamePlayed(ctx context.Context, id int32) error {
|
|
||||||
_, err := q.db.Exec(ctx, addGamePlayed, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearGames = `-- name: ClearGames :exec
|
|
||||||
DELETE FROM game
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) ClearGames(ctx context.Context) error {
|
|
||||||
_, err := q.db.Exec(ctx, clearGames)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const findAllGames = `-- name: FindAllGames :many
|
|
||||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
|
||||||
FROM game
|
|
||||||
WHERE deleted IS NULL
|
|
||||||
ORDER BY game_name
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) FindAllGames(ctx context.Context) ([]Game, error) {
|
|
||||||
rows, err := q.db.Query(ctx, findAllGames)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Game
|
|
||||||
for rows.Next() {
|
|
||||||
var i Game
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.GameName,
|
|
||||||
&i.Added,
|
|
||||||
&i.Deleted,
|
|
||||||
&i.LastChanged,
|
|
||||||
&i.Path,
|
|
||||||
&i.TimesPlayed,
|
|
||||||
&i.LastPlayed,
|
|
||||||
&i.NumberOfSongs,
|
|
||||||
&i.Hash,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAllGamesIncludingDeleted = `-- name: GetAllGamesIncludingDeleted :many
|
|
||||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
|
||||||
FROM game
|
|
||||||
ORDER BY game_name
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetAllGamesIncludingDeleted(ctx context.Context) ([]Game, error) {
|
|
||||||
rows, err := q.db.Query(ctx, getAllGamesIncludingDeleted)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Game
|
|
||||||
for rows.Next() {
|
|
||||||
var i Game
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.GameName,
|
|
||||||
&i.Added,
|
|
||||||
&i.Deleted,
|
|
||||||
&i.LastChanged,
|
|
||||||
&i.Path,
|
|
||||||
&i.TimesPlayed,
|
|
||||||
&i.LastPlayed,
|
|
||||||
&i.NumberOfSongs,
|
|
||||||
&i.Hash,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const getGameById = `-- name: GetGameById :one
|
|
||||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
|
||||||
FROM game
|
|
||||||
WHERE id = $1
|
|
||||||
AND deleted IS NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetGameById(ctx context.Context, id int32) (Game, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getGameById, id)
|
|
||||||
var i Game
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.GameName,
|
|
||||||
&i.Added,
|
|
||||||
&i.Deleted,
|
|
||||||
&i.LastChanged,
|
|
||||||
&i.Path,
|
|
||||||
&i.TimesPlayed,
|
|
||||||
&i.LastPlayed,
|
|
||||||
&i.NumberOfSongs,
|
|
||||||
&i.Hash,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getGameNameById = `-- name: GetGameNameById :one
|
|
||||||
SELECT game_name FROM game WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetGameNameById(ctx context.Context, id int32) (string, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getGameNameById, id)
|
|
||||||
var game_name string
|
|
||||||
err := row.Scan(&game_name)
|
|
||||||
return game_name, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getIdByGameName = `-- name: GetIdByGameName :one
|
|
||||||
SELECT id FROM game WHERE game_name = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetIdByGameName(ctx context.Context, gameName string) (int32, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getIdByGameName, gameName)
|
|
||||||
var id int32
|
|
||||||
err := row.Scan(&id)
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertGame = `-- name: InsertGame :one
|
|
||||||
INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
|
|
||||||
`
|
|
||||||
|
|
||||||
type InsertGameParams struct {
|
|
||||||
GameName string `json:"game_name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) InsertGame(ctx context.Context, arg InsertGameParams) (int32, error) {
|
|
||||||
row := q.db.QueryRow(ctx, insertGame, arg.GameName, arg.Path, arg.Hash)
|
|
||||||
var id int32
|
|
||||||
err := row.Scan(&id)
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertGameWithExistingId = `-- name: InsertGameWithExistingId :exec
|
|
||||||
INSERT INTO game (id, game_name, path, hash, added) VALUES ($1, $2, $3, $4, now())
|
|
||||||
`
|
|
||||||
|
|
||||||
type InsertGameWithExistingIdParams struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
GameName string `json:"game_name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) InsertGameWithExistingId(ctx context.Context, arg InsertGameWithExistingIdParams) error {
|
|
||||||
_, err := q.db.Exec(ctx, insertGameWithExistingId,
|
|
||||||
arg.ID,
|
|
||||||
arg.GameName,
|
|
||||||
arg.Path,
|
|
||||||
arg.Hash,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeDeletionDate = `-- name: RemoveDeletionDate :exec
|
|
||||||
UPDATE game SET deleted=NULL WHERE id=$1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) RemoveDeletionDate(ctx context.Context, id int32) error {
|
|
||||||
_, err := q.db.Exec(ctx, removeDeletionDate, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetGameIdSeq = `-- name: ResetGameIdSeq :one
|
|
||||||
SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1)
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) ResetGameIdSeq(ctx context.Context) (int64, error) {
|
|
||||||
row := q.db.QueryRow(ctx, resetGameIdSeq)
|
|
||||||
var setval int64
|
|
||||||
err := row.Scan(&setval)
|
|
||||||
return setval, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const setGameDeletionDate = `-- name: SetGameDeletionDate :exec
|
|
||||||
UPDATE game SET deleted=now() WHERE deleted IS NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) SetGameDeletionDate(ctx context.Context) error {
|
|
||||||
_, err := q.db.Exec(ctx, setGameDeletionDate)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateGameHash = `-- name: UpdateGameHash :exec
|
|
||||||
UPDATE game SET hash=$1, last_changed=now() WHERE id=$2
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateGameHashParams struct {
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateGameHash(ctx context.Context, arg UpdateGameHashParams) error {
|
|
||||||
_, err := q.db.Exec(ctx, updateGameHash, arg.Hash, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateGameName = `-- name: UpdateGameName :exec
|
|
||||||
UPDATE game SET game_name=$1, path=$2, last_changed=now() WHERE id=$3
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateGameNameParams struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateGameName(ctx context.Context, arg UpdateGameNameParams) error {
|
|
||||||
_, err := q.db.Exec(ctx, updateGameName, arg.Name, arg.Path, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -6,36 +6,48 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
type Session struct {
|
||||||
ID int32 `json:"id"`
|
Token string `json:"token"`
|
||||||
GameName string `json:"game_name"`
|
IpAddress string `json:"ip_address"`
|
||||||
Added time.Time `json:"added"`
|
UserAgent string `json:"user_agent"`
|
||||||
Deleted *time.Time `json:"deleted"`
|
ClientType *string `json:"client_type"`
|
||||||
LastChanged *time.Time `json:"last_changed"`
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
Path string `json:"path"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
TimesPlayed int32 `json:"times_played"`
|
|
||||||
LastPlayed *time.Time `json:"last_played"`
|
|
||||||
NumberOfSongs int32 `json:"number_of_songs"`
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Song struct {
|
type Song struct {
|
||||||
GameID int32 `json:"game_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
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"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
FileName *string `json:"file_name"`
|
FileName *string `json:"file_name"`
|
||||||
|
ID pgtype.Int4 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SongList struct {
|
type SongList struct {
|
||||||
MatchDate time.Time `json:"match_date"`
|
MatchDate time.Time `json:"match_date"`
|
||||||
MatchID int32 `json:"match_id"`
|
MatchID int32 `json:"match_id"`
|
||||||
SongNo int32 `json:"song_no"`
|
SongNo int32 `json:"song_no"`
|
||||||
GameName *string `json:"game_name"`
|
SoundtrackName *string `json:"soundtrack_name"`
|
||||||
SongName *string `json:"song_name"`
|
SongName *string `json:"song_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Soundtrack struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
Added time.Time `json:"added"`
|
||||||
|
Deleted *time.Time `json:"deleted"`
|
||||||
|
LastChanged *time.Time `json:"last_changed"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
TimesPlayed int32 `json:"times_played"`
|
||||||
|
LastPlayed *time.Time `json:"last_played"`
|
||||||
|
NumberOfSongs int32 `json:"number_of_songs"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Vgmq struct {
|
type Vgmq struct {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: session.sql
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createSession = `-- name: CreateSession :one
|
||||||
|
INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSessionParams struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
IpAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
ClientType *string `json:"client_type"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createSession,
|
||||||
|
arg.Token,
|
||||||
|
arg.IpAddress,
|
||||||
|
arg.UserAgent,
|
||||||
|
arg.ClientType,
|
||||||
|
arg.ExpiresAt,
|
||||||
|
)
|
||||||
|
var i Session
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Token,
|
||||||
|
&i.IpAddress,
|
||||||
|
&i.UserAgent,
|
||||||
|
&i.ClientType,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE expires_at < NOW()
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteExpiredSessions(ctx context.Context) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteExpiredSessions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSession = `-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE token = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteSession, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSession = `-- name: GetSession :one
|
||||||
|
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE token = $1
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSession, token)
|
||||||
|
var i Session
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Token,
|
||||||
|
&i.IpAddress,
|
||||||
|
&i.UserAgent,
|
||||||
|
&i.ClientType,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSessions = `-- name: ListSessions :many
|
||||||
|
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listSessions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Session
|
||||||
|
for rows.Next() {
|
||||||
|
var i Session
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Token,
|
||||||
|
&i.IpAddress,
|
||||||
|
&i.UserAgent,
|
||||||
|
&i.ClientType,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -7,37 +7,40 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const addHashToSong = `-- name: AddHashToSong :exec
|
const addHashToSong = `-- name: AddHashToSong :exec
|
||||||
UPDATE song SET hash=$1 where path=$2
|
UPDATE song SET hash=$1 where soundtrack_id = $2 AND path = $3
|
||||||
`
|
`
|
||||||
|
|
||||||
type AddHashToSongParams struct {
|
type AddHashToSongParams struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Path string `json:"path"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) error {
|
func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) error {
|
||||||
_, err := q.db.Exec(ctx, addHashToSong, arg.Hash, arg.Path)
|
_, err := q.db.Exec(ctx, addHashToSong, arg.Hash, arg.SoundtrackID, arg.Path)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const addSong = `-- name: AddSong :exec
|
const addSong = `-- name: AddSong :exec
|
||||||
INSERT INTO song(game_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
|
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
|
||||||
`
|
`
|
||||||
|
|
||||||
type AddSongParams struct {
|
type AddSongParams struct {
|
||||||
GameID int32 `json:"game_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SongName string `json:"song_name"`
|
SongName string `json:"song_name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
FileName *string `json:"file_name"`
|
FileName *string `json:"file_name"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
||||||
_, err := q.db.Exec(ctx, addSong,
|
_, err := q.db.Exec(ctx, addSong,
|
||||||
arg.GameID,
|
arg.SoundtrackID,
|
||||||
arg.SongName,
|
arg.SongName,
|
||||||
arg.Path,
|
arg.Path,
|
||||||
arg.FileName,
|
arg.FileName,
|
||||||
@@ -48,25 +51,30 @@ func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
|||||||
|
|
||||||
const addSongPlayed = `-- name: AddSongPlayed :exec
|
const addSongPlayed = `-- name: AddSongPlayed :exec
|
||||||
UPDATE song SET times_played = times_played + 1
|
UPDATE song SET times_played = times_played + 1
|
||||||
WHERE game_id = $1 AND song_name = $2
|
WHERE soundtrack_id = $1 AND song_name = $2
|
||||||
`
|
`
|
||||||
|
|
||||||
type AddSongPlayedParams struct {
|
type AddSongPlayedParams struct {
|
||||||
GameID int32 `json:"game_id"`
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
SongName string `json:"song_name"`
|
SongName string `json:"song_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error {
|
func (q *Queries) AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error {
|
||||||
_, err := q.db.Exec(ctx, addSongPlayed, arg.GameID, arg.SongName)
|
_, err := q.db.Exec(ctx, addSongPlayed, arg.SoundtrackID, arg.SongName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkSong = `-- name: CheckSong :one
|
const checkSong = `-- name: CheckSong :one
|
||||||
SELECT COUNT(*) FROM song WHERE path = $1
|
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) CheckSong(ctx context.Context, path string) (int64, error) {
|
type CheckSongParams struct {
|
||||||
row := q.db.QueryRow(ctx, checkSong, path)
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CheckSong(ctx context.Context, arg CheckSongParams) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, checkSong, arg.SoundtrackID, arg.Path)
|
||||||
var count int64
|
var count int64
|
||||||
err := row.Scan(&count)
|
err := row.Scan(&count)
|
||||||
return count, err
|
return count, err
|
||||||
@@ -92,17 +100,17 @@ func (q *Queries) ClearSongs(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearSongsByGameId = `-- name: ClearSongsByGameId :exec
|
const clearSongsBySoundtrackId = `-- name: ClearSongsBySoundtrackId :exec
|
||||||
DELETE FROM song WHERE game_id = $1
|
DELETE FROM song WHERE soundtrack_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ClearSongsByGameId(ctx context.Context, gameID int32) error {
|
func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error {
|
||||||
_, err := q.db.Exec(ctx, clearSongsByGameId, gameID)
|
_, err := q.db.Exec(ctx, clearSongsBySoundtrackId, soundtrackID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchAllSongs = `-- name: FetchAllSongs :many
|
const fetchAllSongs = `-- name: FetchAllSongs :many
|
||||||
SELECT game_id, song_name, path, times_played, hash, file_name FROM song
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||||
@@ -115,12 +123,13 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Song
|
var i Song
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.GameID,
|
&i.SoundtrackID,
|
||||||
&i.SongName,
|
&i.SongName,
|
||||||
&i.Path,
|
&i.Path,
|
||||||
&i.TimesPlayed,
|
&i.TimesPlayed,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.FileName,
|
&i.FileName,
|
||||||
|
&i.ID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -132,14 +141,14 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const findSongsFromGame = `-- name: FindSongsFromGame :many
|
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many
|
||||||
SELECT game_id, song_name, path, times_played, hash, file_name
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id
|
||||||
FROM song
|
FROM song
|
||||||
WHERE game_id = $1
|
WHERE soundtrack_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song, error) {
|
func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error) {
|
||||||
rows, err := q.db.Query(ctx, findSongsFromGame, gameID)
|
rows, err := q.db.Query(ctx, findSongsFromSoundtrack, soundtrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -148,12 +157,13 @@ func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song,
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Song
|
var i Song
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.GameID,
|
&i.SoundtrackID,
|
||||||
&i.SongName,
|
&i.SongName,
|
||||||
&i.Path,
|
&i.Path,
|
||||||
&i.TimesPlayed,
|
&i.TimesPlayed,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.FileName,
|
&i.FileName,
|
||||||
|
&i.ID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -165,39 +175,64 @@ func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song,
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSongById = `-- name: GetSongById :one
|
||||||
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSongById, id)
|
||||||
|
var i Song
|
||||||
|
err := row.Scan(
|
||||||
|
&i.SoundtrackID,
|
||||||
|
&i.SongName,
|
||||||
|
&i.Path,
|
||||||
|
&i.TimesPlayed,
|
||||||
|
&i.Hash,
|
||||||
|
&i.FileName,
|
||||||
|
&i.ID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getSongWithHash = `-- name: GetSongWithHash :one
|
const getSongWithHash = `-- name: GetSongWithHash :one
|
||||||
SELECT game_id, song_name, path, times_played, hash, file_name FROM song WHERE hash = $1
|
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE hash = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
|
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
|
||||||
row := q.db.QueryRow(ctx, getSongWithHash, hash)
|
row := q.db.QueryRow(ctx, getSongWithHash, hash)
|
||||||
var i Song
|
var i Song
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.GameID,
|
&i.SoundtrackID,
|
||||||
&i.SongName,
|
&i.SongName,
|
||||||
&i.Path,
|
&i.Path,
|
||||||
&i.TimesPlayed,
|
&i.TimesPlayed,
|
||||||
&i.Hash,
|
&i.Hash,
|
||||||
&i.FileName,
|
&i.FileName,
|
||||||
|
&i.ID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeBrokenSong = `-- name: RemoveBrokenSong :exec
|
const removeBrokenSong = `-- name: RemoveBrokenSong :exec
|
||||||
DELETE FROM song WHERE path = $1
|
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) RemoveBrokenSong(ctx context.Context, path string) error {
|
type RemoveBrokenSongParams struct {
|
||||||
_, err := q.db.Exec(ctx, removeBrokenSong, path)
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, removeBrokenSong, arg.SoundtrackID, arg.Path)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
|
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
|
||||||
DELETE FROM song where path = any ($1)
|
DELETE FROM song WHERE id = ANY($1)
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) RemoveBrokenSongs(ctx context.Context, paths []string) error {
|
func (q *Queries) RemoveBrokenSongs(ctx context.Context, id pgtype.Int4) error {
|
||||||
_, err := q.db.Exec(ctx, removeBrokenSongs, paths)
|
_, err := q.db.Exec(ctx, removeBrokenSongs, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const getSongList = `-- name: GetSongList :many
|
const getSongList = `-- name: GetSongList :many
|
||||||
SELECT match_date, match_id, song_no, game_name, song_name
|
SELECT match_date, match_id, song_no, soundtrack_name, song_name
|
||||||
FROM song_list
|
FROM song_list
|
||||||
WHERE match_date = $1
|
WHERE match_date = $1
|
||||||
ORDER BY song_no DESC
|
ORDER BY song_no DESC
|
||||||
@@ -30,7 +30,7 @@ func (q *Queries) GetSongList(ctx context.Context, matchDate time.Time) ([]SongL
|
|||||||
&i.MatchDate,
|
&i.MatchDate,
|
||||||
&i.MatchID,
|
&i.MatchID,
|
||||||
&i.SongNo,
|
&i.SongNo,
|
||||||
&i.GameName,
|
&i.SoundtrackName,
|
||||||
&i.SongName,
|
&i.SongName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -44,16 +44,16 @@ func (q *Queries) GetSongList(ctx context.Context, matchDate time.Time) ([]SongL
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertSongInList = `-- name: InsertSongInList :exec
|
const insertSongInList = `-- name: InsertSongInList :exec
|
||||||
INSERT INTO song_list (match_date, match_id, song_no, game_name, song_name)
|
INSERT INTO song_list (match_date, match_id, song_no, soundtrack_name, song_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertSongInListParams struct {
|
type InsertSongInListParams struct {
|
||||||
MatchDate time.Time `json:"match_date"`
|
MatchDate time.Time `json:"match_date"`
|
||||||
MatchID int32 `json:"match_id"`
|
MatchID int32 `json:"match_id"`
|
||||||
SongNo int32 `json:"song_no"`
|
SongNo int32 `json:"song_no"`
|
||||||
GameName *string `json:"game_name"`
|
SoundtrackName *string `json:"soundtrack_name"`
|
||||||
SongName *string `json:"song_name"`
|
SongName *string `json:"song_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertSongInList(ctx context.Context, arg InsertSongInListParams) error {
|
func (q *Queries) InsertSongInList(ctx context.Context, arg InsertSongInListParams) error {
|
||||||
@@ -61,7 +61,7 @@ func (q *Queries) InsertSongInList(ctx context.Context, arg InsertSongInListPara
|
|||||||
arg.MatchDate,
|
arg.MatchDate,
|
||||||
arg.MatchID,
|
arg.MatchID,
|
||||||
arg.SongNo,
|
arg.SongNo,
|
||||||
arg.GameName,
|
arg.SoundtrackName,
|
||||||
arg.SongName,
|
arg.SongName,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: soundtrack.sql
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
|
||||||
|
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) AddSoundtrackPlayed(ctx context.Context, id int32) error {
|
||||||
|
_, err := q.db.Exec(ctx, addSoundtrackPlayed, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSoundtracks = `-- name: ClearSoundtracks :exec
|
||||||
|
DELETE FROM soundtrack
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ClearSoundtracks(ctx context.Context) error {
|
||||||
|
_, err := q.db.Exec(ctx, clearSoundtracks)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
|
||||||
|
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE deleted IS NULL
|
||||||
|
ORDER BY soundtrack_name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) {
|
||||||
|
rows, err := q.db.Query(ctx, findAllSoundtracks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Soundtrack
|
||||||
|
for rows.Next() {
|
||||||
|
var i Soundtrack
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.Added,
|
||||||
|
&i.Deleted,
|
||||||
|
&i.LastChanged,
|
||||||
|
&i.Path,
|
||||||
|
&i.TimesPlayed,
|
||||||
|
&i.LastPlayed,
|
||||||
|
&i.NumberOfSongs,
|
||||||
|
&i.Hash,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
|
||||||
|
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||||
|
FROM soundtrack
|
||||||
|
ORDER BY soundtrack_name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getAllSoundtracksIncludingDeleted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Soundtrack
|
||||||
|
for rows.Next() {
|
||||||
|
var i Soundtrack
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.Added,
|
||||||
|
&i.Deleted,
|
||||||
|
&i.LastChanged,
|
||||||
|
&i.Path,
|
||||||
|
&i.TimesPlayed,
|
||||||
|
&i.LastPlayed,
|
||||||
|
&i.NumberOfSongs,
|
||||||
|
&i.Hash,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIdBySoundtrackName = `-- name: GetIdBySoundtrackName :one
|
||||||
|
SELECT id FROM soundtrack WHERE soundtrack_name = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getIdBySoundtrackName, soundtrackName)
|
||||||
|
var id int32
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSoundtrackById = `-- name: GetSoundtrackById :one
|
||||||
|
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSoundtrackById, id)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSoundtrackNameById = `-- name: GetSoundtrackNameById :one
|
||||||
|
SELECT soundtrack_name FROM soundtrack WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSoundtrackNameById, id)
|
||||||
|
var soundtrack_name string
|
||||||
|
err := row.Scan(&soundtrack_name)
|
||||||
|
return soundtrack_name, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSoundtrack = `-- name: InsertSoundtrack :one
|
||||||
|
INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertSoundtrackParams struct {
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertSoundtrack, arg.SoundtrackName, arg.Path, arg.Hash)
|
||||||
|
var id int32
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec
|
||||||
|
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now())
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertSoundtrackWithExistingIdParams struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertSoundtrackWithExistingId,
|
||||||
|
arg.ID,
|
||||||
|
arg.SoundtrackName,
|
||||||
|
arg.Path,
|
||||||
|
arg.Hash,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSoundtrackDeletionDate = `-- name: RemoveSoundtrackDeletionDate :exec
|
||||||
|
UPDATE soundtrack SET deleted=NULL WHERE id=$1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error {
|
||||||
|
_, err := q.db.Exec(ctx, removeSoundtrackDeletionDate, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSoundtrackIdSeq = `-- name: ResetSoundtrackIdSeq :one
|
||||||
|
SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1)
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, resetSoundtrackIdSeq)
|
||||||
|
var setval int64
|
||||||
|
err := row.Scan(&setval)
|
||||||
|
return setval, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
|
||||||
|
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SetSoundtrackDeletionDate(ctx context.Context) error {
|
||||||
|
_, err := q.db.Exec(ctx, setSoundtrackDeletionDate)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSoundtrackHash = `-- name: UpdateSoundtrackHash :exec
|
||||||
|
UPDATE soundtrack SET hash=$1, last_changed=now() WHERE id=$2
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateSoundtrackHashParams struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateSoundtrackHash(ctx context.Context, arg UpdateSoundtrackHashParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, updateSoundtrackHash, arg.Hash, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSoundtrackName = `-- name: UpdateSoundtrackName :exec
|
||||||
|
UPDATE soundtrack SET soundtrack_name=$1, path=$2, last_changed=now() WHERE id=$3
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateSoundtrackNameParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateSoundtrackName(ctx context.Context, arg UpdateSoundtrackNameParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, updateSoundtrackName, arg.Name, arg.Path, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: statistics.sql
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLastPlayedGames = `-- name: GetLastPlayedGames :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.last_played as soundtrack_last_played,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||||
|
ORDER BY g.last_played DESC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLastPlayedGamesRow struct {
|
||||||
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last played soundtracks (most recently played)
|
||||||
|
func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLastPlayedGamesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getLastPlayedGames, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetLastPlayedGamesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLastPlayedGamesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SoundtrackID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.SoundtrackPlayed,
|
||||||
|
&i.SoundtrackLastPlayed,
|
||||||
|
&i.Songs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLeastPlayedGamesWithSongs = `-- name: GetLeastPlayedGamesWithSongs :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.last_played as soundtrack_last_played,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played,
|
||||||
|
'file_name', s.file_name
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||||
|
ORDER BY g.times_played ASC, g.soundtrack_name
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLeastPlayedGamesWithSongsRow struct {
|
||||||
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Least played soundtracks with their songs
|
||||||
|
func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedGamesWithSongsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getLeastPlayedGamesWithSongs, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetLeastPlayedGamesWithSongsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLeastPlayedGamesWithSongsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SoundtrackID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.SoundtrackPlayed,
|
||||||
|
&i.SoundtrackLastPlayed,
|
||||||
|
&i.Songs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLeastPlayedSongsWithGame = `-- name: GetLeastPlayedSongsWithGame :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 GetLeastPlayedSongsWithGameRow 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) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithGameRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getLeastPlayedSongsWithGame, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetLeastPlayedSongsWithGameRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLeastPlayedSongsWithGameRow
|
||||||
|
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 getMostPlayedGamesWithSongs = `-- name: GetMostPlayedGamesWithSongs :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.last_played as soundtrack_last_played,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played,
|
||||||
|
'file_name', s.file_name
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||||
|
ORDER BY g.times_played DESC, g.soundtrack_name
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetMostPlayedGamesWithSongsRow struct {
|
||||||
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most played soundtracks with their songs
|
||||||
|
func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedGamesWithSongsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getMostPlayedGamesWithSongs, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetMostPlayedGamesWithSongsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetMostPlayedGamesWithSongsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SoundtrackID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.SoundtrackPlayed,
|
||||||
|
&i.SoundtrackLastPlayed,
|
||||||
|
&i.Songs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMostPlayedSongsWithGame = `-- name: GetMostPlayedSongsWithGame :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
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.added,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL AND g.times_played = 0
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
|
||||||
|
ORDER BY g.soundtrack_name
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetNeverPlayedGamesRow struct {
|
||||||
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
|
Added time.Time `json:"added"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Games that have never been played (times_played = 0)
|
||||||
|
func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGamesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getNeverPlayedGames)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetNeverPlayedGamesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetNeverPlayedGamesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SoundtrackID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.SoundtrackPlayed,
|
||||||
|
&i.Added,
|
||||||
|
&i.Songs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOldestPlayedGames = `-- name: GetOldestPlayedGames :many
|
||||||
|
SELECT
|
||||||
|
g.id as soundtrack_id,
|
||||||
|
g.soundtrack_name,
|
||||||
|
g.times_played as soundtrack_played,
|
||||||
|
g.last_played as soundtrack_last_played,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'song_name', s.song_name,
|
||||||
|
'path', s.path,
|
||||||
|
'times_played', s.times_played
|
||||||
|
)
|
||||||
|
) as songs
|
||||||
|
FROM soundtrack g
|
||||||
|
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||||
|
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||||
|
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||||
|
ORDER BY g.last_played ASC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetOldestPlayedGamesRow struct {
|
||||||
|
SoundtrackID int32 `json:"soundtrack_id"`
|
||||||
|
SoundtrackName string `json:"soundtrack_name"`
|
||||||
|
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||||
|
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||||
|
Songs []byte `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest played soundtracks (least recently played, but has been played at least once)
|
||||||
|
func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetOldestPlayedGamesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getOldestPlayedGames, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetOldestPlayedGamesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetOldestPlayedGamesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SoundtrackID,
|
||||||
|
&i.SoundtrackName,
|
||||||
|
&i.SoundtrackPlayed,
|
||||||
|
&i.SoundtrackLastPlayed,
|
||||||
|
&i.Songs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatisticsSummary = `-- name: GetStatisticsSummary :one
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_soundtracks,
|
||||||
|
COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint as played_soundtracks,
|
||||||
|
COALESCE(SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END), 0)::bigint as never_played_soundtracks,
|
||||||
|
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
|
||||||
|
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
|
||||||
|
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
|
||||||
|
COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
|
||||||
|
FROM soundtrack
|
||||||
|
WHERE deleted IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetStatisticsSummaryRow struct {
|
||||||
|
TotalSoundtracks int64 `json:"total_soundtracks"`
|
||||||
|
PlayedSoundtracks int64 `json:"played_soundtracks"`
|
||||||
|
NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"`
|
||||||
|
TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"`
|
||||||
|
AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"`
|
||||||
|
MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"`
|
||||||
|
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get statistics summary
|
||||||
|
func (q *Queries) GetStatisticsSummary(ctx context.Context) (GetStatisticsSummaryRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getStatisticsSummary)
|
||||||
|
var i GetStatisticsSummaryRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TotalSoundtracks,
|
||||||
|
&i.PlayedSoundtracks,
|
||||||
|
&i.NeverPlayedSoundtracks,
|
||||||
|
&i.TotalSoundtrackPlays,
|
||||||
|
&i.AvgSoundtrackPlays,
|
||||||
|
&i.MaxSoundtrackPlays,
|
||||||
|
&i.MinSoundtrackPlays,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
+42
-10
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -16,6 +17,8 @@ var (
|
|||||||
testDBUser string
|
testDBUser string
|
||||||
testDBPassword string
|
testDBPassword string
|
||||||
testDBName string
|
testDBName string
|
||||||
|
// TestDatabase is the database instance for tests
|
||||||
|
TestDatabase *Database
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSetupDB initializes the test database using existing functions
|
// TestSetupDB initializes the test database using existing functions
|
||||||
@@ -44,9 +47,28 @@ func TestSetupDB(t *testing.T) {
|
|||||||
// Create the database first (testuser is a superuser in the container)
|
// Create the database first (testuser is a superuser in the container)
|
||||||
createTestDatabase(host, port, dbname, user, password)
|
createTestDatabase(host, port, dbname, user, password)
|
||||||
|
|
||||||
// Now run migrations using the existing function
|
// Create database instance and run migrations
|
||||||
Migrate_db(host, port, user, password, dbname)
|
var err error
|
||||||
InitDB(host, port, user, password, dbname)
|
TestDatabase, err = NewDatabase(host, port, user, password, dbname)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any existing schema to ensure clean state
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err = TestDatabase.Pool.Exec(ctx, "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Warning: Could not clean schema: %v", err)
|
||||||
|
// Continue anyway, migrations might still work
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if err := TestDatabase.RunMigrations(); err != nil {
|
||||||
|
// Clean up on failure to prevent nil pointer issues in other tests
|
||||||
|
TestDatabase.Close()
|
||||||
|
TestDatabase = nil
|
||||||
|
t.Fatalf("Failed to run migrations: %v", err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,33 +108,43 @@ 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
|
||||||
|
// Note: We also don't nil TestDatabase to allow reuse across tests
|
||||||
|
// if TestDatabase != nil {
|
||||||
|
// TestDatabase.Close()
|
||||||
|
// TestDatabase = nil
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClearDatabase clears all data from the test database
|
// TestClearDatabase clears all data from the test database
|
||||||
// Useful for running tests with a clean slate
|
// Useful for running tests with a clean slate
|
||||||
func TestClearDatabase(t *testing.T) {
|
func TestClearDatabase(t *testing.T) {
|
||||||
if Dbpool == nil {
|
if TestDatabase == nil || TestDatabase.Pool == nil {
|
||||||
t.Skip("Database not initialized")
|
t.Skip("Database not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
_, err := Dbpool.Exec(Ctx, "TRUNCATE TABLE "+table+" CASCADE")
|
_, err := TestDatabase.Pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Failed to truncate table %s: %v", table, err)
|
t.Logf("Failed to truncate table %s: %v", table, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset sequences
|
// Reset sequences (renamed from game_id_seq to soundtrack_id_seq in migration 000005)
|
||||||
_, err := Dbpool.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"music-server/internal/backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CharacterHandler struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCharacterHandler() *CharacterHandler {
|
||||||
|
return &CharacterHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCharacterList godoc
|
||||||
|
// @Summary Get list of characters
|
||||||
|
// @Description Returns a list of all available characters
|
||||||
|
// @Tags characters
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} string
|
||||||
|
// @Router /characters [get]
|
||||||
|
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
||||||
|
characters := backend.GetCharacterList()
|
||||||
|
return ctx.JSON(http.StatusOK, characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCharacter godoc
|
||||||
|
// @Summary Get character image
|
||||||
|
// @Description Returns the image for a specific character
|
||||||
|
// @Tags characters
|
||||||
|
// @Accept json
|
||||||
|
// @Produce image/png
|
||||||
|
// @Param name query string true "Character name"
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Router /character [get]
|
||||||
|
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
||||||
|
character := ctx.QueryParam("name")
|
||||||
|
characterPath := backend.GetCharacter(character)
|
||||||
|
file, err := os.Open(characterPath)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return ctx.Stream(http.StatusOK, "image/png", file)
|
||||||
|
}
|
||||||
@@ -5,45 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"music-server/internal/backend"
|
|
||||||
"music-server/internal/db"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHealthCheck verifies the health endpoint returns database status
|
|
||||||
func TestHealthCheck(t *testing.T) {
|
|
||||||
// Setup database
|
|
||||||
db.TestSetupDB(t)
|
|
||||||
defer db.TestTearDownDB(t)
|
|
||||||
|
|
||||||
e := StartTestServer(t)
|
|
||||||
|
|
||||||
resp := MakeTestRequest(t, e, "GET", "/health")
|
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
|
||||||
|
|
||||||
var healthData map[string]string
|
|
||||||
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, healthData)
|
|
||||||
assert.Equal(t, "up", healthData["status"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetVersion verifies the version endpoint returns version history
|
|
||||||
func TestGetVersion(t *testing.T) {
|
|
||||||
e := StartTestServer(t)
|
|
||||||
|
|
||||||
resp := MakeTestRequest(t, e, "GET", "/version")
|
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
|
||||||
|
|
||||||
var versionData backend.VersionData
|
|
||||||
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, versionData.Version)
|
|
||||||
assert.NotEmpty(t, versionData.Changelog)
|
|
||||||
assert.NotEmpty(t, versionData.History)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetCharacterList verifies the characters endpoint returns list of characters
|
// TestGetCharacterList verifies the characters endpoint returns list of characters
|
||||||
func TestGetCharacterList(t *testing.T) {
|
func TestGetCharacterList(t *testing.T) {
|
||||||
e := StartTestServer(t)
|
e := StartTestServer(t)
|
||||||
@@ -81,16 +45,3 @@ func TestGetCharacterNotFound(t *testing.T) {
|
|||||||
// Should return 404 or similar error
|
// Should return 404 or similar error
|
||||||
assert.NotEqual(t, http.StatusOK, resp.Code)
|
assert.NotEqual(t, http.StatusOK, resp.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDBTest verifies the database test endpoint
|
|
||||||
func TestDBTest(t *testing.T) {
|
|
||||||
// Setup database
|
|
||||||
db.TestSetupDB(t)
|
|
||||||
defer db.TestTearDownDB(t)
|
|
||||||
|
|
||||||
e := StartTestServer(t)
|
|
||||||
|
|
||||||
resp := MakeTestRequest(t, e, "GET", "/dbtest")
|
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
|
||||||
assert.Contains(t, resp.Body.String(), "TestedDB")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"music-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthHandler struct {
|
||||||
|
db *db.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthHandler(database *db.Database) *HealthHandler {
|
||||||
|
return &HealthHandler{db: database}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck godoc
|
||||||
|
//
|
||||||
|
// @Summary Check server health
|
||||||
|
// @Description Returns the health status of the server
|
||||||
|
// @Tags health
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {string} string "OK"
|
||||||
|
// @Router /health [get]
|
||||||
|
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
|
||||||
|
return ctx.JSON(http.StatusOK, h.db.Health())
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHealthCheck verifies the health endpoint returns database status
|
||||||
|
func TestHealthCheck(t *testing.T) {
|
||||||
|
e := StartTestServer(t)
|
||||||
|
// No explicit teardown - handled by StartTestServer's sync.Once
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/health")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var healthData map[string]string
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, healthData)
|
||||||
|
assert.Equal(t, "up", healthData["status"])
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"music-server/internal/backend"
|
|
||||||
"music-server/internal/db"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type IndexHandler struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewIndexHandler() *IndexHandler {
|
|
||||||
return &IndexHandler{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVersion godoc
|
|
||||||
//
|
|
||||||
// @Summary Getting the version of the backend
|
|
||||||
// @Description get string by ID
|
|
||||||
// @Tags accounts
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} backend.VersionData
|
|
||||||
// @Failure 404 {object} string
|
|
||||||
// @Router /version [get]
|
|
||||||
func (i *IndexHandler) GetVersion(ctx *echo.Context) error {
|
|
||||||
versionHistory := backend.GetVersionHistory()
|
|
||||||
if versionHistory.Version == "" {
|
|
||||||
return ctx.JSON(http.StatusNotFound, "version not found")
|
|
||||||
}
|
|
||||||
return ctx.JSON(http.StatusOK, versionHistory)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDBTest godoc
|
|
||||||
// @Summary Test database connection
|
|
||||||
// @Description Tests the database connection
|
|
||||||
// @Tags database
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {string} string "TestedDB"
|
|
||||||
// @Router /dbtest [get]
|
|
||||||
func (i *IndexHandler) GetDBTest(ctx *echo.Context) error {
|
|
||||||
backend.TestDB()
|
|
||||||
return ctx.JSON(http.StatusOK, "TestedDB")
|
|
||||||
}
|
|
||||||
|
|
||||||
// HealthCheck godoc
|
|
||||||
// @Summary Check server health
|
|
||||||
// @Description Returns the health status of the server
|
|
||||||
// @Tags health
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {string} string "OK"
|
|
||||||
// @Router /health [get]
|
|
||||||
func (i *IndexHandler) HealthCheck(ctx *echo.Context) error {
|
|
||||||
return ctx.JSON(http.StatusOK, db.Health())
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCharacterList godoc
|
|
||||||
// @Summary Get list of characters
|
|
||||||
// @Description Returns a list of all available characters
|
|
||||||
// @Tags characters
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {array} string
|
|
||||||
// @Router /characters [get]
|
|
||||||
func (i *IndexHandler) GetCharacterList(ctx *echo.Context) error {
|
|
||||||
characters := backend.GetCharacterList()
|
|
||||||
return ctx.JSON(http.StatusOK, characters)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCharacter godoc
|
|
||||||
// @Summary Get character image
|
|
||||||
// @Description Returns the image for a specific character
|
|
||||||
// @Tags characters
|
|
||||||
// @Accept json
|
|
||||||
// @Produce image/png
|
|
||||||
// @Param name query string true "Character name"
|
|
||||||
// @Success 200 {file} file
|
|
||||||
// @Router /character [get]
|
|
||||||
func (i *IndexHandler) GetCharacter(ctx *echo.Context) error {
|
|
||||||
character := ctx.QueryParam("name")
|
|
||||||
return ctx.File(backend.GetCharacter(character))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeprecationMiddleware adds deprecation warning to responses
|
||||||
|
// for old endpoints that are being phased out in favor of /api/v1/*
|
||||||
|
func DeprecationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
// Add deprecation warning header
|
||||||
|
c.Response().Header().Add("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
|
||||||
|
c.Response().Header().Add("Deprecation", "true")
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package middleware provides Echo middleware for the MusicServer application.
|
||||||
|
package middleware
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenAuthMiddleware returns an Echo middleware that validates session tokens
|
||||||
|
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
// Extract token from Authorization header
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearer token format
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
|
||||||
|
}
|
||||||
|
|
||||||
|
token := parts[1]
|
||||||
|
queries := repository.New(pool)
|
||||||
|
session, err := queries.GetSession(c.Request().Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Warn("Invalid token attempt",
|
||||||
|
zap.String("token", token),
|
||||||
|
zap.String("ip", c.RealIP()),
|
||||||
|
zap.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
if time.Now().After(session.ExpiresAt.Time) {
|
||||||
|
// Clean up expired session in background
|
||||||
|
go func() {
|
||||||
|
queries.DeleteSession(c.Request().Context(), token)
|
||||||
|
}()
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add session to request context for potential use by handlers
|
||||||
|
c.Set("session", session)
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenIPCheckMiddleware checks if the request IP matches the session IP
|
||||||
|
func TokenIPCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
sessionVal := c.Get("session")
|
||||||
|
if sessionVal == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "No session in context"})
|
||||||
|
}
|
||||||
|
session := sessionVal.(repository.Session)
|
||||||
|
if session.IpAddress != c.RealIP() {
|
||||||
|
logging.GetLogger().Warn("Token IP mismatch",
|
||||||
|
zap.String("token_ip", session.IpAddress),
|
||||||
|
zap.String("request_ip", c.RealIP()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,8 +229,8 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
|||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllGames godoc
|
// GetAllSoundtracks godoc
|
||||||
// @Summary Get all games
|
// @Summary Get all soundtracks
|
||||||
// @Description Returns a list of all games in order
|
// @Description Returns a list of all games in order
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -238,17 +238,17 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
|||||||
// @Success 200 {array} map[string]interface{}
|
// @Success 200 {array} map[string]interface{}
|
||||||
// @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) GetAllGames(ctx *echo.Context) error {
|
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
logging.GetLogger().Info("Syncing is in progress")
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||||
}
|
}
|
||||||
gameList := backend.GetAllGames()
|
soundtrackList := backend.GetAllSoundtracks()
|
||||||
return ctx.JSON(http.StatusOK, gameList)
|
return ctx.JSON(http.StatusOK, soundtrackList)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllGamesRandom godoc
|
// GetAllSoundtracksRandom godoc
|
||||||
// @Summary Get all games random
|
// @Summary Get all soundtracks random
|
||||||
// @Description Returns a list of all games in random order
|
// @Description Returns a list of all games in random order
|
||||||
// @Tags music
|
// @Tags music
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -256,13 +256,13 @@ func (m *MusicHandler) GetAllGames(ctx *echo.Context) error {
|
|||||||
// @Success 200 {array} map[string]interface{}
|
// @Success 200 {array} map[string]interface{}
|
||||||
// @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) GetAllGamesRandom(ctx *echo.Context) error {
|
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
logging.GetLogger().Info("Syncing is in progress")
|
logging.GetLogger().Info("Syncing is in progress")
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||||
}
|
}
|
||||||
gameList := backend.GetAllGamesRandom()
|
soundtrackList := backend.GetAllSoundtracksRandom()
|
||||||
return ctx.JSON(http.StatusOK, gameList)
|
return ctx.JSON(http.StatusOK, soundtrackList)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutPlayed godoc
|
// PutPlayed godoc
|
||||||
|
|||||||
+103
-38
@@ -2,15 +2,16 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"music-server/cmd/web"
|
"music-server/cmd/web"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
"music-server/internal/server/middleware"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/labstack/echo/v5/middleware"
|
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||||
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
||||||
"music-server/internal/logging"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,16 +30,16 @@ import (
|
|||||||
// @BasePath /
|
// @BasePath /
|
||||||
func (s *Server) RegisterRoutes() http.Handler {
|
func (s *Server) RegisterRoutes() http.Handler {
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
// Serve OpenAPI spec at /openapi
|
// Serve OpenAPI spec at /openapi
|
||||||
e.GET("/openapi", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
e.GET("/openapi", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
http.ServeFile(w, r, "cmd/docs/swagger.json")
|
http.ServeFile(w, r, "cmd/docs/swagger.json")
|
||||||
})))
|
})))
|
||||||
e.Use(logging.RequestLogger())
|
e.Use(logging.RequestLogger())
|
||||||
e.Use(middleware.Recover())
|
e.Use(echoMiddleware.Recover())
|
||||||
|
|
||||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||||
AllowOrigins: []string{"https://*", "http://*"},
|
AllowOrigins: []string{"https://*", "http://*"},
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||||
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
@@ -57,47 +58,111 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
// Swagger UI
|
// Swagger UI
|
||||||
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
||||||
|
|
||||||
index := NewIndexHandler()
|
// ============================================
|
||||||
e.GET("/version", index.GetVersion)
|
// Legacy Endpoints (Deprecated - use /api/v1/ instead)
|
||||||
e.GET("/dbtest", index.GetDBTest)
|
// ============================================
|
||||||
e.GET("/health", index.HealthCheck)
|
deprecatedMiddleware := middleware.DeprecationMiddleware
|
||||||
e.GET("/character", index.GetCharacter)
|
|
||||||
e.GET("/characters", index.GetCharacterList)
|
health := NewHealthHandler(s.db)
|
||||||
|
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
|
||||||
|
|
||||||
|
version := NewVersionHandler()
|
||||||
|
e.GET("/version", deprecatedMiddleware(version.GetLatestVersion))
|
||||||
|
e.GET("/version/history", deprecatedMiddleware(version.GetVersionHistory))
|
||||||
|
|
||||||
|
character := NewCharacterHandler()
|
||||||
|
e.GET("/character", deprecatedMiddleware(character.GetCharacter))
|
||||||
|
e.GET("/characters", deprecatedMiddleware(character.GetCharacterList))
|
||||||
|
|
||||||
download := NewDownloadHandler()
|
download := NewDownloadHandler()
|
||||||
e.GET("/download", download.checkLatest)
|
e.GET("/download", deprecatedMiddleware(download.checkLatest))
|
||||||
e.GET("/download/list", download.listAssetsOfLatest)
|
e.GET("/download/list", deprecatedMiddleware(download.listAssetsOfLatest))
|
||||||
e.GET("/download/windows", download.downloadLatestWindows)
|
e.GET("/download/windows", deprecatedMiddleware(download.downloadLatestWindows))
|
||||||
e.GET("/download/linux", download.downloadLatestLinux)
|
e.GET("/download/linux", deprecatedMiddleware(download.downloadLatestLinux))
|
||||||
|
|
||||||
sync := NewSyncHandler()
|
sync := NewSyncHandler()
|
||||||
syncGroup := e.Group("/sync")
|
syncGroup := e.Group("/sync")
|
||||||
syncGroup.GET("", sync.SyncGamesNewOnlyChanges)
|
syncGroup.GET("", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
||||||
syncGroup.GET("/progress", sync.SyncProgress)
|
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
|
||||||
syncGroup.GET("/new", sync.SyncGamesNewOnlyChanges)
|
syncGroup.GET("/new", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
||||||
syncGroup.GET("/full", sync.SyncGamesNewFull)
|
syncGroup.GET("/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull))
|
||||||
syncGroup.GET("/new/full", sync.SyncGamesNewFull)
|
syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull))
|
||||||
syncGroup.GET("/quick", sync.SyncGamesNewOnlyChanges)
|
syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
||||||
syncGroup.GET("/reset", sync.ResetGames)
|
syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetDB))
|
||||||
|
|
||||||
music := NewMusicHandler()
|
music := NewMusicHandler()
|
||||||
musicGroup := e.Group("/music")
|
musicGroup := e.Group("/music")
|
||||||
musicGroup.GET("", music.GetSong)
|
musicGroup.GET("", deprecatedMiddleware(music.GetSong))
|
||||||
musicGroup.GET("/soundTest", music.GetSoundCheckSong)
|
musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong))
|
||||||
musicGroup.GET("/reset", music.ResetMusic)
|
musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic))
|
||||||
musicGroup.GET("/rand", music.GetRandomSong)
|
musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong))
|
||||||
musicGroup.GET("/rand/low", music.GetRandomSongLowChance)
|
musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance))
|
||||||
musicGroup.GET("/rand/classic", music.GetRandomSongClassic)
|
musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic))
|
||||||
musicGroup.GET("/info", music.GetSongInfo)
|
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
|
||||||
musicGroup.GET("/list", music.GetPlayedSongs)
|
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
|
||||||
musicGroup.GET("/next", music.GetNextSong)
|
musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong))
|
||||||
musicGroup.GET("/previous", music.GetPreviousSong)
|
musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong))
|
||||||
musicGroup.GET("/all", music.GetAllGamesRandom)
|
musicGroup.GET("/all", deprecatedMiddleware(music.GetAllSoundtracksRandom))
|
||||||
musicGroup.GET("/all/order", music.GetAllGames)
|
musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllSoundtracks))
|
||||||
musicGroup.GET("/all/random", music.GetAllGamesRandom)
|
musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllSoundtracksRandom))
|
||||||
musicGroup.PUT("/played", music.PutPlayed)
|
musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed))
|
||||||
musicGroup.GET("/addQue", music.AddLatestToQue)
|
musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue))
|
||||||
musicGroup.GET("/addPlayed", music.AddLatestPlayed)
|
musicGroup.GET("/addPlayed", deprecatedMiddleware(music.AddLatestPlayed))
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API v1 Routes with Token Authentication
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Create /api/v1 group
|
||||||
|
apiV1 := e.Group("/api/v1")
|
||||||
|
|
||||||
|
// Public endpoints - no token required
|
||||||
|
apiV1.POST("/token", func(c *echo.Context) error {
|
||||||
|
return s.tokenHandler.CreateTokenHandler(c)
|
||||||
|
})
|
||||||
|
apiV1.DELETE("/token", func(c *echo.Context) error {
|
||||||
|
return s.tokenHandler.DeleteTokenHandler(c)
|
||||||
|
})
|
||||||
|
apiV1.POST("/token/cleanup", func(c *echo.Context) error {
|
||||||
|
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Protected endpoints - require valid token
|
||||||
|
// Create token auth middleware with pool access
|
||||||
|
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
|
||||||
|
|
||||||
|
// Protected group with token authentication
|
||||||
|
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
|
||||||
|
|
||||||
|
// Statistics API endpoints (protected by token auth)
|
||||||
|
statistics := s.statisticsHandler
|
||||||
|
protectedV1.GET("/statistics/games/most-played", func(c *echo.Context) error {
|
||||||
|
return statistics.GetMostPlayedGames(c)
|
||||||
|
})
|
||||||
|
protectedV1.GET("/statistics/games/least-played", func(c *echo.Context) error {
|
||||||
|
return statistics.GetLeastPlayedGames(c)
|
||||||
|
})
|
||||||
|
protectedV1.GET("/statistics/games/never-played", func(c *echo.Context) error {
|
||||||
|
return statistics.GetNeverPlayedGames(c)
|
||||||
|
})
|
||||||
|
protectedV1.GET("/statistics/games/last-played", func(c *echo.Context) error {
|
||||||
|
return statistics.GetLastPlayedGames(c)
|
||||||
|
})
|
||||||
|
protectedV1.GET("/statistics/games/oldest-played", func(c *echo.Context) error {
|
||||||
|
return statistics.GetOldestPlayedGames(c)
|
||||||
|
})
|
||||||
|
protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error {
|
||||||
|
return statistics.GetMostPlayedSongs(c)
|
||||||
|
})
|
||||||
|
protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error {
|
||||||
|
return statistics.GetLeastPlayedSongs(c)
|
||||||
|
})
|
||||||
|
protectedV1.GET("/statistics/summary", func(c *echo.Context) error {
|
||||||
|
return statistics.GetStatisticsSummary(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: VGMQ endpoints will be added to protectedV1 group
|
||||||
|
_ = protectedV1 // Use the variable to avoid unused variable error
|
||||||
|
|
||||||
routes := e.Router().Routes()
|
routes := e.Router().Routes()
|
||||||
sort.Slice(routes, func(i, j int) bool {
|
sort.Slice(routes, func(i, j int) bool {
|
||||||
|
|||||||
+67
-23
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
"music-server/internal/db"
|
"music-server/internal/db"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,7 +15,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
port int
|
port int
|
||||||
|
db *db.Database
|
||||||
|
tokenHandler *TokenHandler
|
||||||
|
statisticsHandler *StatisticsHandler
|
||||||
|
httpServer *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -29,7 +34,9 @@ var (
|
|||||||
logJSON = os.Getenv("LOG_JSON") == "true"
|
logJSON = os.Getenv("LOG_JSON") == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewServer() *http.Server {
|
// NewServerInstance creates a new Server instance with all dependencies initialized.
|
||||||
|
// Use this for dependency injection and proper lifecycle management.
|
||||||
|
func NewServerInstance() *Server {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
if logLevel == "" {
|
if logLevel == "" {
|
||||||
logLevel = "info"
|
logLevel = "info"
|
||||||
@@ -39,8 +46,47 @@ func NewServer() *http.Server {
|
|||||||
logger := logging.GetLogger()
|
logger := logging.GetLogger()
|
||||||
|
|
||||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||||
NewServer := &Server{
|
|
||||||
port: port,
|
// Validate required environment variables
|
||||||
|
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
||||||
|
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database instance
|
||||||
|
database, err := db.NewDatabase(host, dbPort, username, password, dbName)
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Fatal("Failed to initialize database", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations using the new method
|
||||||
|
if err := database.RunMigrations(); err != nil {
|
||||||
|
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize backend package with database pool
|
||||||
|
backend.InitBackend(database.Pool)
|
||||||
|
|
||||||
|
// Initialize token handler with database pool
|
||||||
|
tokenHandler := NewTokenHandler(database.Pool)
|
||||||
|
|
||||||
|
// Initialize statistics handler
|
||||||
|
statisticsHandler := NewStatisticsHandler()
|
||||||
|
|
||||||
|
// Create the server instance
|
||||||
|
appServer := &Server{
|
||||||
|
port: port,
|
||||||
|
db: database,
|
||||||
|
tokenHandler: tokenHandler,
|
||||||
|
statisticsHandler: statisticsHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the HTTP server
|
||||||
|
appServer.httpServer = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", port),
|
||||||
|
Handler: appServer.RegisterRoutes(),
|
||||||
|
IdleTimeout: time.Minute,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Starting server",
|
logger.Info("Starting server",
|
||||||
@@ -55,23 +101,21 @@ func NewServer() *http.Server {
|
|||||||
zap.String("charactersPath", charactersPath),
|
zap.String("charactersPath", charactersPath),
|
||||||
)
|
)
|
||||||
|
|
||||||
//conf.SetupDb()
|
return appServer
|
||||||
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
}
|
||||||
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
|
||||||
}
|
// HTTPServer returns the underlying http.Server for serving HTTP requests.
|
||||||
|
func (s *Server) HTTPServer() *http.Server {
|
||||||
db.Migrate_db(host, dbPort, username, password, dbName)
|
return s.httpServer
|
||||||
|
}
|
||||||
db.InitDB(host, dbPort, username, password, dbName)
|
|
||||||
|
// DB returns the database instance for dependency injection.
|
||||||
// Declare Server config
|
func (s *Server) DB() *db.Database {
|
||||||
server := &http.Server{
|
return s.db
|
||||||
Addr: fmt.Sprintf(":%d", NewServer.port),
|
}
|
||||||
Handler: NewServer.RegisterRoutes(),
|
|
||||||
IdleTimeout: time.Minute,
|
// NewServer creates a new HTTP server (deprecated, use NewServerInstance instead).
|
||||||
ReadTimeout: 10 * time.Second,
|
// This function is kept for backward compatibility.
|
||||||
WriteTimeout: 30 * time.Second,
|
func NewServer() *http.Server {
|
||||||
}
|
return NewServerInstance().HTTPServer()
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatisticsHandler handles statistics-related HTTP requests
|
||||||
|
type StatisticsHandler struct {
|
||||||
|
statsBackend *backend.StatisticsHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatisticsHandler creates a new StatisticsHandler
|
||||||
|
func NewStatisticsHandler() *StatisticsHandler {
|
||||||
|
return &StatisticsHandler{
|
||||||
|
statsBackend: backend.NewStatisticsHandler(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMostPlayedGames returns top N most played games with songs
|
||||||
|
// GET /api/v1/statistics/games/most-played
|
||||||
|
//
|
||||||
|
// @Summary Get most played games
|
||||||
|
// @Description Returns the top N most played games with their songs
|
||||||
|
// @Tags statistics
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
|
// @Success 200 {array} backend.GameWithSongs
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/statistics/games/most-played [get]
|
||||||
|
func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error {
|
||||||
|
limit := 10 // default
|
||||||
|
limitStr := ctx.QueryParam("limit")
|
||||||
|
if limitStr != "" {
|
||||||
|
var err error
|
||||||
|
limit, err = strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||||
|
}
|
||||||
|
// Cap at 100 for performance
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
games, err := h.statsBackend.GetMostPlayedGamesWithSongs(int32(limit))
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get most played games", zap.String("error", err.Error()))
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, games)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLeastPlayedGames returns top N least played games with songs
|
||||||
|
// GET /api/v1/statistics/games/least-played
|
||||||
|
//
|
||||||
|
// @Summary Get least played games
|
||||||
|
// @Description Returns the top N least played games with their songs
|
||||||
|
// @Tags statistics
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
|
// @Success 200 {array} backend.GameWithSongs
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/statistics/games/least-played [get]
|
||||||
|
func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error {
|
||||||
|
limit := 10
|
||||||
|
limitStr := ctx.QueryParam("limit")
|
||||||
|
if limitStr != "" {
|
||||||
|
var err error
|
||||||
|
limit, err = strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
games, err := h.statsBackend.GetLeastPlayedGamesWithSongs(int32(limit))
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get least played games", zap.String("error", err.Error()))
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, games)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMostPlayedSongs returns top N most played songs with game info
|
||||||
|
// GET /api/v1/statistics/songs/most-played
|
||||||
|
//
|
||||||
|
// @Summary Get most played songs
|
||||||
|
// @Description Returns the top N most played songs with their game info
|
||||||
|
// @Tags statistics
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
|
// @Success 200 {array} backend.SongInfoForStats
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/statistics/songs/most-played [get]
|
||||||
|
func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
|
||||||
|
limit := 10
|
||||||
|
limitStr := ctx.QueryParam("limit")
|
||||||
|
if limitStr != "" {
|
||||||
|
var err error
|
||||||
|
limit, err = strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
songs, err := h.statsBackend.GetMostPlayedSongsWithGame(int32(limit))
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error()))
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, songs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLeastPlayedSongs returns top N least played songs with game info
|
||||||
|
// GET /api/v1/statistics/songs/least-played
|
||||||
|
//
|
||||||
|
// @Summary Get least played songs
|
||||||
|
// @Description Returns the top N least played songs with their game info
|
||||||
|
// @Tags statistics
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
|
// @Success 200 {array} backend.SongInfoForStats
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/statistics/songs/least-played [get]
|
||||||
|
func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
|
||||||
|
limit := 10
|
||||||
|
limitStr := ctx.QueryParam("limit")
|
||||||
|
if limitStr != "" {
|
||||||
|
var err error
|
||||||
|
limit, err = strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
songs, err := h.statsBackend.GetLeastPlayedSongsWithGame(int32(limit))
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, songs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNeverPlayedGames returns games that have never been played
|
||||||
|
// GET /api/v1/statistics/games/never-played
|
||||||
|
//
|
||||||
|
// @Summary Get never played games
|
||||||
|
// @Description Returns all games that have never been played (times_played = 0)
|
||||||
|
// @Tags statistics
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} backend.GameWithSongs
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/statistics/games/never-played [get]
|
||||||
|
func (h *StatisticsHandler) GetNeverPlayedGames(ctx *echo.Context) error {
|
||||||
|
games, err := h.statsBackend.GetNeverPlayedGames()
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get never played games", zap.String("error", err.Error()))
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, games)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastPlayedGames returns most recently played games
|
||||||
|
// GET /api/v1/statistics/games/last-played
|
||||||
|
//
|
||||||
|
// @Summary Get last played games
|
||||||
|
// @Description Returns the most recently played games
|
||||||
|
// @Tags statistics
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
|
// @Success 200 {array} backend.GameWithSongs
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/statistics/games/last-played [get]
|
||||||
|
func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error {
|
||||||
|
limit := 10
|
||||||
|
limitStr := ctx.QueryParam("limit")
|
||||||
|
if limitStr != "" {
|
||||||
|
var err error
|
||||||
|
limit, err = strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
games, err := h.statsBackend.GetLastPlayedGames(int32(limit))
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get last played games", zap.String("error", err.Error()))
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, games)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOldestPlayedGames returns least recently played games
|
||||||
|
// GET /api/v1/statistics/games/oldest-played
|
||||||
|
//
|
||||||
|
// @Summary Get oldest played games
|
||||||
|
// @Description Returns the least recently played games (that have been played at least once)
|
||||||
|
// @Tags statistics
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of results (default: 10)"
|
||||||
|
// @Success 200 {array} backend.GameWithSongs
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/statistics/games/oldest-played [get]
|
||||||
|
func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error {
|
||||||
|
limit := 10
|
||||||
|
limitStr := ctx.QueryParam("limit")
|
||||||
|
if limitStr != "" {
|
||||||
|
var err error
|
||||||
|
limit, err = strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
games, err := h.statsBackend.GetOldestPlayedGames(int32(limit))
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get oldest played games", zap.String("error", err.Error()))
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, games)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatisticsSummary returns overall statistics
|
||||||
|
// GET /api/v1/statistics/summary
|
||||||
|
//
|
||||||
|
// @Summary Get statistics summary
|
||||||
|
// @Description Returns overall statistics about the music library
|
||||||
|
// @Tags statistics
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} backend.StatisticsSummary
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/statistics/summary [get]
|
||||||
|
func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error {
|
||||||
|
summary, err := h.statsBackend.GetStatisticsSummary()
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get statistics summary", zap.String("error", err.Error()))
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, summary)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/db"
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestStatisticsEndpoints tests the statistics API endpoints
|
||||||
|
func TestStatisticsEndpoints(t *testing.T) {
|
||||||
|
// Skip if test database not configured
|
||||||
|
e := StartTestServer(t)
|
||||||
|
if e == nil {
|
||||||
|
t.Skip("Test database not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get token first
|
||||||
|
token := getTestToken(t, e)
|
||||||
|
if token == "" {
|
||||||
|
t.Skip("Could not get test token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test /api/v1/statistics/summary
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var summary backend.StatisticsSummary
|
||||||
|
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPartialMigrationThenSyncThenComplete tests migration workflow
|
||||||
|
// Note: This test requires the database to be in a specific state
|
||||||
|
// It tests: partial migration → data insert → sync → complete migration
|
||||||
|
func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
|
||||||
|
// This test is complex and requires careful setup
|
||||||
|
// For now, we test the final state: all migrations + sync
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
if e == nil {
|
||||||
|
t.Skip("Test database not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get token
|
||||||
|
token := getTestToken(t, e)
|
||||||
|
if token == "" {
|
||||||
|
t.Skip("Could not get test token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert test data manually (5 soundtracks with songs)
|
||||||
|
insertTestData(t)
|
||||||
|
|
||||||
|
// Run sync to ensure data is properly loaded
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sync/new", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
// Wait for sync to complete
|
||||||
|
if !waitForSyncCompletion(t, e, 60) {
|
||||||
|
t.Error("Sync did not complete within timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify data via statistics endpoint
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var summary backend.StatisticsSummary
|
||||||
|
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// After sync with /sync/new, only soundtracks matching filesystem remain
|
||||||
|
// testMusic has 3 games
|
||||||
|
require.Equal(t, int64(3), summary.TotalGames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertTestData inserts 5 test soundtracks with songs into the database
|
||||||
|
func insertTestData(t *testing.T) {
|
||||||
|
if db.TestDatabase == nil || db.TestDatabase.Pool == nil {
|
||||||
|
t.Skip("Test database not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
queries := repository.New(db.TestDatabase.Pool)
|
||||||
|
|
||||||
|
// Insert 5 soundtracks
|
||||||
|
soundtracks := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"Test Soundtrack 1", "/path/to/soundtrack1"},
|
||||||
|
{"Test Soundtrack 2", "/path/to/soundtrack2"},
|
||||||
|
{"Test Soundtrack 3", "/path/to/soundtrack3"},
|
||||||
|
{"Test Soundtrack 4", "/path/to/soundtrack4"},
|
||||||
|
{"Test Soundtrack 5", "/path/to/soundtrack5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, st := range soundtracks {
|
||||||
|
_, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{
|
||||||
|
SoundtrackName: st.name,
|
||||||
|
Path: st.path,
|
||||||
|
Hash: "test-hash-" + st.name,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get soundtrack IDs
|
||||||
|
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
|
||||||
|
|
||||||
|
// Insert songs for each soundtrack
|
||||||
|
songData := []struct {
|
||||||
|
soundtrackID int32
|
||||||
|
songs []string
|
||||||
|
}{
|
||||||
|
{soundtrackIDs[0].ID, []string{"Song A", "Song B"}},
|
||||||
|
{soundtrackIDs[1].ID, []string{"Song C", "Song D"}},
|
||||||
|
{soundtrackIDs[2].ID, []string{"Song E"}},
|
||||||
|
{soundtrackIDs[3].ID, []string{"Song F", "Song G", "Song H"}},
|
||||||
|
{soundtrackIDs[4].ID, []string{"Song I"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sd := range songData {
|
||||||
|
for _, songName := range sd.songs {
|
||||||
|
err := queries.AddSong(ctx, repository.AddSongParams{
|
||||||
|
SoundtrackID: sd.soundtrackID,
|
||||||
|
SongName: songName,
|
||||||
|
Path: "/path/to/" + songName + ".mp3",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to insert song: %s", songName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Inserted %d soundtracks with %d total songs", len(soundtracks), 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTestToken gets a valid token for testing
|
||||||
|
func getTestToken(t *testing.T, e *echo.Echo) string {
|
||||||
|
reqBody := `{"client_type": "test"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", strings.NewReader(reqBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Logf("Failed to get token: %s", rec.Body.String())
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
err := json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp.Token
|
||||||
|
}
|
||||||
@@ -34,59 +34,61 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
|||||||
return ctx.JSON(http.StatusOK, response)
|
return ctx.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncGamesNewOnlyChanges godoc
|
// SyncSoundtracksNewOnlyChanges godoc
|
||||||
// @Summary Sync games with only changes
|
// @Summary Sync soundtracks with only changes
|
||||||
// @Description Starts syncing games with only new changes
|
// @Description Starts syncing games with only new changes
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {string} string "Start syncing games"
|
// @Success 200 {string} string "Start syncing soundtracks"
|
||||||
// @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) SyncGamesNewOnlyChanges(ctx *echo.Context) error {
|
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
logging.GetLogger().Warn("Syncing is already in progress")
|
logging.GetLogger().Warn("Syncing is already in progress")
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||||
}
|
}
|
||||||
logging.GetLogger().Info("Starting sync with only changes")
|
logging.GetLogger().Info("Starting sync with only changes")
|
||||||
go backend.SyncGamesNewOnlyChanges()
|
backend.Syncing = true
|
||||||
return ctx.JSON(http.StatusOK, "Start syncing games")
|
go backend.SyncSoundtracksNewOnlyChanges()
|
||||||
|
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncGamesNewFull godoc
|
// SyncSoundtracksNewFull godoc
|
||||||
// @Summary Sync all games fully
|
// @Summary Sync all games fully
|
||||||
// @Description Starts a full sync of all games
|
// @Description Starts a full sync of all games
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {string} string "Start syncing games full"
|
// @Success 200 {string} string "Start syncing soundtracks full"
|
||||||
// @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) SyncGamesNewFull(ctx *echo.Context) error {
|
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
logging.GetLogger().Warn("Syncing is already in progress")
|
logging.GetLogger().Warn("Syncing is already in progress")
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||||
}
|
}
|
||||||
logging.GetLogger().Info("Starting full sync")
|
logging.GetLogger().Info("Starting full sync")
|
||||||
go backend.SyncGamesNewFull()
|
backend.Syncing = true
|
||||||
return ctx.JSON(http.StatusOK, "Start syncing games full")
|
go backend.SyncSoundtracksNewFull()
|
||||||
|
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetGames godoc
|
// ResetDB godoc
|
||||||
// @Summary Reset games database
|
// @Summary Reset soundtracks database
|
||||||
// @Description Resets the games database by deleting all games and songs
|
// @Description Resets the games database by deleting all games and songs
|
||||||
// @Tags sync
|
// @Tags sync
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {string} string "Games and songs are deleted from the database"
|
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
|
||||||
// @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) ResetGames(ctx *echo.Context) error {
|
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
logging.GetLogger().Warn("Cannot reset - syncing is in progress")
|
logging.GetLogger().Warn("Cannot reset - syncing is in progress")
|
||||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||||
}
|
}
|
||||||
logging.GetLogger().Info("Resetting games database")
|
logging.GetLogger().Info("Resetting soundtracks database")
|
||||||
backend.ResetDB()
|
backend.ResetDB()
|
||||||
return ctx.JSON(http.StatusOK, "Games and songs are deleted from the database")
|
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
|||||||
db.TestClearDatabase(t)
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
// Before sync - should have no games
|
// Before sync - should have no games
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, err := repo.FindAllGames(db.Ctx)
|
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(gamesBefore)
|
||||||
t.Logf("Games before sync: %d", beforeCount)
|
t.Logf("Games before sync: %d", beforeCount)
|
||||||
@@ -92,7 +92,7 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After sync - should have games
|
// After sync - should have games
|
||||||
gamesAfter, err := repo.FindAllGames(db.Ctx)
|
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(gamesAfter)
|
||||||
t.Logf("Games after sync: %d", afterCount)
|
t.Logf("Games after sync: %d", afterCount)
|
||||||
@@ -112,8 +112,8 @@ func TestSyncMakesDifference(t *testing.T) {
|
|||||||
db.TestClearDatabase(t)
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
// Before sync - should have no games
|
// Before sync - should have no games
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, err := repo.FindAllGames(db.Ctx)
|
gamesBefore, 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(gamesBefore), "Should have no games before sync")
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ func TestSyncMakesDifference(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After sync - should have games
|
// After sync - should have games
|
||||||
gamesAfter, err := repo.FindAllGames(db.Ctx)
|
gamesAfter, 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(gamesAfter) > 0, "Should have games after sync")
|
||||||
}
|
}
|
||||||
@@ -199,8 +199,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get initial count
|
// Get initial count
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, _ := repo.FindAllGames(db.Ctx)
|
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(gamesBefore)
|
||||||
|
|
||||||
// Run incremental sync (should not change count if nothing changed)
|
// Run incremental sync (should not change count if nothing changed)
|
||||||
@@ -211,7 +211,7 @@ 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.FindAllGames(db.Ctx)
|
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(gamesAfter)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -227,8 +227,8 @@ func TestResetGames(t *testing.T) {
|
|||||||
e := StartTestServer(t)
|
e := StartTestServer(t)
|
||||||
|
|
||||||
// First ensure we have data
|
// First ensure we have data
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, _ := repo.FindAllGames(db.Ctx)
|
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(gamesBefore)
|
||||||
|
|
||||||
if beforeCount == 0 {
|
if beforeCount == 0 {
|
||||||
@@ -238,7 +238,7 @@ 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.FindAllGames(db.Ctx)
|
gamesBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
beforeCount = len(gamesBefore)
|
beforeCount = len(gamesBefore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ 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.FindAllGames(db.Ctx)
|
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(gamesAfter)
|
||||||
|
|
||||||
t.Logf("Games after reset: %d", afterCount)
|
t.Logf("Games after reset: %d", afterCount)
|
||||||
@@ -281,8 +281,8 @@ func TestSyncGamesNewFull(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify database is populated
|
// Verify database is populated
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
games, err := repo.FindAllGames(db.Ctx)
|
games, 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(games) > 0, "Database should be populated after full sync")
|
||||||
t.Logf("Full sync populated %d games", len(games))
|
t.Logf("Full sync populated %d games", len(games))
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/db"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,8 +48,21 @@ func StartTestServer(t *testing.T) *echo.Echo {
|
|||||||
os.Setenv("LOG_JSON", "false")
|
os.Setenv("LOG_JSON", "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize database for tests
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
|
||||||
|
// Initialize backend with test database pool
|
||||||
|
// This ensures BackendRepo() and BackendCtx() are available
|
||||||
|
if db.TestDatabase != nil && db.TestDatabase.Pool != nil {
|
||||||
|
backend.InitBackend(db.TestDatabase.Pool)
|
||||||
|
}
|
||||||
|
|
||||||
// Create a Server instance and get its routes
|
// Create a Server instance and get its routes
|
||||||
s := &Server{}
|
s := &Server{
|
||||||
|
db: db.TestDatabase,
|
||||||
|
tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
|
||||||
|
statisticsHandler: NewStatisticsHandler(),
|
||||||
|
}
|
||||||
handler := s.RegisterRoutes()
|
handler := s.RegisterRoutes()
|
||||||
|
|
||||||
// Wrap the http.Handler in an echo.Echo
|
// Wrap the http.Handler in an echo.Echo
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenRequest represents a request to generate a new token
|
||||||
|
type TokenRequest struct {
|
||||||
|
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenResponse represents the response with a new token
|
||||||
|
type TokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
ClientType string `json:"client_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenHandler contains the database pool for token operations
|
||||||
|
type TokenHandler struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenHandler creates a new token handler with database pool
|
||||||
|
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
|
||||||
|
return &TokenHandler{
|
||||||
|
pool: pool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken creates a new cryptographically secure token
|
||||||
|
func (h *TokenHandler) generateToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenHandler creates a new session token
|
||||||
|
// POST /api/v1/token
|
||||||
|
//
|
||||||
|
// @Summary Create session token
|
||||||
|
// @Description Returns a new session token for API access
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body TokenRequest true "Client type"
|
||||||
|
// @Success 200 {object} TokenResponse
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/token [post]
|
||||||
|
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||||
|
var req TokenRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ClientType == "" {
|
||||||
|
req.ClientType = "web"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token, err := h.generateToken()
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error()))
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set expiration (24 hours from now)
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
clientType := req.ClientType
|
||||||
|
|
||||||
|
// Store in database using sqlc-generated repository
|
||||||
|
queries := repository.New(h.pool)
|
||||||
|
session, err := queries.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||||
|
Token: token,
|
||||||
|
IpAddress: c.RealIP(),
|
||||||
|
UserAgent: c.Request().UserAgent(),
|
||||||
|
ClientType: &clientType,
|
||||||
|
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error()))
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
|
||||||
|
}
|
||||||
|
|
||||||
|
response := TokenResponse{
|
||||||
|
Token: session.Token,
|
||||||
|
ExpiresAt: session.ExpiresAt.Time,
|
||||||
|
ClientType: *session.ClientType,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTokenHandler invalidates a session token
|
||||||
|
// DELETE /api/v1/token
|
||||||
|
//
|
||||||
|
// @Summary Invalidate session token
|
||||||
|
// @Description Deletes the current session token
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param Authorization header string true "Bearer token"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/token [delete]
|
||||||
|
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
token := parts[1]
|
||||||
|
|
||||||
|
// Delete session using sqlc-generated repository
|
||||||
|
queries := repository.New(h.pool)
|
||||||
|
err := queries.DeleteSession(c.Request().Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to delete session", zap.String("error", err.Error()))
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupExpiredSessionsHandler removes all expired sessions
|
||||||
|
// POST /api/v1/token/cleanup
|
||||||
|
//
|
||||||
|
// @Summary Cleanup expired sessions
|
||||||
|
// @Description Removes all expired session tokens from the database
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param Authorization header string true "Bearer token"
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/token/cleanup [post]
|
||||||
|
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
|
||||||
|
// Verify token is valid first (using existing middleware)
|
||||||
|
// The middleware will have already validated the token
|
||||||
|
|
||||||
|
queries := repository.New(h.pool)
|
||||||
|
err := queries.DeleteExpiredSessions(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to cleanup sessions", zap.String("error", err.Error()))
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get count of deleted rows (DeleteExpiredSessions doesn't return count in the generated code)
|
||||||
|
// So we just return success
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "cleanup complete",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"music-server/internal/backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VersionHandler struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVersionHandler() *VersionHandler {
|
||||||
|
return &VersionHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersionHistory godoc
|
||||||
|
//
|
||||||
|
// @Summary Getting the version history of the backend
|
||||||
|
// @Description get version history
|
||||||
|
// @Tags version
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} backend.VersionData
|
||||||
|
// @Failure 404 {object} string
|
||||||
|
// @Router /version/history [get]
|
||||||
|
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
||||||
|
versionHistory := backend.GetVersionHistory()
|
||||||
|
if len(versionHistory) == 0 {
|
||||||
|
return ctx.JSON(http.StatusNotFound, "version not found")
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, versionHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestVersion godoc
|
||||||
|
//
|
||||||
|
// @Summary Getting the latest version of the backend
|
||||||
|
// @Description get latest version info
|
||||||
|
// @Tags version
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} backend.VersionData
|
||||||
|
// @Failure 404 {object} string
|
||||||
|
// @Router /version [get]
|
||||||
|
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
|
||||||
|
latestVersion := backend.GetLatestVersion()
|
||||||
|
if latestVersion.Version == "" {
|
||||||
|
return ctx.JSON(http.StatusNotFound, "version not found")
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, latestVersion)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetLatestVersion verifies the version endpoint returns latest version
|
||||||
|
func TestGetLatestVersion(t *testing.T) {
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/version")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var versionData backend.VersionData
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, versionData.Version)
|
||||||
|
assert.NotEmpty(t, versionData.Changelog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetVersionHistory verifies the version history endpoint returns version history
|
||||||
|
func TestGetVersionHistory(t *testing.T) {
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/version/history")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var versionHistory []backend.VersionData
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &versionHistory)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, versionHistory)
|
||||||
|
assert.NotEmpty(t, versionHistory[0].Version)
|
||||||
|
assert.NotEmpty(t, versionHistory[0].Changelog)
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ 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(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
games, err := repo.FindAllGames(db.Ctx)
|
games, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
if len(games) == 0 {
|
if len(games) == 0 {
|
||||||
|
|||||||
@@ -80,9 +80,17 @@ run:
|
|||||||
@templ generate
|
@templ generate
|
||||||
@go run cmd/main.go
|
@go run cmd/main.go
|
||||||
|
|
||||||
|
build-run: build
|
||||||
|
@go run cmd/main.go
|
||||||
|
|
||||||
test: build
|
test: build
|
||||||
@echo "Testing..."
|
@echo "Starting test database container..."
|
||||||
@go test ./... -v
|
@podman-compose -f compose.test.yaml up -d
|
||||||
|
@sleep 10
|
||||||
|
@echo "Running integration tests..."
|
||||||
|
@just test-integration
|
||||||
|
@echo "Stopping test database container..."
|
||||||
|
@just test-integration-down
|
||||||
|
|
||||||
# Clean the binary
|
# Clean the binary
|
||||||
clean:
|
clean:
|
||||||
@@ -102,7 +110,9 @@ podman-down:
|
|||||||
# Run integration tests with podman
|
# Run integration tests with podman
|
||||||
# Starts a test PostgreSQL container, runs tests, then cleans up
|
# Starts a test PostgreSQL container, runs tests, then cleans up
|
||||||
test-integration:
|
test-integration:
|
||||||
@echo "Starting test database container..."
|
@echo "Cleaning old test database..."
|
||||||
|
@podman-compose -f compose.test.yaml down -v
|
||||||
|
@echo "Starting fresh test database container..."
|
||||||
@podman-compose -f compose.test.yaml up -d
|
@podman-compose -f compose.test.yaml up -d
|
||||||
@sleep 10
|
@sleep 10
|
||||||
@echo "Running integration tests..."
|
@echo "Running integration tests..."
|
||||||
|
|||||||
Reference in New Issue
Block a user