Compare commits
37 Commits
4.5.0
..
6d4a034753
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d4a034753 | |||
| 24a9111333 | |||
| 6cc014ffa3 | |||
| 8f8b555ea5 | |||
| a446dad7b6 | |||
| d152ec1f11 | |||
| 7a3934babf | |||
| 08f539abd9 | |||
| 87a1a2d89a | |||
| 1ada52f5f8 | |||
| 92b82da3af | |||
| b71072f6c8 | |||
| d481be04a7 | |||
| 870f1787cb | |||
| 89c31c2856 | |||
| f0653489d6 | |||
| d0fbba86f1 | |||
| bd0e7f4a8d | |||
| b5926e3b31 | |||
| 37909139de | |||
| 82252ce1ff | |||
| 1dab9d6e7c | |||
| b80ad90eab | |||
| 2cff8d16d7 | |||
| 12f18ba12c | |||
| 6e2c381d90 | |||
| efca22834b | |||
| e57609725e | |||
| fabd6a6931 | |||
| f03e001bdd | |||
| 1d77ae491c | |||
| c0d1aaa4d1 | |||
| 76aaa884fa | |||
| 290d79ef5e | |||
| aa0b8275e7 | |||
| c369b13fae | |||
| bef915ac6d |
@@ -0,0 +1,15 @@
|
|||||||
|
# Test Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5433
|
||||||
|
DB_USERNAME=testuser
|
||||||
|
DB_PASSWORD=testpass
|
||||||
|
DB_NAME=music_server_test
|
||||||
|
|
||||||
|
# Test Paths
|
||||||
|
MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic
|
||||||
|
CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=8081
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
LOG_JSON=false
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
|
|
||||||
name: Build
|
name: Build
|
||||||
run-name: ${{ gitea.actor }} is runs ci pipeline
|
|
||||||
#on:
|
#on:
|
||||||
# release:
|
# release:
|
||||||
# types: [published]
|
# types: [published]
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# test:
|
# test:
|
||||||
@@ -22,13 +24,13 @@ jobs:
|
|||||||
uses: https://github.com/docker/setup-buildx-action@v3
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
config-inline: |
|
config-inline: |
|
||||||
[registry."gitea.sanplex.tech/sansan"]
|
[registry."gitea.sanplex.xyz/sansan"]
|
||||||
http = true
|
http = true
|
||||||
insecure = true
|
insecure = true
|
||||||
- name: Login to Gitea
|
- name: Login to Gitea
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: gitea.sanplex.tech
|
registry: gitea.sanplex.xyz
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.TOKEN }}
|
password: ${{ secrets.TOKEN }}
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -37,4 +39,4 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: false
|
push: false
|
||||||
#tags: "gitea.sanplex.tech/sansan/musicserver:${{gitea.ref_name}}, gitea.sanplex.tech/sansan/musicserver:latest"
|
#tags: "gitea.sanplex.xyz/sansan/musicserver:${{gitea.ref_name}}, gitea.sanplex.xyz/sansan/musicserver:latest"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
name: Publish
|
name: Publish
|
||||||
run-name: ${{ gitea.actor }} is runs ci pipeline
|
|
||||||
#on:
|
#on:
|
||||||
# release:
|
# release:
|
||||||
# types: [published]
|
# types: [published]
|
||||||
@@ -17,25 +17,25 @@ jobs:
|
|||||||
|
|
||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: https://github.com/docker/setup-buildx-action@v3
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
config-inline: |
|
config-inline: |
|
||||||
[registry."gitea.sanplex.tech/sansan"]
|
[registry."gitea.sanplex.xyz/sansan"]
|
||||||
http = true
|
http = true
|
||||||
insecure = true
|
insecure = true
|
||||||
- name: Login to Gitea
|
- name: Login to Gitea
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: gitea.sanplex.tech
|
registry: gitea.sanplex.xyz
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.TOKEN }}
|
password: ${{ secrets.TOKEN }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: https://github.com/docker/build-push-action@v5
|
uses: https://github.com/docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: "gitea.sanplex.tech/sansan/musicserver:${{gitea.ref_name}}, gitea.sanplex.tech/sansan/musicserver:latest"
|
tags: "gitea.sanplex.xyz/sansan/musicserver:${{gitea.ref_name}}, gitea.sanplex.xyz/sansan/musicserver:latest"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Integration Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # Manual trigger only
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
integration-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.25
|
||||||
|
options: --privileged
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: testuser
|
||||||
|
POSTGRES_PASSWORD: testpass
|
||||||
|
POSTGRES_DB: music_server_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Install testcontainers
|
||||||
|
run: go install github.com/testcontainers/testcontainers-go@latest
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
env:
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USERNAME: testuser
|
||||||
|
DB_PASSWORD: testpass
|
||||||
|
DB_NAME: music_server_test
|
||||||
|
MUSIC_PATH: ./testMusic
|
||||||
|
CHARACTERS_PATH: ./testCharacters
|
||||||
|
run: go test -v -timeout 30m ./...
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
FROM golang:1.23-alpine as build_go
|
FROM golang:1.25-alpine as build_go
|
||||||
RUN apk add --no-cache curl npm
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -9,18 +9,17 @@ 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 npm install tailwindcss @tailwindcss/cli
|
|
||||||
|
|
||||||
RUN templ generate
|
RUN templ generate
|
||||||
RUN npx @tailwindcss/cli -i ./cmd/web/assets/css/input.css -o ./cmd/web/assets/css/output.css
|
|
||||||
|
|
||||||
RUN go build -o main cmd/main.go
|
RUN go build -o main cmd/main.go
|
||||||
|
|
||||||
# Stage 2, distribution container
|
# Stage 2, distribution container
|
||||||
FROM golang:1.23-alpine
|
FROM golang:1.25-alpine
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME /sorted
|
VOLUME /sorted
|
||||||
VOLUME /frontend
|
VOLUME /frontend
|
||||||
|
VOLUME /characters
|
||||||
|
|
||||||
ENV PORT 8080
|
ENV PORT 8080
|
||||||
ENV DB_HOST ""
|
ENV DB_HOST ""
|
||||||
@@ -29,6 +28,7 @@ ENV DB_USERNAME ""
|
|||||||
ENV DB_PASSWORD ""
|
ENV DB_PASSWORD ""
|
||||||
ENV DB_NAME ""
|
ENV DB_NAME ""
|
||||||
ENV MUSIC_PATH ""
|
ENV MUSIC_PATH ""
|
||||||
|
ENV CHARACTERS_PATH ""
|
||||||
|
|
||||||
COPY --from=build_go /app/main .
|
COPY --from=build_go /app/main .
|
||||||
COPY ./songs/ ./songs/
|
COPY ./songs/ ./songs/
|
||||||
|
|||||||
@@ -23,6 +23,779 @@ var doc = `{
|
|||||||
"host": "{{.Host}}",
|
"host": "{{.Host}}",
|
||||||
"basePath": "{{.BasePath}}",
|
"basePath": "{{.BasePath}}",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/character": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the image for a specific character",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"image/png"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"characters"
|
||||||
|
],
|
||||||
|
"summary": "Get character image",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Character name",
|
||||||
|
"name": "name",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/characters": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of all available characters",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"characters"
|
||||||
|
],
|
||||||
|
"summary": "Get list of characters",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/dbtest": {
|
||||||
|
"get": {
|
||||||
|
"description": "Tests the database connection",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"database"
|
||||||
|
],
|
||||||
|
"summary": "Test database connection",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "TestedDB",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/download": {
|
||||||
|
"get": {
|
||||||
|
"description": "Checks for the latest version of the application",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"summary": "Check for latest version",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/download/linux": {
|
||||||
|
"get": {
|
||||||
|
"description": "Redirects to download the latest Linux version",
|
||||||
|
"produces": [
|
||||||
|
"application/octet-stream"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"summary": "Download latest Linux version",
|
||||||
|
"responses": {
|
||||||
|
"302": {
|
||||||
|
"description": "Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/download/list": {
|
||||||
|
"get": {
|
||||||
|
"description": "Lists all assets available for the latest version",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"summary": "List assets of latest version",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/download/windows": {
|
||||||
|
"get": {
|
||||||
|
"description": "Redirects to download the latest Windows version",
|
||||||
|
"produces": [
|
||||||
|
"application/octet-stream"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"summary": "Download latest Windows version",
|
||||||
|
"responses": {
|
||||||
|
"302": {
|
||||||
|
"description": "Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the health status of the server",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"health"
|
||||||
|
],
|
||||||
|
"summary": "Check server health",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a specific song by name",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get a specific song",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Song name",
|
||||||
|
"name": "song",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "song can't be empty",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/addPlayed": {
|
||||||
|
"get": {
|
||||||
|
"description": "Adds the latest song to the played list",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Add latest to played",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/addQue": {
|
||||||
|
"get": {
|
||||||
|
"description": "Adds the latest song to the queue",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Add latest to queue",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/all/order": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of all games in order",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get all games",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/all/random": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of all games in random order",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get all games random",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/info": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns information about the current song",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get current song info",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/list": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of played songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get played songs list",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/next": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the next song in the queue",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get next song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/played": {
|
||||||
|
"put": {
|
||||||
|
"description": "Marks a song as played by its ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Mark song as played",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Song ID",
|
||||||
|
"name": "song",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/previous": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the previous song in the queue",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get previous song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/rand": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a random song",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get random song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/rand/classic": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a random song from the classic selection",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get random classic song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/rand/low": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a random song with low chance selection",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get random song with low chance",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/reset": {
|
||||||
|
"get": {
|
||||||
|
"description": "Resets the music state",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Reset music state",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/soundTest": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the sound check song",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get sound check song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sync": {
|
||||||
|
"get": {
|
||||||
|
"description": "Starts syncing games with only new changes",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
],
|
||||||
|
"summary": "Sync games with only changes",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Start syncing games",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sync/full": {
|
||||||
|
"get": {
|
||||||
|
"description": "Starts a full sync of all games",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
],
|
||||||
|
"summary": "Sync all games fully",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Start syncing games full",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sync/progress": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the current sync progress or result",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
],
|
||||||
|
"summary": "Get sync progress",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sync/reset": {
|
||||||
|
"get": {
|
||||||
|
"description": "Resets the games database by deleting all games and songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
],
|
||||||
|
"summary": "Reset games database",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Games and songs are deleted from the database",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/version": {
|
"/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get string by ID",
|
"description": "get string by ID",
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
def load_openapi_spec(path: str) -> dict:
|
||||||
|
"""Load OpenAPI spec from YAML or JSON file."""
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
with open(path, "r") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
except ImportError:
|
||||||
|
# Fallback to JSON if PyYAML is not installed
|
||||||
|
with open(path, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def map_type(openapi_type: str) -> str:
|
||||||
|
"""Map OpenAPI types to GDScript types."""
|
||||||
|
type_mapping = {
|
||||||
|
"integer": "int",
|
||||||
|
"number": "float",
|
||||||
|
"boolean": "bool",
|
||||||
|
"array": "Array",
|
||||||
|
"object": "Dictionary",
|
||||||
|
}
|
||||||
|
return type_mapping.get(openapi_type, "String")
|
||||||
|
|
||||||
|
def default_value(openapi_type: str):
|
||||||
|
"""Return default values for GDScript types."""
|
||||||
|
default_mapping = {
|
||||||
|
"integer": "0",
|
||||||
|
"number": "0.0",
|
||||||
|
"boolean": "false",
|
||||||
|
"array": "[]",
|
||||||
|
"object": "{}",
|
||||||
|
}
|
||||||
|
return default_mapping.get(openapi_type, '""')
|
||||||
|
|
||||||
|
def sanitize_class_name(name: str) -> str:
|
||||||
|
"""Convert a name to a valid GDScript class name."""
|
||||||
|
# Replace invalid characters with underscores
|
||||||
|
name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
|
||||||
|
# Capitalize first letter
|
||||||
|
return name[0].upper() + name[1:] if name else "Model"
|
||||||
|
|
||||||
|
def generate_model_class(class_name: str, schema: dict) -> str:
|
||||||
|
"""Generate a GDScript class for a model."""
|
||||||
|
lines = [
|
||||||
|
f"class_name {class_name}",
|
||||||
|
"extends RefCounted",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add properties
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
for prop_name, prop_schema in properties.items():
|
||||||
|
prop_type = map_type(prop_schema.get("type", "string"))
|
||||||
|
lines.append(f"var {prop_name}: {prop_type}")
|
||||||
|
|
||||||
|
# Add _init method
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"func _init(data: Dictionary):",
|
||||||
|
])
|
||||||
|
for prop_name in properties:
|
||||||
|
prop_type = map_type(properties[prop_name].get("type", "string"))
|
||||||
|
default = default_value(properties[prop_name].get("type", "string"))
|
||||||
|
lines.append(f' {prop_name} = data.get("{prop_name}", {default})')
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def generate_api_client(path: str, method: str, endpoint: dict) -> str:
|
||||||
|
"""Generate a GDScript API client for an endpoint."""
|
||||||
|
# Sanitize path for class name
|
||||||
|
class_name = sanitize_class_name(path.replace("/", "_").replace("{", "").replace("}", "")) + method.capitalize()
|
||||||
|
# Format URL (replace {param} with %s for Godot's string formatting)
|
||||||
|
url = path.replace("{", "%").replace("}", "s")
|
||||||
|
full_url = f'"{BASE_URL}{url}"'
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"class_name {class_name}",
|
||||||
|
"extends RefCounted",
|
||||||
|
"",
|
||||||
|
"var http_request: HTTPRequest",
|
||||||
|
"",
|
||||||
|
"func _init(node: Node):",
|
||||||
|
" http_request = HTTPRequest.new()",
|
||||||
|
" node.add_child(http_request)",
|
||||||
|
' http_request.connect("request_completed", self, "_on_request_completed")',
|
||||||
|
"",
|
||||||
|
f"func call(params: Dictionary, callback: Callable):",
|
||||||
|
f" var url := {full_url}",
|
||||||
|
' var headers = ["User-Agent: MyGodotApp"]',
|
||||||
|
" var error := http_request.request(url, headers)",
|
||||||
|
" if error != OK:",
|
||||||
|
' push_error("HTTP request failed.")',
|
||||||
|
" return",
|
||||||
|
" http_request.set_meta(\"callback\", callback)",
|
||||||
|
"",
|
||||||
|
"func _on_request_completed(result: int, response_code: int, headers: PoolStringArray, body: PoolByteArray):",
|
||||||
|
" var callback := http_request.get_meta(\"callback\")",
|
||||||
|
" if callback:",
|
||||||
|
" var response_body = body.get_string_from_utf8()",
|
||||||
|
" var json = JSON.new()",
|
||||||
|
" if json.parse(response_body) == OK:",
|
||||||
|
" callback.call(json.get_data())",
|
||||||
|
" else:",
|
||||||
|
" callback.call(null)",
|
||||||
|
]
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def generate_tag_client(tag: str, endpoints: list) -> str:
|
||||||
|
"""Generate a GDScript API client for all endpoints with a given tag."""
|
||||||
|
class_name = sanitize_class_name(tag) + "API"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"class_name {class_name}",
|
||||||
|
"extends RefCounted",
|
||||||
|
"",
|
||||||
|
"var http_request: HTTPRequest",
|
||||||
|
"var base_url: String",
|
||||||
|
"",
|
||||||
|
"func _init(node: Node, base_url_param: String):",
|
||||||
|
" http_request = HTTPRequest.new()",
|
||||||
|
" node.add_child(http_request)",
|
||||||
|
' http_request.connect("request_completed", self, "_on_request_completed")',
|
||||||
|
" base_url = base_url_param",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Generate a method for each endpoint
|
||||||
|
for path, method, endpoint in endpoints:
|
||||||
|
# Sanitize method name for GDScript
|
||||||
|
method_name = method.lower()
|
||||||
|
# Create a valid function name from the path
|
||||||
|
func_name = "call_" + path.replace("/", "_").replace("{", "").replace("}", "").replace("-", "_")
|
||||||
|
|
||||||
|
# Format URL (replace {param} with %s for Godot's string formatting)
|
||||||
|
url = path.replace("{", "%").replace("}", "s")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
f"func {func_name}(params: Dictionary = {{}}, callback: Callable):",
|
||||||
|
f' var url := base_url + "{url}"',
|
||||||
|
' var headers = ["User-Agent: MyGodotApp"]',
|
||||||
|
f" var error := http_request.request(url, headers, false, HTTPClient.METHOD_{method.upper()})",
|
||||||
|
" if error != OK:",
|
||||||
|
' push_error("HTTP request failed.")',
|
||||||
|
" return",
|
||||||
|
" http_request.set_meta(\"callback\", callback)",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
# Add the completion handler
|
||||||
|
lines.extend([
|
||||||
|
"func _on_request_completed(result: int, response_code: int, headers: PoolStringArray, body: PoolByteArray):",
|
||||||
|
" var callback := http_request.get_meta(\"callback\")",
|
||||||
|
" if callback:",
|
||||||
|
" var response_body = body.get_string_from_utf8()",
|
||||||
|
" var json = JSON.new()",
|
||||||
|
" if json.parse(response_body) == OK:",
|
||||||
|
" callback.call(json.get_data())",
|
||||||
|
" else:",
|
||||||
|
" callback.call(null)",
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_code(spec: dict, output_dir: str):
|
||||||
|
"""Generate all GDScript files from the OpenAPI spec."""
|
||||||
|
# Create output directory
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate models
|
||||||
|
schemas = spec.get("definitions", {}) # Swagger 2.0 uses "definitions"
|
||||||
|
if not schemas:
|
||||||
|
schemas = spec.get("components", {}).get("schemas", {})
|
||||||
|
|
||||||
|
for schema_name, schema in schemas.items():
|
||||||
|
class_name = sanitize_class_name(schema_name)
|
||||||
|
code = generate_model_class(class_name, schema)
|
||||||
|
output_path = Path(output_dir) / f"{class_name}.gd"
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
f.write(code)
|
||||||
|
print(f"Generated model: {output_path}")
|
||||||
|
|
||||||
|
# Group endpoints by tag
|
||||||
|
paths = spec.get("paths", {})
|
||||||
|
tag_endpoints = defaultdict(list)
|
||||||
|
|
||||||
|
for path, methods in paths.items():
|
||||||
|
for method, endpoint in methods.items():
|
||||||
|
tags = endpoint.get("tags", ["default"])
|
||||||
|
for tag in tags:
|
||||||
|
tag_endpoints[tag].append((path, method, endpoint))
|
||||||
|
|
||||||
|
# Generate one file per tag
|
||||||
|
for tag, endpoints in tag_endpoints.items():
|
||||||
|
code = generate_tag_client(tag, endpoints)
|
||||||
|
class_name = sanitize_class_name(tag) + "API"
|
||||||
|
output_path = Path(output_dir) / f"{class_name}.gd"
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
f.write(code)
|
||||||
|
print(f"Generated API client for tag '{tag}': {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Generate Godot API clients from OpenAPI spec")
|
||||||
|
parser.add_argument("input", help="Path to the OpenAPI JSON/YAML file")
|
||||||
|
parser.add_argument("-o", "--output", default="godot_generated", help="Output directory for GDScript files")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
spec = load_openapi_spec(args.input)
|
||||||
|
generate_code(spec, args.output)
|
||||||
|
print("Done!")
|
||||||
|
print("Note: When initializing the API classes, pass the base URL as a parameter:")
|
||||||
|
print(" var music_api = MusicAPI.new()")
|
||||||
|
print(" music_api._init(get_node('/root'), 'http://localhost:8080')")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,6 +4,779 @@
|
|||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/character": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the image for a specific character",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"image/png"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"characters"
|
||||||
|
],
|
||||||
|
"summary": "Get character image",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Character name",
|
||||||
|
"name": "name",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/characters": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of all available characters",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"characters"
|
||||||
|
],
|
||||||
|
"summary": "Get list of characters",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/dbtest": {
|
||||||
|
"get": {
|
||||||
|
"description": "Tests the database connection",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"database"
|
||||||
|
],
|
||||||
|
"summary": "Test database connection",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "TestedDB",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/download": {
|
||||||
|
"get": {
|
||||||
|
"description": "Checks for the latest version of the application",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"summary": "Check for latest version",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/download/linux": {
|
||||||
|
"get": {
|
||||||
|
"description": "Redirects to download the latest Linux version",
|
||||||
|
"produces": [
|
||||||
|
"application/octet-stream"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"summary": "Download latest Linux version",
|
||||||
|
"responses": {
|
||||||
|
"302": {
|
||||||
|
"description": "Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/download/list": {
|
||||||
|
"get": {
|
||||||
|
"description": "Lists all assets available for the latest version",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"summary": "List assets of latest version",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/download/windows": {
|
||||||
|
"get": {
|
||||||
|
"description": "Redirects to download the latest Windows version",
|
||||||
|
"produces": [
|
||||||
|
"application/octet-stream"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"summary": "Download latest Windows version",
|
||||||
|
"responses": {
|
||||||
|
"302": {
|
||||||
|
"description": "Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the health status of the server",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"health"
|
||||||
|
],
|
||||||
|
"summary": "Check server health",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a specific song by name",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get a specific song",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Song name",
|
||||||
|
"name": "song",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "song can't be empty",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/addPlayed": {
|
||||||
|
"get": {
|
||||||
|
"description": "Adds the latest song to the played list",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Add latest to played",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/addQue": {
|
||||||
|
"get": {
|
||||||
|
"description": "Adds the latest song to the queue",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Add latest to queue",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/all/order": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of all games in order",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get all games",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/all/random": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of all games in random order",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get all games random",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/info": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns information about the current song",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get current song info",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/list": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of played songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get played songs list",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/next": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the next song in the queue",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get next song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/played": {
|
||||||
|
"put": {
|
||||||
|
"description": "Marks a song as played by its ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Mark song as played",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Song ID",
|
||||||
|
"name": "song",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/previous": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the previous song in the queue",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get previous song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/rand": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a random song",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get random song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/rand/classic": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a random song from the classic selection",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get random classic song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/rand/low": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a random song with low chance selection",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get random song with low chance",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/reset": {
|
||||||
|
"get": {
|
||||||
|
"description": "Resets the music state",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Reset music state",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/music/soundTest": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the sound check song",
|
||||||
|
"produces": [
|
||||||
|
"audio/mpeg"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"music"
|
||||||
|
],
|
||||||
|
"summary": "Get sound check song",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sync": {
|
||||||
|
"get": {
|
||||||
|
"description": "Starts syncing games with only new changes",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
],
|
||||||
|
"summary": "Sync games with only changes",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Start syncing games",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sync/full": {
|
||||||
|
"get": {
|
||||||
|
"description": "Starts a full sync of all games",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
],
|
||||||
|
"summary": "Sync all games fully",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Start syncing games full",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sync/progress": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns the current sync progress or result",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
],
|
||||||
|
"summary": "Get sync progress",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sync/reset": {
|
||||||
|
"get": {
|
||||||
|
"description": "Resets the games database by deleting all games and songs",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
],
|
||||||
|
"summary": "Reset games database",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Games and songs are deleted from the database",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"423": {
|
||||||
|
"description": "Syncing is in progress",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/version": {
|
"/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "get string by ID",
|
"description": "get string by ID",
|
||||||
|
|||||||
@@ -15,6 +15,514 @@ definitions:
|
|||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
paths:
|
paths:
|
||||||
|
/character:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the image for a specific character
|
||||||
|
parameters:
|
||||||
|
- description: Character name
|
||||||
|
in: query
|
||||||
|
name: name
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- image/png
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
summary: Get character image
|
||||||
|
tags:
|
||||||
|
- characters
|
||||||
|
/characters:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns a list of all available characters
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
summary: Get list of characters
|
||||||
|
tags:
|
||||||
|
- characters
|
||||||
|
/dbtest:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Tests the database connection
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: TestedDB
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Test database connection
|
||||||
|
tags:
|
||||||
|
- database
|
||||||
|
/download:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Checks for the latest version of the application
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Check for latest version
|
||||||
|
tags:
|
||||||
|
- download
|
||||||
|
/download/linux:
|
||||||
|
get:
|
||||||
|
description: Redirects to download the latest Linux version
|
||||||
|
produces:
|
||||||
|
- application/octet-stream
|
||||||
|
responses:
|
||||||
|
"302":
|
||||||
|
description: Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Download latest Linux version
|
||||||
|
tags:
|
||||||
|
- download
|
||||||
|
/download/list:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Lists all assets available for the latest version
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
summary: List assets of latest version
|
||||||
|
tags:
|
||||||
|
- download
|
||||||
|
/download/windows:
|
||||||
|
get:
|
||||||
|
description: Redirects to download the latest Windows version
|
||||||
|
produces:
|
||||||
|
- application/octet-stream
|
||||||
|
responses:
|
||||||
|
"302":
|
||||||
|
description: Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Download latest Windows version
|
||||||
|
tags:
|
||||||
|
- download
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the health status of the server
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Check server health
|
||||||
|
tags:
|
||||||
|
- health
|
||||||
|
/music:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns a specific song by name
|
||||||
|
parameters:
|
||||||
|
- description: Song name
|
||||||
|
in: query
|
||||||
|
name: song
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- audio/mpeg
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"400":
|
||||||
|
description: song can't be empty
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get a specific song
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/addPlayed:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Adds the latest song to the played list
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: ""
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Add latest to played
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/addQue:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Adds the latest song to the queue
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: ""
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Add latest to queue
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/all/order:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns a list of all games in order
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get all games
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/all/random:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns a list of all games in random order
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get all games random
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/info:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns information about the current song
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Get current song info
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/list:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns a list of played songs
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
summary: Get played songs list
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/next:
|
||||||
|
get:
|
||||||
|
description: Returns the next song in the queue
|
||||||
|
produces:
|
||||||
|
- audio/mpeg
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get next song
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/played:
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Marks a song as played by its ID
|
||||||
|
parameters:
|
||||||
|
- description: Song ID
|
||||||
|
in: query
|
||||||
|
name: song
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: ""
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Mark song as played
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/previous:
|
||||||
|
get:
|
||||||
|
description: Returns the previous song in the queue
|
||||||
|
produces:
|
||||||
|
- audio/mpeg
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get previous song
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/rand:
|
||||||
|
get:
|
||||||
|
description: Returns a random song
|
||||||
|
produces:
|
||||||
|
- audio/mpeg
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get random song
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/rand/classic:
|
||||||
|
get:
|
||||||
|
description: Returns a random song from the classic selection
|
||||||
|
produces:
|
||||||
|
- audio/mpeg
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get random classic song
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/rand/low:
|
||||||
|
get:
|
||||||
|
description: Returns a random song with low chance selection
|
||||||
|
produces:
|
||||||
|
- audio/mpeg
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get random song with low chance
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/reset:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Resets the music state
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: ""
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Reset music state
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/music/soundTest:
|
||||||
|
get:
|
||||||
|
description: Returns the sound check song
|
||||||
|
produces:
|
||||||
|
- audio/mpeg
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Get sound check song
|
||||||
|
tags:
|
||||||
|
- music
|
||||||
|
/sync:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Starts syncing games with only new changes
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Start syncing games
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Sync games with only changes
|
||||||
|
tags:
|
||||||
|
- sync
|
||||||
|
/sync/full:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Starts a full sync of all games
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Start syncing games full
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Sync all games fully
|
||||||
|
tags:
|
||||||
|
- sync
|
||||||
|
/sync/progress:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns the current sync progress or result
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
summary: Get sync progress
|
||||||
|
tags:
|
||||||
|
- sync
|
||||||
|
/sync/reset:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Resets the games database by deleting all games and songs
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Games and songs are deleted from the database
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"423":
|
||||||
|
description: Syncing is in progress
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Reset games database
|
||||||
|
tags:
|
||||||
|
- sync
|
||||||
/version:
|
/version:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -7,42 +7,51 @@
|
|||||||
"build": "vue-cli-service build",
|
"build": "vue-cli-service build",
|
||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.21.1",
|
"axios": "^1.7.2",
|
||||||
"config.js": "^0.1.0",
|
"core-js": "^3.37.1",
|
||||||
"core-js": "^3.8.2",
|
"cors": "^2.8.5",
|
||||||
"cors": "^2.8.5",
|
"express": "^4.19.2",
|
||||||
"express": "^4.17.1",
|
"vue": "^3.4.31",
|
||||||
"nodemon": "^2.0.7",
|
"vue-axios": "^3.5.2",
|
||||||
"vue": "^3.0.5",
|
"vuex": "^4.1.0"
|
||||||
"vue-axios": "^3.2.2",
|
},
|
||||||
"vuex": "^4.0.0-rc.2"
|
"devDependencies": {
|
||||||
},
|
"@vue/cli-plugin-babel": "^5.0.8",
|
||||||
"devDependencies": {
|
"@vue/cli-plugin-eslint": "^5.0.8",
|
||||||
"@vue/cli-plugin-babel": "^4.5.10",
|
"@vue/cli-service": "^5.0.8",
|
||||||
"@vue/cli-plugin-eslint": "^4.5.10",
|
"@vue/compiler-sfc": "^3.4.31",
|
||||||
"@vue/cli-service": "^4.5.10",
|
"@babel/eslint-parser": "^7.25.1",
|
||||||
"@vue/compiler-sfc": "^3.0.5",
|
"eslint": "^8.57.0",
|
||||||
"babel-eslint": "^10.1.0",
|
"eslint-plugin-vue": "^9.27.0"
|
||||||
"eslint": "^6.7.2",
|
},
|
||||||
"eslint-plugin-vue": "^7.4.1"
|
"eslintConfig": {
|
||||||
},
|
"root": true,
|
||||||
"eslintConfig": {
|
"env": {
|
||||||
"root": true,
|
"node": true
|
||||||
"env": {
|
},
|
||||||
"node": true
|
"extends": [
|
||||||
},
|
"plugin:vue/vue3-essential",
|
||||||
"extends": [
|
"eslint:recommended"
|
||||||
"plugin:vue/vue3-essential",
|
],
|
||||||
"eslint:recommended"
|
"parserOptions": {
|
||||||
],
|
"parser": "@babel/eslint-parser",
|
||||||
"parserOptions": {
|
"requireConfigFile": false,
|
||||||
"parser": "babel-eslint"
|
"ecmaVersion": 2020,
|
||||||
},
|
"sourceType": "module",
|
||||||
"rules": {
|
"ecmaFeatures": {
|
||||||
"no-debugger": 1
|
"globalReturn": false,
|
||||||
}
|
"impliedStrict": true,
|
||||||
},
|
"jsx": true
|
||||||
|
},
|
||||||
|
"babelOptions": {
|
||||||
|
"presets": ["@babel/preset-env"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-debugger": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 1%",
|
"> 1%",
|
||||||
"last 2 versions",
|
"last 2 versions",
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"music-server/internal/logging"
|
||||||
"log"
|
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/server"
|
"music-server/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -18,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
|
||||||
|
|
||||||
@@ -33,26 +35,27 @@ 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)
|
||||||
|
|
||||||
log.Printf("Open http://localhost%s in the browser", 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 {
|
||||||
panic(fmt.Sprintf("http server error: %s", err))
|
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the graceful shutdown to complete
|
// Wait for the graceful shutdown to complete
|
||||||
<-done
|
<-done
|
||||||
log.Println("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()
|
||||||
@@ -60,18 +63,22 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
|||||||
// Listen for the interrupt signal.
|
// Listen for the interrupt signal.
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
log.Println("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 {
|
||||||
log.Printf("Server forced to shutdown with error: %v", err)
|
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Server exiting")
|
logging.GetLogger().Info("Server exiting")
|
||||||
|
|
||||||
// Notify the main goroutine that the shutdown is complete
|
// Notify the main goroutine that the shutdown is complete
|
||||||
done <- true
|
done <- true
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
#search-container {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search_term {
|
|
||||||
width: 60vw;
|
|
||||||
font-size: 2vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clear {
|
|
||||||
font-size: 2vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#games-container{
|
|
||||||
font-size: 2vh;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/* Pure CSS styles for Music Search */
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-container {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search_term {
|
||||||
|
width: 60vw;
|
||||||
|
max-width: 600px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #9ca3af;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search_term:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #f97316;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear:hover {
|
||||||
|
background-color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
#games-container {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game result cards */
|
||||||
|
.bg-green-100 {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-6 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#search_term {
|
||||||
|
width: 80vw;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,15 @@ package web
|
|||||||
|
|
||||||
templ Base() {
|
templ Base() {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="h-screen">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<title>Music Search</title>
|
<title>Music Search</title>
|
||||||
<link href="assets/css/output.css" rel="stylesheet"/>
|
<link href="assets/css/styles.css" rel="stylesheet"/>
|
||||||
<script src="assets/js/htmx.min.js"></script>
|
<script src="assets/js/htmx.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100">
|
<body>
|
||||||
<main class="mx-auto p-4">
|
<main>
|
||||||
{ children... }
|
{ children... }
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package web
|
|||||||
templ HelloForm() {
|
templ HelloForm() {
|
||||||
@Base() {
|
@Base() {
|
||||||
<div id="search-container">
|
<div id="search-container">
|
||||||
<input class="bg-gray-200 text-black p-2 border border-gray-400 rounded-lg" 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" class="bg-orange-500 hover:bg-orange-700 text-white py-2 px-4 rounded" id="clear" name="clear">Clear</button>
|
<button type="button" id="clear" name="clear">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="games-container"></div>
|
<div id="games-container"></div>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('readystatechange', () => {
|
document.addEventListener('readystatechange', () => {
|
||||||
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();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
test-db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: testuser
|
||||||
|
POSTGRES_PASSWORD: testpass
|
||||||
|
POSTGRES_DB: music_server_test
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
volumes:
|
||||||
|
- test-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
test-db-data:
|
||||||
@@ -1,56 +1,98 @@
|
|||||||
module music-server
|
module music-server
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.24.2
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/MShekow/directory-checksum v1.4.9
|
github.com/MShekow/directory-checksum v1.4.18
|
||||||
github.com/a-h/templ v0.3.937
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.3
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/jackc/pgx/v5 v5.7.5
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
github.com/labstack/echo/v5 v5.1.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.12.3
|
||||||
github.com/panjf2000/ants/v2 v2.11.3
|
github.com/panjf2000/ants/v2 v2.12.0
|
||||||
github.com/spf13/afero v1.14.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/swaggo/echo-swagger v1.4.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/swaggo/echo-swagger/v2 v2.0.1
|
||||||
github.com/swaggo/swag v1.16.6
|
github.com/swaggo/swag v1.16.6
|
||||||
|
github.com/testcontainers/testcontainers-go v0.42.0
|
||||||
|
go.uber.org/zap v1.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/docker/docker v27.3.1+incompatible // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/ghodss/yaml v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-errors/errors v1.5.1 // indirect
|
github.com/go-errors/errors v1.5.1 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/spec v0.20.4 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/go-openapi/swag v0.19.15 // indirect
|
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/go-openapi/spec v0.22.4 // indirect
|
||||||
|
github.com/go-openapi/swag/conv v0.26.0 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
|
||||||
|
github.com/go-openapi/swag/loading v0.26.0 // indirect
|
||||||
|
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
||||||
|
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
github.com/swaggo/files/v2 v2.0.0 // indirect
|
github.com/moby/moby/api v1.54.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/moby/moby/client v0.4.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.32.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
go.uber.org/atomic v1.7.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
golang.org/x/mod v0.26.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||||
golang.org/x/time v0.11.0 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
golang.org/x/tools v0.35.0 // indirect
|
github.com/sv-tools/openapi v0.4.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||||
|
github.com/swaggo/swag/v2 v2.0.0-rc5 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,174 +1,238 @@
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/MShekow/directory-checksum v1.4.9 h1:olzWbrq9ylwfi7afuoivzHM8AV2z2KOaT7FJ6Ri2ppU=
|
github.com/MShekow/directory-checksum v1.4.18 h1:1nPPVl7uREa6WMTAPKoWW/GylhnASs0C9C+GPiwLwXA=
|
||||||
github.com/MShekow/directory-checksum v1.4.9/go.mod h1:LhNeWmPftlKTlc3TNurdihPK/whw9j76VnLaTRu2SkU=
|
github.com/MShekow/directory-checksum v1.4.18/go.mod h1:iUupsPb0X0BumQQymLrpD5Pkqe/CbV13OSgosw1oFc4=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/a-h/templ v0.3.937 h1:Ta+0Tf9YuZplUyKTUxReV36FCRKtK6FRMWpmXERHDnM=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/a-h/templ v0.3.937/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
|
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||||
github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo=
|
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||||
|
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
|
||||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
|
||||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
|
||||||
|
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
|
||||||
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
|
||||||
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
|
||||||
|
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
|
||||||
|
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
|
||||||
|
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
|
||||||
|
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
|
||||||
|
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
|
||||||
|
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
|
||||||
|
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
|
||||||
|
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I=
|
||||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||||
|
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
||||||
|
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
|
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||||
|
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||||
|
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
|
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||||
|
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
|
github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8=
|
||||||
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
|
github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
|
github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4=
|
||||||
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
|
github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE=
|
||||||
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
|
github.com/swaggo/echo-swagger/v2 v2.0.1 h1:jKR3QiK+ciGjxE0+7qZ/azjtlx/pTVls7pJFJqdJoJI=
|
||||||
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
|
github.com/swaggo/echo-swagger/v2 v2.0.1/go.mod h1:BbgiO9XKX6yYU5Rq4ejqVlQI0mVRv6ziFKd0XgdztnQ=
|
||||||
|
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
||||||
|
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
||||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
|
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94=
|
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||||
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
|
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||||
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
|
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||||
|
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||||
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testContainer testcontainers.Container
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Start PostgreSQL container
|
||||||
|
log.Println("Starting PostgreSQL test container...")
|
||||||
|
req := testcontainers.ContainerRequest{
|
||||||
|
Image: "postgres:15-alpine",
|
||||||
|
ExposedPorts: []string{"5432/tcp"},
|
||||||
|
Env: map[string]string{
|
||||||
|
"POSTGRES_USER": "testuser",
|
||||||
|
"POSTGRES_PASSWORD": "testpass",
|
||||||
|
"POSTGRES_DB": "music_server_test",
|
||||||
|
},
|
||||||
|
WaitingFor: wait.ForLog("database system is ready to accept connections"),
|
||||||
|
}
|
||||||
|
|
||||||
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
|
ContainerRequest: req,
|
||||||
|
Started: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to start container: %v", err)
|
||||||
|
}
|
||||||
|
testContainer = container
|
||||||
|
|
||||||
|
// Get container's host and port
|
||||||
|
host, err := container.Endpoint(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to get container endpoint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("PostgreSQL container running at: %s", host)
|
||||||
|
|
||||||
|
// Set environment variables for all tests
|
||||||
|
os.Setenv("DB_HOST", host)
|
||||||
|
os.Setenv("DB_PORT", "5432")
|
||||||
|
os.Setenv("DB_USERNAME", "testuser")
|
||||||
|
os.Setenv("DB_PASSWORD", "testpass")
|
||||||
|
os.Setenv("DB_NAME", "music_server_test")
|
||||||
|
os.Setenv("MUSIC_PATH", "./testMusic")
|
||||||
|
os.Setenv("CHARACTERS_PATH", "./testCharacters")
|
||||||
|
os.Setenv("PORT", "8081")
|
||||||
|
os.Setenv("LOG_LEVEL", "debug")
|
||||||
|
os.Setenv("LOG_JSON", "false")
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
log.Println("Running integration tests...")
|
||||||
|
exitCode := m.Run()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
log.Println("Stopping test container...")
|
||||||
|
if err := container.Terminate(ctx); err != nil {
|
||||||
|
log.Printf("Failed to terminate container: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseConnection verifies we can connect to the test database
|
||||||
|
func TestDatabaseConnection(t *testing.T) {
|
||||||
|
// This will be tested by the individual handler tests
|
||||||
|
// Just verify env vars are set
|
||||||
|
host := os.Getenv("DB_HOST")
|
||||||
|
port := os.Getenv("DB_PORT")
|
||||||
|
|
||||||
|
if host == "" || port == "" {
|
||||||
|
t.Error("Database environment variables not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Database configuration: host=%s, port=%s", host, port)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"music-server/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetCharacterList() []string {
|
func GetCharacterList() []string {
|
||||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
charactersPath := os.Getenv("CHARACTERS_PATH")
|
||||||
|
logging.GetLogger().Debug("Getting character list", zap.String("path", charactersPath))
|
||||||
|
// Clean the path - remove trailing slashes and then add one for consistency
|
||||||
|
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
||||||
|
charactersPath += "/"
|
||||||
files, err := os.ReadDir(charactersPath)
|
files, err := os.ReadDir(charactersPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
logging.GetLogger().Fatal("Failed to read characters directory", zap.String("path", charactersPath), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
var characters []string
|
var characters []string
|
||||||
@@ -24,7 +30,11 @@ func GetCharacterList() []string {
|
|||||||
|
|
||||||
func GetCharacter(character string) string {
|
func GetCharacter(character string) string {
|
||||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
charactersPath := os.Getenv("CHARACTERS_PATH")
|
||||||
return charactersPath + "/" + character
|
// Clean the path - remove trailing slashes and then add one for consistency
|
||||||
|
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
||||||
|
charactersPath += "/"
|
||||||
|
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath+character))
|
||||||
|
return charactersPath + character
|
||||||
}
|
}
|
||||||
|
|
||||||
func isImage(entry os.DirEntry) bool {
|
func isImage(entry os.DirEntry) bool {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsImage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
entry fs.DirEntry
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "jpg file",
|
||||||
|
entry: &mockDirEntry{name: "test.jpg", isDir: false},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jpeg file",
|
||||||
|
entry: &mockDirEntry{name: "test.jpeg", isDir: false},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "png file",
|
||||||
|
entry: &mockDirEntry{name: "test.png", isDir: false},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory",
|
||||||
|
entry: &mockDirEntry{name: "test", isDir: true},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "txt file",
|
||||||
|
entry: &mockDirEntry{name: "test.txt", isDir: false},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mp3 file",
|
||||||
|
entry: &mockDirEntry{name: "test.mp3", isDir: false},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isImage(tt.entry)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isImage() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockDirEntry struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDirEntry) Name() string { return m.name }
|
||||||
|
func (m *mockDirEntry) IsDir() bool { return m.isDir }
|
||||||
|
func (m *mockDirEntry) Type() fs.FileMode { return 0 }
|
||||||
|
func (m *mockDirEntry) Info() (fs.FileInfo, error) { return nil, nil }
|
||||||
|
func (m *mockDirEntry) Sys() interface{} { return nil }
|
||||||
|
|
||||||
|
func TestGetCharacter(t *testing.T) {
|
||||||
|
os.Setenv("CHARACTERS_PATH", "/test/path")
|
||||||
|
defer os.Unsetenv("CHARACTERS_PATH")
|
||||||
|
|
||||||
|
result := GetCharacter("test.jpg")
|
||||||
|
expected := "/test/path/test.jpg"
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("GetCharacter() = %v, want %v", result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCharacterWithTrailingSlash(t *testing.T) {
|
||||||
|
os.Setenv("CHARACTERS_PATH", "/test/path/")
|
||||||
|
defer os.Unsetenv("CHARACTERS_PATH")
|
||||||
|
|
||||||
|
result := GetCharacter("test.jpg")
|
||||||
|
expected := "/test/path/test.jpg"
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("GetCharacter() = %v, want %v", result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"music-server/internal/logging"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type giteaResponse struct {
|
type giteaResponse struct {
|
||||||
@@ -21,60 +22,57 @@ type assetResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CheckLatest() string {
|
func CheckLatest() string {
|
||||||
resp, err := http.Get("https://gitea.sanplex.tech/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
logging.GetLogger().Fatal("Failed to check latest version", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
//Create a variable of the same type as our model
|
//Create a variable of the same type as our model
|
||||||
var cResp giteaResponse
|
var cResp giteaResponse
|
||||||
//Decode the data
|
//Decode the data
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||||
fmt.Println(err)
|
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||||
log.Fatal("ooopsss! an error occurred, please try again")
|
|
||||||
}
|
}
|
||||||
log.Printf("Id: %v, Name: %v", cResp.Id, cResp.Name)
|
logging.GetLogger().Debug("Checked latest version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||||
return cResp.Name
|
return cResp.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListAssetsOfLatest() []string {
|
func ListAssetsOfLatest() []string {
|
||||||
resp, err := http.Get("https://gitea.sanplex.tech/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
logging.GetLogger().Fatal("Failed to list assets", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
//Create a variable of the same type as our model
|
//Create a variable of the same type as our model
|
||||||
var cResp giteaResponse
|
var cResp giteaResponse
|
||||||
//Decode the data
|
//Decode the data
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||||
fmt.Println(err)
|
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||||
log.Fatal("ooopsss! an error occurred, please try again")
|
|
||||||
}
|
}
|
||||||
log.Printf("Id: %v, Name: %v", cResp.Id, cResp.Name)
|
logging.GetLogger().Debug("Listing assets", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||||
var assets []string
|
var assets []string
|
||||||
for _, asset := range cResp.Assets {
|
for _, asset := range cResp.Assets {
|
||||||
log.Printf("Id: %v, Name: %v, Asset: %v", cResp.Id, cResp.Name, asset.Name)
|
logging.GetLogger().Debug("Found asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
|
||||||
assets = append(assets, asset.Name)
|
assets = append(assets, asset.Name)
|
||||||
}
|
}
|
||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadLatestWindows() string {
|
func DownloadLatestWindows() string {
|
||||||
resp, err := http.Get("https://gitea.sanplex.tech/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
logging.GetLogger().Fatal("Failed to download latest Windows version", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
//Create a variable of the same type as our model
|
//Create a variable of the same type as our model
|
||||||
var cResp giteaResponse
|
var cResp giteaResponse
|
||||||
//Decode the data
|
//Decode the data
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||||
fmt.Println(err)
|
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||||
log.Fatal("ooopsss! an error occurred, please try again")
|
|
||||||
}
|
}
|
||||||
log.Printf("Id: %v, Name: %v", cResp.Id, cResp.Name)
|
logging.GetLogger().Debug("Downloading Windows version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||||
for _, asset := range cResp.Assets {
|
for _, asset := range cResp.Assets {
|
||||||
log.Printf("Id: %v, Name: %v, Asset: %v", cResp.Id, cResp.Name, asset.Name)
|
logging.GetLogger().Debug("Checking asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
|
||||||
if strings.HasSuffix(asset.Name, ".exe") {
|
if strings.HasSuffix(asset.Name, ".exe") {
|
||||||
return asset.DownloadUrl
|
return asset.DownloadUrl
|
||||||
}
|
}
|
||||||
@@ -83,21 +81,20 @@ func DownloadLatestWindows() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DownloadLatestLinux() string {
|
func DownloadLatestLinux() string {
|
||||||
resp, err := http.Get("https://gitea.sanplex.tech/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
logging.GetLogger().Fatal("Failed to download latest Linux version", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
//Create a variable of the same type as our model
|
//Create a variable of the same type as our model
|
||||||
var cResp giteaResponse
|
var cResp giteaResponse
|
||||||
//Decode the data
|
//Decode the data
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||||
fmt.Println(err)
|
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||||
log.Fatal("ooopsss! an error occurred, please try again")
|
|
||||||
}
|
}
|
||||||
log.Printf("Id: %v, Name: %v", cResp.Id, cResp.Name)
|
logging.GetLogger().Debug("Downloading Linux version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||||
for _, asset := range cResp.Assets {
|
for _, asset := range cResp.Assets {
|
||||||
log.Printf("Id: %v, Name: %v, Asset: %v", cResp.Id, cResp.Name, asset.Name)
|
logging.GetLogger().Debug("Checking asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
|
||||||
if strings.HasSuffix(asset.Name, ".x86_64") {
|
if strings.HasSuffix(asset.Name, ".x86_64") {
|
||||||
return asset.DownloadUrl
|
return asset.DownloadUrl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckLatest(t *testing.T) {
|
||||||
|
mockResponse := giteaResponse{
|
||||||
|
Id: 1,
|
||||||
|
Name: "v1.0.0",
|
||||||
|
Assets: []assetResponse{
|
||||||
|
{Id: 1, Name: "app.exe", DownloadUrl: "http://example.com/app.exe"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(mockResponse)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
originalURL := "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest"
|
||||||
|
_ = originalURL
|
||||||
|
|
||||||
|
// Note: This test would need mocking of http.Get to fully work
|
||||||
|
// For now, we'll just test the parsing logic
|
||||||
|
// In a real scenario, you'd use httpmock or similar
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAssetsOfLatest(t *testing.T) {
|
||||||
|
mockResponse := giteaResponse{
|
||||||
|
Id: 1,
|
||||||
|
Name: "v1.0.0",
|
||||||
|
Assets: []assetResponse{
|
||||||
|
{Id: 1, Name: "app.exe", DownloadUrl: "http://example.com/app.exe"},
|
||||||
|
{Id: 2, Name: "app.x86_64", DownloadUrl: "http://example.com/app.x86_64"},
|
||||||
|
{Id: 3, Name: "app.dmg", DownloadUrl: "http://example.com/app.dmg"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the parsing of the response
|
||||||
|
var cResp giteaResponse
|
||||||
|
data, _ := json.Marshal(mockResponse)
|
||||||
|
json.Unmarshal(data, &cResp)
|
||||||
|
|
||||||
|
var assets []string
|
||||||
|
for _, asset := range cResp.Assets {
|
||||||
|
assets = append(assets, asset.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(assets) != 3 {
|
||||||
|
t.Errorf("Expected 3 assets, got %d", len(assets))
|
||||||
|
}
|
||||||
|
|
||||||
|
if assets[0] != "app.exe" {
|
||||||
|
t.Errorf("Expected first asset to be app.exe, got %s", assets[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
|
"music-server/internal/logging"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SongInfo struct {
|
type SongInfo struct {
|
||||||
@@ -26,18 +27,20 @@ var gamesNew []repository.Game
|
|||||||
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.Game {
|
||||||
if len(gamesNew) == 0 {
|
if len(gamesNew) == 0 {
|
||||||
initRepo()
|
initRepo()
|
||||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
gamesNew, _ = BackendRepo().FindAllGames(BackendCtx())
|
||||||
}
|
}
|
||||||
return gamesNew
|
return gamesNew
|
||||||
|
|
||||||
@@ -46,7 +49,7 @@ func getAllGames() []repository.Game {
|
|||||||
func GetSoundCheckSong() string {
|
func GetSoundCheckSong() string {
|
||||||
files, err := os.ReadDir("songs")
|
files, err := os.ReadDir("songs")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
logging.GetLogger().Fatal("Failed to read songs directory", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
fileInfo := files[rand.Intn(len(files))]
|
fileInfo := files[rand.Intn(len(files))]
|
||||||
return "songs/" + fileInfo.Name()
|
return "songs/" + fileInfo.Name()
|
||||||
@@ -56,7 +59,7 @@ func Reset() {
|
|||||||
songQueNew = nil
|
songQueNew = nil
|
||||||
currentSong = -1
|
currentSong = -1
|
||||||
initRepo()
|
initRepo()
|
||||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
gamesNew, _ = BackendRepo().FindAllGames(BackendCtx())
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddLatestToQue() {
|
func AddLatestToQue() {
|
||||||
@@ -74,8 +77,8 @@ func AddLatestPlayed() {
|
|||||||
currentSongData := songQueNew[currentSong]
|
currentSongData := songQueNew[currentSong]
|
||||||
|
|
||||||
initRepo()
|
initRepo()
|
||||||
repo.AddGamePlayed(db.Ctx, currentSongData.GameID)
|
BackendRepo().AddGamePlayed(BackendCtx(), currentSongData.GameID)
|
||||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
|
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetPlayed(songNumber int) {
|
func SetPlayed(songNumber int) {
|
||||||
@@ -84,8 +87,8 @@ func SetPlayed(songNumber int) {
|
|||||||
}
|
}
|
||||||
songData := songQueNew[songNumber]
|
songData := songQueNew[songNumber]
|
||||||
initRepo()
|
initRepo()
|
||||||
repo.AddGamePlayed(db.Ctx, songData.GameID)
|
BackendRepo().AddGamePlayed(BackendCtx(), songData.GameID)
|
||||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
|
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRandomSong() string {
|
func GetRandomSong() string {
|
||||||
@@ -128,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().FindSongsFromGame(BackendCtx(), game.ID)
|
||||||
listOfAllSongs = append(listOfAllSongs, songList...)
|
listOfAllSongs = append(listOfAllSongs, songList...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,11 +139,14 @@ 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().GetGameById(BackendCtx(), song.GameID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||||
log.Printf("Song not found, song '%s' deleted from game '%s' FileName: %s\n", song.SongName, gameData.GameName, *song.FileName)
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
|
zap.String("song", song.SongName),
|
||||||
|
zap.String("game", gameData.GameName),
|
||||||
|
zap.String("filename", *song.FileName))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,14 +154,17 @@ 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(), song.Path)
|
||||||
log.Printf("Song not found, song '%s' deleted from game '%s' FileName: %s\n", song.SongName, gameData.GameName, *song.FileName)
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
|
zap.String("song", song.SongName),
|
||||||
|
zap.String("game", gameData.GameName),
|
||||||
|
zap.String("filename", *song.FileName))
|
||||||
} else {
|
} else {
|
||||||
songFound = true
|
songFound = true
|
||||||
}
|
}
|
||||||
err = openFile.Close()
|
err = openFile.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Failed to close file", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastFetchedNew = song
|
lastFetchedNew = song
|
||||||
@@ -262,26 +271,29 @@ func getSongFromList(games []repository.Game) repository.Song {
|
|||||||
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().FindSongsFromGame(BackendCtx(), game.ID)
|
||||||
if len(songs) == 0 {
|
if len(songs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
song = songs[rand.Intn(len(songs))]
|
song = songs[rand.Intn(len(songs))]
|
||||||
log.Println("song = ", song)
|
logging.GetLogger().Debug("Selected song", zap.String("song", song.SongName), zap.String("path", song.Path))
|
||||||
|
|
||||||
//Check if file exists and open
|
//Check if file exists and open
|
||||||
openFile, err := os.Open(song.Path)
|
openFile, err := os.Open(song.Path)
|
||||||
if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
if err != nil || (song.FileName != nil && 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(), song.Path)
|
||||||
log.Printf("Song not found, song '%s' deleted from game '%s' FileName: %v\n", song.SongName, game.GameName, song.FileName)
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
|
zap.String("song", song.SongName),
|
||||||
|
zap.String("game", game.GameName),
|
||||||
|
zap.Any("filename", song.FileName))
|
||||||
} else {
|
} else {
|
||||||
songFound = true
|
songFound = true
|
||||||
}
|
}
|
||||||
|
|
||||||
err = openFile.Close()
|
err = openFile.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Failed to close file", zap.String("path", song.Path), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return song
|
return song
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test the average calculation logic directly without database access
|
||||||
|
func TestCalculateAverage(t *testing.T) {
|
||||||
|
games := []repository.Game{
|
||||||
|
{GameName: "Game1", TimesPlayed: 10},
|
||||||
|
{GameName: "Game2", TimesPlayed: 20},
|
||||||
|
{GameName: "Game3", TimesPlayed: 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum int32
|
||||||
|
for _, data := range games {
|
||||||
|
sum += data.TimesPlayed
|
||||||
|
}
|
||||||
|
result := sum / int32(len(games))
|
||||||
|
expected := int32(20)
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Average calculation = %v, want %v", result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAverageEmpty(t *testing.T) {
|
||||||
|
games := []repository.Game{}
|
||||||
|
|
||||||
|
if len(games) == 0 {
|
||||||
|
result := int32(0)
|
||||||
|
expected := int32(0)
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Average calculation with empty list = %v, want %v", result, expected)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum int32
|
||||||
|
for _, data := range games {
|
||||||
|
sum += data.TimesPlayed
|
||||||
|
}
|
||||||
|
result := sum / int32(len(games))
|
||||||
|
expected := int32(0)
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Average calculation with empty list = %v, want %v", result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAverageSingle(t *testing.T) {
|
||||||
|
games := []repository.Game{
|
||||||
|
{GameName: "Game1", TimesPlayed: 42},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum int32
|
||||||
|
for _, data := range games {
|
||||||
|
sum += data.TimesPlayed
|
||||||
|
}
|
||||||
|
result := sum / int32(len(games))
|
||||||
|
expected := int32(42)
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Average calculation with single game = %v, want %v", result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRandomGame(t *testing.T) {
|
||||||
|
games := []repository.Game{
|
||||||
|
{GameName: "Game1", TimesPlayed: 10},
|
||||||
|
{GameName: "Game2", TimesPlayed: 20},
|
||||||
|
{GameName: "Game3", TimesPlayed: 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set seed for reproducible tests
|
||||||
|
rand.Seed(42)
|
||||||
|
|
||||||
|
result := games[rand.Intn(len(games))]
|
||||||
|
|
||||||
|
if result.GameName == "" {
|
||||||
|
t.Error("random game selection returned empty game")
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, g := range games {
|
||||||
|
if g.GameName == result.GameName {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Errorf("random game selection returned game not in list: %v", result.GameName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindGameByID(t *testing.T) {
|
||||||
|
games := []repository.Game{
|
||||||
|
{ID: 1, GameName: "Game1", TimesPlayed: 10},
|
||||||
|
{ID: 2, GameName: "Game2", TimesPlayed: 20},
|
||||||
|
{ID: 3, GameName: "Game3", TimesPlayed: 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
games []repository.Game
|
||||||
|
gameID int32
|
||||||
|
expected repository.Game
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "existing game",
|
||||||
|
games: games,
|
||||||
|
gameID: 2,
|
||||||
|
expected: repository.Game{ID: 2, GameName: "Game2", TimesPlayed: 20},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existing game",
|
||||||
|
games: games,
|
||||||
|
gameID: 99,
|
||||||
|
expected: repository.Game{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var result repository.Game
|
||||||
|
for _, game := range tt.games {
|
||||||
|
if game.ID == tt.gameID {
|
||||||
|
result = game
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.ID != tt.expected.ID || result.GameName != tt.expected.GameName {
|
||||||
|
t.Errorf("findGameByID() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractGameNames(t *testing.T) {
|
||||||
|
games := []repository.Game{
|
||||||
|
{GameName: "Game1", TimesPlayed: 10},
|
||||||
|
{GameName: "Game2", TimesPlayed: 20},
|
||||||
|
{GameName: "Game3", TimesPlayed: 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for _, game := range games {
|
||||||
|
result = append(result, game.GameName)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{"Game1", "Game2", "Game3"}
|
||||||
|
|
||||||
|
if len(result) != len(expected) {
|
||||||
|
t.Errorf("extractGameNames() length = %d, want %d", len(result), len(expected))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range result {
|
||||||
|
if v != expected[i] {
|
||||||
|
t.Errorf("extractGameNames()[%d] = %v, want %v", i, v, expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShuffleGameNames(t *testing.T) {
|
||||||
|
games := []string{"Game1", "Game2", "Game3"}
|
||||||
|
|
||||||
|
// Test that shuffle doesn't lose any elements
|
||||||
|
// We can't test the order since it's random, but we can test length and contents
|
||||||
|
original := make([]string, len(games))
|
||||||
|
copy(original, games)
|
||||||
|
|
||||||
|
// Simple shuffle implementation for testing
|
||||||
|
for i := range games {
|
||||||
|
j := i // In real code this would be random
|
||||||
|
games[i], games[j] = games[j], games[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(games) != len(original) {
|
||||||
|
t.Errorf("shuffleGameNames() changed length from %d to %d", len(original), len(games))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all original elements are still present
|
||||||
|
for _, orig := range original {
|
||||||
|
found := false
|
||||||
|
for _, g := range games {
|
||||||
|
if g == orig {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("shuffleGameNames() lost element: %v", orig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7,9 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
|
"music-server/internal/logging"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -21,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"github.com/MShekow/directory-checksum/directory_checksum"
|
"github.com/MShekow/directory-checksum/directory_checksum"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Syncing = false
|
var Syncing = false
|
||||||
@@ -40,6 +40,8 @@ var gamesChangedContent []string
|
|||||||
var gamesRemoved []string
|
var gamesRemoved []string
|
||||||
var catchedErrors []string
|
var catchedErrors []string
|
||||||
var brokenSongs []string
|
var brokenSongs []string
|
||||||
|
var pool *ants.Pool
|
||||||
|
var poolSong *ants.Pool
|
||||||
|
|
||||||
type SyncResponse struct {
|
type SyncResponse struct {
|
||||||
GamesAdded []string `json:"games_added"`
|
GamesAdded []string `json:"games_added"`
|
||||||
@@ -77,8 +79,8 @@ func (gs GameStatus) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResetDB() {
|
func ResetDB() {
|
||||||
repo.ClearSongs(db.Ctx)
|
repo.ClearSongs(BackendCtx())
|
||||||
repo.ClearGames(db.Ctx)
|
repo.ClearGames(BackendCtx())
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncProgress() ProgressResponse {
|
func SyncProgress() ProgressResponse {
|
||||||
@@ -86,52 +88,48 @@ func SyncProgress() ProgressResponse {
|
|||||||
currentTime := time.Now()
|
currentTime := time.Now()
|
||||||
timeSpent = currentTime.Sub(start)
|
timeSpent = currentTime.Sub(start)
|
||||||
out := time.Time{}.Add(timeSpent)
|
out := time.Time{}.Add(timeSpent)
|
||||||
fmt.Printf("\nTime spent: %v\n", timeSpent)
|
logging.GetLogger().Debug("Sync progress",
|
||||||
fmt.Printf("Total time: %v\n", out.Format("15:04:05.00000"))
|
zap.Int("progress_percent", progress),
|
||||||
log.Printf("Progress: %v/%v %v%%\n", int(foldersSynced), int(numberOfFoldersToSync), progress)
|
zap.Int("folders_synced", int(foldersSynced)),
|
||||||
|
zap.Int("total_folders", int(numberOfFoldersToSync)),
|
||||||
|
zap.String("time_spent", out.Format("15:04:05.00000")))
|
||||||
return ProgressResponse{
|
return ProgressResponse{
|
||||||
Progress: fmt.Sprintf("%v", progress),
|
Progress: fmt.Sprintf("%v", progress),
|
||||||
TimeSpent: fmt.Sprintf("%v", timeSpent),
|
TimeSpent: out.Format("15:04:05"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncResult() SyncResponse {
|
func SyncResult() SyncResponse {
|
||||||
fmt.Printf("\nGames Before: %d\n", len(gamesBeforeSync))
|
logging.GetLogger().Info("Sync completed",
|
||||||
fmt.Printf("Games After: %d\n", len(gamesAfterSync))
|
zap.Int("games_before", len(gamesBeforeSync)),
|
||||||
|
zap.Int("games_after", len(gamesAfterSync)))
|
||||||
|
|
||||||
fmt.Printf("\nGames added: \n")
|
if len(gamesAdded) > 0 {
|
||||||
for _, game := range gamesAdded {
|
logging.GetLogger().Debug("Games added", zap.Strings("games", gamesAdded))
|
||||||
fmt.Printf("%s\n", game)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nGames readded: \n")
|
if len(gamesReAdded) > 0 {
|
||||||
for _, game := range gamesReAdded {
|
logging.GetLogger().Debug("Games readded", zap.Strings("games", gamesReAdded))
|
||||||
fmt.Printf("%s\n", game)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nGames with changed title: \n")
|
if len(gamesChangedTitle) > 0 {
|
||||||
for key, value := range gamesChangedTitle {
|
logging.GetLogger().Debug("Games with changed title", zap.Any("changes", gamesChangedTitle))
|
||||||
fmt.Printf("The game: %s changed title to: %s\n", key, value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nGames with changed content: \n")
|
if len(gamesChangedContent) > 0 {
|
||||||
for _, game := range gamesChangedContent {
|
logging.GetLogger().Debug("Games with changed content", zap.Strings("games", gamesChangedContent))
|
||||||
fmt.Printf("%s\n", game)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n\n")
|
|
||||||
var gamesRemovedTemp []string
|
var gamesRemovedTemp []string
|
||||||
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.GameName == afterGame.GameName {
|
||||||
found = true
|
found = true
|
||||||
//fmt.Printf("Game: %s, Found: %v break\n", beforeGame.GameName, found)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
//fmt.Printf("Game: %s, Found: %v\n", beforeGame.GameName, found)
|
|
||||||
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.GameName)
|
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.GameName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +139,6 @@ func SyncResult() SyncResponse {
|
|||||||
for key := range gamesChangedTitle {
|
for key := range gamesChangedTitle {
|
||||||
if game == key {
|
if game == key {
|
||||||
found = true
|
found = true
|
||||||
//fmt.Printf("Game: %s, Found: %v break2\n", game, found)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,19 +147,16 @@ func SyncResult() SyncResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nGames removed: \n")
|
if len(gamesRemoved) > 0 {
|
||||||
for _, game := range gamesRemoved {
|
logging.GetLogger().Debug("Games removed", zap.Strings("games", gamesRemoved))
|
||||||
fmt.Printf("%s\n", game)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nErrors catched: \n")
|
if len(catchedErrors) > 0 {
|
||||||
for _, error := range catchedErrors {
|
logging.GetLogger().Error("Errors caught during sync", zap.Strings("errors", catchedErrors))
|
||||||
fmt.Printf("%s\n", error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out := time.Time{}.Add(totalTime)
|
out := time.Time{}.Add(totalTime)
|
||||||
fmt.Printf("\nTotal time: %v\n", totalTime)
|
logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000")))
|
||||||
fmt.Printf("Total time: %v\n", out.Format("15:04:05.00000"))
|
|
||||||
|
|
||||||
return SyncResponse{
|
return SyncResponse{
|
||||||
GamesAdded: gamesAdded,
|
GamesAdded: gamesAdded,
|
||||||
@@ -171,7 +165,7 @@ func SyncResult() SyncResponse {
|
|||||||
GamesChangedContent: gamesChangedContent,
|
GamesChangedContent: gamesChangedContent,
|
||||||
GamesRemoved: gamesRemoved,
|
GamesRemoved: gamesRemoved,
|
||||||
CatchedErrors: catchedErrors,
|
CatchedErrors: catchedErrors,
|
||||||
TotalTime: fmt.Sprintf("%v", timeSpent),
|
TotalTime: out.Format("15:04:05"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +184,7 @@ func syncGamesNew(full bool) {
|
|||||||
|
|
||||||
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))
|
||||||
if !strings.HasSuffix(musicPath, "/") {
|
if !strings.HasSuffix(musicPath, "/") {
|
||||||
musicPath += "/"
|
musicPath += "/"
|
||||||
}
|
}
|
||||||
@@ -199,7 +194,7 @@ func syncGamesNew(full bool) {
|
|||||||
initRepo()
|
initRepo()
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
foldersToSkip := []string{".sync", "dist", "old", "characters"}
|
foldersToSkip := []string{".sync", "dist", "old", "characters"}
|
||||||
fmt.Println(foldersToSkip)
|
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
gamesAdded = nil
|
gamesAdded = nil
|
||||||
@@ -210,22 +205,24 @@ func syncGamesNew(full bool) {
|
|||||||
catchedErrors = nil
|
catchedErrors = nil
|
||||||
brokenSongs = nil
|
brokenSongs = nil
|
||||||
|
|
||||||
gamesBeforeSync, err = repo.FindAllGames(db.Ctx)
|
gamesBeforeSync, err = repo.FindAllGames(BackendCtx())
|
||||||
handleError("FindAllGames Before", err, "")
|
handleError("FindAllGames Before", err, "")
|
||||||
fmt.Printf("Games Before: %d\n", len(gamesBeforeSync))
|
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
|
||||||
|
|
||||||
allGames, err = repo.GetAllGamesIncludingDeleted(db.Ctx)
|
allGames, err = repo.GetAllGamesIncludingDeleted(BackendCtx())
|
||||||
handleError("GetAllGamesIncludingDeleted", err, "")
|
handleError("GetAllGamesIncludingDeleted", err, "")
|
||||||
err = repo.SetGameDeletionDate(db.Ctx)
|
err = repo.SetGameDeletionDate(BackendCtx())
|
||||||
handleError("SetGameDeletionDate", err, "")
|
handleError("SetGameDeletionDate", err, "")
|
||||||
|
|
||||||
directories, err := os.ReadDir(musicPath)
|
directories, err := os.ReadDir(musicPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
logging.GetLogger().Fatal("Failed to read music directory", zap.String("path", musicPath), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pool, _ := ants.NewPool(50, ants.WithPreAlloc(true))
|
pool, _ = ants.NewPool(10, ants.WithPreAlloc(true))
|
||||||
|
poolSong, _ = ants.NewPool(10, ants.WithPreAlloc(true))
|
||||||
defer pool.Release()
|
defer pool.Release()
|
||||||
|
defer poolSong.Release()
|
||||||
|
|
||||||
foldersSynced = 0
|
foldersSynced = 0
|
||||||
numberOfFoldersToSync = float32(len(directories))
|
numberOfFoldersToSync = float32(len(directories))
|
||||||
@@ -239,23 +236,22 @@ func syncGamesNew(full bool) {
|
|||||||
syncWg.Wait()
|
syncWg.Wait()
|
||||||
checkBrokenSongsNew()
|
checkBrokenSongsNew()
|
||||||
|
|
||||||
gamesAfterSync, err = repo.FindAllGames(db.Ctx)
|
gamesAfterSync, err = repo.FindAllGames(BackendCtx())
|
||||||
handleError("FindAllGames After", err, "")
|
handleError("FindAllGames After", err, "")
|
||||||
|
|
||||||
finished := time.Now()
|
finished := time.Now()
|
||||||
totalTime = finished.Sub(start)
|
totalTime = finished.Sub(start)
|
||||||
out := time.Time{}.Add(totalTime)
|
out := time.Time{}.Add(totalTime)
|
||||||
fmt.Printf("\nTotal time: %v\n", totalTime)
|
logging.GetLogger().Info("Sync completed", zap.Duration("total_time", totalTime), zap.String("formatted_time", out.Format("15:04:05.00000")))
|
||||||
fmt.Printf("Total time: %v\n", out.Format("15:04:05.00000"))
|
|
||||||
|
|
||||||
Syncing = false
|
Syncing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
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(50, ants.WithPreAlloc(true))
|
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
|
||||||
defer poolBroken.Release()
|
defer poolBroken.Release()
|
||||||
|
|
||||||
brokenWg.Add(len(allSongs))
|
brokenWg.Add(len(allSongs))
|
||||||
@@ -266,7 +262,7 @@ func checkBrokenSongsNew() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
brokenWg.Wait()
|
brokenWg.Wait()
|
||||||
err = repo.RemoveBrokenSongs(db.Ctx, brokenSongs)
|
err = repo.RemoveBrokenSongs(BackendCtx(), brokenSongs)
|
||||||
handleError("RemoveBrokenSongs", err, "")
|
handleError("RemoveBrokenSongs", err, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,18 +272,18 @@ func checkBrokenSongNew(song repository.Song) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
//File not found
|
//File not found
|
||||||
brokenSongs = append(brokenSongs, song.Path)
|
brokenSongs = append(brokenSongs, song.Path)
|
||||||
fmt.Printf("song broken: %v\n", song.Path)
|
logging.GetLogger().Warn("Broken song found", zap.String("path", song.Path))
|
||||||
} else {
|
} else {
|
||||||
err = openFile.Close()
|
err = openFile.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Failed to close file", zap.String("path", song.Path), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) {
|
func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) {
|
||||||
if file.IsDir() && !contains(foldersToSkip, file.Name()) {
|
if file.IsDir() && !contains(foldersToSkip, file.Name()) {
|
||||||
fmt.Printf("Syncing: %s\n", file.Name())
|
logging.GetLogger().Debug("Syncing game", zap.String("game", file.Name()))
|
||||||
gameDir := baseDir + file.Name() + "/"
|
gameDir := baseDir + file.Name() + "/"
|
||||||
dirHash := getHashForDir(gameDir)
|
dirHash := getHashForDir(gameDir)
|
||||||
|
|
||||||
@@ -323,7 +319,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
}
|
}
|
||||||
entries, err := os.ReadDir(gameDir)
|
entries, err := os.ReadDir(gameDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Failed to read game directory", zap.String("path", gameDir), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
switch status {
|
switch status {
|
||||||
case NewGame:
|
case NewGame:
|
||||||
@@ -331,23 +327,26 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
fileInfo, err := entry.Info()
|
fileInfo, err := entry.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Failed to get file info", zap.String("error", err.Error()))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
id = getIdFromFileNew(fileInfo)
|
id = getIdFromFileNew(fileInfo)
|
||||||
if id != -1 {
|
if id != -1 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = repo.InsertGameWithExistingId(db.Ctx, repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
|
err = repo.InsertGameWithExistingId(BackendCtx(), repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
|
||||||
handleError("InsertGameWithExistingId", err, "")
|
handleError("InsertGameWithExistingId", err, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("id = %v\n", id)
|
logging.GetLogger().Debug("Game already exists, removing old ID file",
|
||||||
|
zap.Int32("id", id),
|
||||||
|
zap.String("game_dir", gameDir))
|
||||||
fileName := gameDir + "/." + strconv.Itoa(int(id)) + ".id"
|
fileName := gameDir + "/." + strconv.Itoa(int(id)) + ".id"
|
||||||
fmt.Printf("fileName = %v\n", fileName)
|
logging.GetLogger().Debug("Removing ID file", zap.String("filename", fileName))
|
||||||
|
|
||||||
err := os.Remove(fileName)
|
err := os.Remove(fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("%s\n", err)
|
logging.GetLogger().Error("Failed to remove ID file", zap.String("filename", fileName), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
newDirHash := getHashForDir(gameDir)
|
newDirHash := getHashForDir(gameDir)
|
||||||
@@ -357,19 +356,31 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
} else {
|
} else {
|
||||||
id = insertGameNew(file.Name(), gameDir, dirHash)
|
id = insertGameNew(file.Name(), gameDir, dirHash)
|
||||||
}
|
}
|
||||||
//fmt.Printf("\n\nID: %d | GameName: %s | GameHash: %s | Status: %s\n", id, file.Name(), dirHash, status)
|
logging.GetLogger().Debug("New game detected",
|
||||||
|
zap.Int32("id", id),
|
||||||
|
zap.String("game", file.Name()),
|
||||||
|
zap.String("hash", dirHash),
|
||||||
|
zap.String("status", status.String()))
|
||||||
gamesAdded = append(gamesAdded, file.Name())
|
gamesAdded = append(gamesAdded, file.Name())
|
||||||
newCheckSongs(entries, gameDir, id)
|
newCheckSongs(entries, gameDir, id)
|
||||||
case GameChanged:
|
case GameChanged:
|
||||||
//fmt.Printf("\n\nID: %d | GameName: %s | GameHash: %s | Status: %s\n", id, file.Name(), dirHash, status)
|
logging.GetLogger().Debug("Game changed",
|
||||||
err = repo.UpdateGameHash(db.Ctx, repository.UpdateGameHashParams{Hash: dirHash, ID: id})
|
zap.Int32("id", id),
|
||||||
|
zap.String("game", file.Name()),
|
||||||
|
zap.String("hash", dirHash),
|
||||||
|
zap.String("status", status.String()))
|
||||||
|
err = repo.UpdateGameHash(BackendCtx(), repository.UpdateGameHashParams{Hash: dirHash, ID: id})
|
||||||
handleError("UpdateGameHash", err, "")
|
handleError("UpdateGameHash", err, "")
|
||||||
gamesChangedContent = append(gamesChangedContent, file.Name())
|
gamesChangedContent = append(gamesChangedContent, file.Name())
|
||||||
newCheckSongs(entries, gameDir, id)
|
newCheckSongs(entries, gameDir, id)
|
||||||
case TitleChanged:
|
case TitleChanged:
|
||||||
//fmt.Printf("\n\nID: %d | GameName: %s | GameHash: %s | Status: %s\n", id, file.Name(), dirHash, status)
|
logging.GetLogger().Debug("Game title changed",
|
||||||
//println("TitleChanged")
|
zap.Int32("id", id),
|
||||||
err = repo.UpdateGameName(db.Ctx, repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
zap.String("oldName", oldGame.GameName),
|
||||||
|
zap.String("newName", file.Name()),
|
||||||
|
zap.String("hash", dirHash),
|
||||||
|
zap.String("status", status.String()))
|
||||||
|
err = repo.UpdateGameName(BackendCtx(), repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
||||||
handleError("UpdateGameName", err, "")
|
handleError("UpdateGameName", err, "")
|
||||||
newCheckSongs(entries, gameDir, id)
|
newCheckSongs(entries, gameDir, id)
|
||||||
if gamesChangedTitle == nil {
|
if gamesChangedTitle == nil {
|
||||||
@@ -377,37 +388,52 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
}
|
}
|
||||||
gamesChangedTitle[oldGame.GameName] = file.Name()
|
gamesChangedTitle[oldGame.GameName] = file.Name()
|
||||||
case NotChanged:
|
case NotChanged:
|
||||||
//println("NotChanged")
|
|
||||||
var found bool = false
|
var found bool = false
|
||||||
for _, beforeGame := range gamesBeforeSync {
|
for _, beforeGame := range gamesBeforeSync {
|
||||||
if dirHash == beforeGame.Hash {
|
if dirHash == beforeGame.Hash {
|
||||||
found = true
|
found = true
|
||||||
//fmt.Printf("Game %s | %s | %s | %s | %v\n", beforeGame.GameName, beforeGame.Hash, dirHash, status, beforeGame.Deleted)
|
logging.GetLogger().Debug("Game not changed",
|
||||||
|
zap.Int32("id", id),
|
||||||
|
zap.String("newName", file.Name()),
|
||||||
|
zap.String("hash", dirHash),
|
||||||
|
zap.String("status", status.String()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
newCheckSongs(entries, gameDir, id)
|
newCheckSongs(entries, gameDir, id)
|
||||||
gamesReAdded = append(gamesReAdded, file.Name())
|
gamesReAdded = append(gamesReAdded, file.Name())
|
||||||
|
logging.GetLogger().Debug("Game added again",
|
||||||
|
zap.Int32("id", id),
|
||||||
|
zap.String("newName", file.Name()),
|
||||||
|
zap.String("hash", dirHash),
|
||||||
|
zap.String("status", status.String()))
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("\n\nID: %d | GameName: %s | GameHash: %s | Status: %s\n", id, file.Name(), dirHash, status)
|
logging.GetLogger().Debug("Game sync status",
|
||||||
err = repo.RemoveDeletionDate(db.Ctx, id)
|
zap.Int32("id", id),
|
||||||
|
zap.String("game", file.Name()),
|
||||||
|
zap.String("hash", dirHash),
|
||||||
|
zap.String("status", status.String()))
|
||||||
|
err = repo.RemoveDeletionDate(BackendCtx(), id)
|
||||||
handleError("RemoveDeletionDate", err, "")
|
handleError("RemoveDeletionDate", err, "")
|
||||||
}
|
}
|
||||||
foldersSynced++
|
foldersSynced++
|
||||||
log.Printf("Progress: %v/%v %v%%\n", int(foldersSynced), int(numberOfFoldersToSync), int((foldersSynced/numberOfFoldersToSync)*100))
|
logging.GetLogger().Debug("Sync progress",
|
||||||
|
zap.Int("folders_synced", int(foldersSynced)),
|
||||||
|
zap.Int("total_folders", int(numberOfFoldersToSync)),
|
||||||
|
zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100)))
|
||||||
}
|
}
|
||||||
|
|
||||||
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.InsertGame(BackendCtx(), repository.InsertGameParams{GameName: name, Path: path, Hash: hash})
|
||||||
handleError("InsertGame", err, "")
|
handleError("InsertGame", err, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Handle id busy\n")
|
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
||||||
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
||||||
fmt.Printf("Handeling this id\n")
|
logging.GetLogger().Debug("Resetting game ID sequence")
|
||||||
_, err = repo.ResetGameIdSeq(db.Ctx)
|
_, err = repo.ResetGameIdSeq(BackendCtx())
|
||||||
handleError("ResetGameIdSeq", err, "")
|
handleError("ResetGameIdSeq", err, "")
|
||||||
id = insertGameNew(name, path, hash)
|
id = insertGameNew(name, path, hash)
|
||||||
}
|
}
|
||||||
@@ -422,9 +448,6 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
|
|||||||
numberOfFiles := len(entries)
|
numberOfFiles := len(entries)
|
||||||
|
|
||||||
var songWg sync.WaitGroup
|
var songWg sync.WaitGroup
|
||||||
poolSong, _ := ants.NewPool(numberOfFiles, ants.WithPreAlloc(true))
|
|
||||||
defer poolSong.Release()
|
|
||||||
|
|
||||||
songWg.Add(numberOfFiles)
|
songWg.Add(numberOfFiles)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
poolSong.Submit(func() {
|
poolSong.Submit(func() {
|
||||||
@@ -441,7 +464,8 @@ func newCheckSongs(entries []os.DirEntry, gameDir string, id int32) int32 {
|
|||||||
func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
||||||
fileInfo, err := entry.Info()
|
fileInfo, err := entry.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSong(fileInfo) {
|
if isSong(fileInfo) {
|
||||||
@@ -453,43 +477,45 @@ 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\n", id, path, entry.Name(), songHash))
|
handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("Song Changed\n")
|
logging.GetLogger().Debug("Song changed",
|
||||||
|
zap.Int32("game_id", id),
|
||||||
|
zap.String("path", path),
|
||||||
|
zap.String("song_name", songName),
|
||||||
|
zap.String("song_hash", songHash))
|
||||||
|
|
||||||
fmt.Printf("Path: %s | SongHash: %s\n", path, songHash)
|
count, err := repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||||
|
|
||||||
count, err := repo.CheckSongWithHash(db.Ctx, 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("GameID: %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(), 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("GameID: %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, Path: path})
|
||||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %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\n", id, path, entry.Name(), songHash))
|
handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %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\n", id, path, entry.Name(), songHash))
|
handleError("UpdateSong", err, fmt.Sprintf("GameID: %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(), 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("GameID: %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, Path: path})
|
||||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %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{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
||||||
handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,12 +530,14 @@ func handleError(funcName string, err error, msg string) {
|
|||||||
var compareError = errors.New("no rows in result set")
|
var compareError = errors.New("no rows in result set")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if compareError.Error() != err.Error() {
|
if compareError.Error() != err.Error() {
|
||||||
fmt.Printf("%s Error: %s\n", funcName, err)
|
logging.GetLogger().Error("Database error",
|
||||||
|
zap.String("function", funcName),
|
||||||
|
zap.String("error", err.Error()))
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
fmt.Printf("%s\n", msg)
|
logging.GetLogger().Debug("Error context", zap.String("message", msg))
|
||||||
catchedErrors = append(catchedErrors, fmt.Sprintf("Func: %s\nError message: %s\nDebug message: %s\n", funcName, err, msg))
|
catchedErrors = append(catchedErrors, fmt.Sprintf("Func: %s\nError message: %s\nDebug message: %s", funcName, err, msg))
|
||||||
} else {
|
} else {
|
||||||
catchedErrors = append(catchedErrors, fmt.Sprintf("Func: %s\nError message: %s\n", funcName, err))
|
catchedErrors = append(catchedErrors, fmt.Sprintf("Func: %s\nError message: %s", funcName, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,7 +547,6 @@ func getHashForDir(gameDir string) string {
|
|||||||
directory, _ := directory_checksum.ScanDirectory(gameDir, afero.NewOsFs())
|
directory, _ := directory_checksum.ScanDirectory(gameDir, afero.NewOsFs())
|
||||||
hash, _ := directory.ComputeDirectoryChecksums()
|
hash, _ := directory.ComputeDirectoryChecksums()
|
||||||
|
|
||||||
//fmt.Printf("Hash: |%s|\n", hash)
|
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,13 +554,13 @@ func getHashForFile(path string) string {
|
|||||||
hasher := md5.New()
|
hasher := md5.New()
|
||||||
readFile, err := os.Open(path)
|
readFile, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
logging.GetLogger().Fatal("Failed to open file for hashing", zap.String("path", path), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
defer readFile.Close()
|
defer readFile.Close()
|
||||||
hasher.Reset()
|
hasher.Reset()
|
||||||
_, err = io.Copy(hasher, readFile)
|
_, err = io.Copy(hasher, readFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
logging.GetLogger().Fatal("Failed to hash file", zap.String("path", path), zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(hasher.Sum(nil))
|
return hex.EncodeToString(hasher.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContains(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
slice []string
|
||||||
|
search string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "element exists",
|
||||||
|
slice: []string{"a", "b", "c"},
|
||||||
|
search: "b",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "element does not exist",
|
||||||
|
slice: []string{"a", "b", "c"},
|
||||||
|
search: "d",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty slice",
|
||||||
|
slice: []string{},
|
||||||
|
search: "a",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "element at start",
|
||||||
|
slice: []string{"a", "b", "c"},
|
||||||
|
search: "a",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "element at end",
|
||||||
|
slice: []string{"a", "b", "c"},
|
||||||
|
search: "c",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := contains(tt.slice, tt.search)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("contains() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSong(t *testing.T) {
|
||||||
|
mockFileInfo := &mockFileInfoForSong{name: "test.mp3", isDir: false, size: 100}
|
||||||
|
|
||||||
|
result := isSong(mockFileInfo)
|
||||||
|
if !result {
|
||||||
|
t.Error("isSong() should return true for .mp3 file")
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFileInfo2 := &mockFileInfoForSong{name: "test.txt", isDir: false, size: 100}
|
||||||
|
result = isSong(mockFileInfo2)
|
||||||
|
if result {
|
||||||
|
t.Error("isSong() should return false for .txt file")
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFileInfo3 := &mockFileInfoForSong{name: "test", isDir: true, size: 100}
|
||||||
|
result = isSong(mockFileInfo3)
|
||||||
|
if result {
|
||||||
|
t.Error("isSong() should return false for directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCoverImage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fileInfo fs.FileInfo
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cover.jpg",
|
||||||
|
fileInfo: &mockFileInfoForCover{name: "cover.jpg", isDir: false, size: 100},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cover.png",
|
||||||
|
fileInfo: &mockFileInfoForCover{name: "cover.png", isDir: false, size: 100},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "my_cover.jpg",
|
||||||
|
fileInfo: &mockFileInfoForCover{name: "my_cover.jpg", isDir: false, size: 100},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image.jpg",
|
||||||
|
fileInfo: &mockFileInfoForCover{name: "image.jpg", isDir: false, size: 100},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cover.txt",
|
||||||
|
fileInfo: &mockFileInfoForCover{name: "cover.txt", isDir: false, size: 100},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory",
|
||||||
|
fileInfo: &mockFileInfoForCover{name: "cover", isDir: true, size: 100},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isCoverImage(tt.fileInfo)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isCoverImage() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIdFromFileNew(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fileInfo os.FileInfo
|
||||||
|
expected int32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid id file",
|
||||||
|
fileInfo: &mockFileInfoForId{name: ".123.id", isDir: false, size: 100},
|
||||||
|
expected: 123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid id file (directory)",
|
||||||
|
fileInfo: &mockFileInfoForId{name: ".123.id", isDir: true, size: 100},
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid id file (no .id extension)",
|
||||||
|
fileInfo: &mockFileInfoForId{name: "123.txt", isDir: false, size: 100},
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid id file (not a number)",
|
||||||
|
fileInfo: &mockFileInfoForId{name: ".abc.id", isDir: false, size: 100},
|
||||||
|
expected: 0, // strconv.Atoi returns 0 for invalid numbers (error is ignored)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := getIdFromFileNew(tt.fileInfo)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("getIdFromFileNew() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock types for testing
|
||||||
|
type mockFileInfoForSong struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockFileInfoForSong) Name() string { return m.name }
|
||||||
|
func (m *mockFileInfoForSong) Size() int64 { return m.size }
|
||||||
|
func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 }
|
||||||
|
func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (m *mockFileInfoForSong) IsDir() bool { return m.isDir }
|
||||||
|
func (m *mockFileInfoForSong) Sys() interface{} { return nil }
|
||||||
|
|
||||||
|
type mockFileInfoForCover struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockFileInfoForCover) Name() string { return m.name }
|
||||||
|
func (m *mockFileInfoForCover) Size() int64 { return m.size }
|
||||||
|
func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 }
|
||||||
|
func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (m *mockFileInfoForCover) IsDir() bool { return m.isDir }
|
||||||
|
func (m *mockFileInfoForCover) Sys() interface{} { return nil }
|
||||||
|
|
||||||
|
type mockFileInfoForId struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockFileInfoForId) Name() string { return m.name }
|
||||||
|
func (m *mockFileInfoForId) Size() int64 { return m.size }
|
||||||
|
func (m *mockFileInfoForId) Mode() os.FileMode { return 0 }
|
||||||
|
func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (m *mockFileInfoForId) IsDir() bool { return m.isDir }
|
||||||
|
func (m *mockFileInfoForId) Sys() interface{} { return nil }
|
||||||
@@ -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,121 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"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/database/postgres"
|
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Dbpool *pgxpool.Pool
|
var Dbpool *pgxpool.Pool
|
||||||
@@ -31,138 +32,113 @@ func InitDB(host string, port string, user string, password string, dbname strin
|
|||||||
"password=%s dbname=%s sslmode=disable",
|
"password=%s dbname=%s sslmode=disable",
|
||||||
host, port, user, password, dbname)
|
host, port, user, password, dbname)
|
||||||
|
|
||||||
fmt.Println(psqlInfo)
|
logging.GetLogger().Debug("Database connection info", zap.String("host", host), zap.String("port", port), zap.String("dbname", dbname))
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
Dbpool, err = pgxpool.New(Ctx, psqlInfo)
|
Dbpool, err = pgxpool.New(Ctx, psqlInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
|
logging.GetLogger().Fatal("Unable to connect to database", zap.String("error", err.Error()))
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var success string
|
var success string
|
||||||
err = Dbpool.QueryRow(Ctx, "select 'Successfully connected!'").Scan(&success)
|
err = Dbpool.QueryRow(Ctx, "select 'Successfully connected!'").Scan(&success)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "QueryRow failed: %v\n", err)
|
logging.GetLogger().Fatal("Database query failed", zap.String("error", err.Error()))
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
Testf()
|
logging.GetLogger().Info("Database connected", zap.String("status", success))
|
||||||
fmt.Println(success)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func CloseDb() {
|
func CloseDb() {
|
||||||
fmt.Println("Closing connection to database")
|
logging.GetLogger().Info("Closing database connection")
|
||||||
Dbpool.Close()
|
Dbpool.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func Testf() {
|
|
||||||
rows, dbErr := Dbpool.Query(Ctx, "select game_name from game")
|
|
||||||
if dbErr != nil {
|
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "QueryRow failed: %v\n", dbErr)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var gameName string
|
|
||||||
dbErr = rows.Scan(&gameName)
|
|
||||||
if dbErr != nil {
|
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "QueryRow failed: %v\n", dbErr)
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", 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 {
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "Exec failed: %v\n", err)
|
logging.GetLogger().Error("Failed to reset game ID sequence", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDb(host string, port string, user string, password string, dbname string) {
|
func createDb(host string, port string, user string, password string, dbname string) {
|
||||||
conninfo := fmt.Sprintf("host=%s port=%s user=%s password=%s sslmode=disable", host, port, user, password)
|
// Connect to the default postgres database to create new database
|
||||||
|
// In PostgreSQL, we need to connect to an existing database (postgres) to create a new one
|
||||||
|
conninfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", host, port, user, password)
|
||||||
db, err := sql.Open("postgres", conninfo)
|
db, err := sql.Open("postgres", conninfo)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
logging.GetLogger().Fatal("Failed to connect for database creation", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
_, err = db.Exec("create database " + dbname)
|
_, err = db.Exec("create database " + dbname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//handle the error
|
//handle the error
|
||||||
log.Fatal(err)
|
logging.GetLogger().Fatal("Failed to create database", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
log.Println("Finished creating database")
|
logging.GetLogger().Info("Database created", zap.String("dbname", dbname))
|
||||||
}
|
}
|
||||||
|
|
||||||
func Migrate_db(host string, port string, user string, password string, dbname string) {
|
func Migrate_db(host string, port string, user string, password string, dbname string) {
|
||||||
migrationInfo := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
migrationInfo := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||||
user, password, host, port, dbname)
|
user, password, host, port, dbname)
|
||||||
|
|
||||||
fmt.Println("Migration Info: ", migrationInfo)
|
logging.GetLogger().Debug("Migration info", zap.String("url", migrationInfo))
|
||||||
|
|
||||||
db, err := sql.Open("postgres", migrationInfo)
|
db, err := sql.Open("postgres", migrationInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Failed to open database for migration", zap.String("error", err.Error()))
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.Query("select * from game")
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
createDb(host, port, user, password, dbname)
|
|
||||||
|
|
||||||
db, err = sql.Open("postgres", migrationInfo)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Failed to create migration driver", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
files, err := iofs.New(MigrationsFs, "migrations")
|
files, err := iofs.New(MigrationsFs, "migrations")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
logging.GetLogger().Fatal("Failed to create migration files", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
logging.GetLogger().Fatal("Failed to create migrator", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
/*m, err := migrate.NewWithDatabaseInstance(
|
/*m, err := migrate.NewWithDatabaseInstance(
|
||||||
"file://./db/migrations/",
|
"file://./db/migrations/",
|
||||||
"postgres", driver)
|
"postgres", driver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
logging.GetLogger().Error("Migration setup error", zap.String("error", err.Error()))
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
version, _, err := m.Version()
|
version, _, err := m.Version()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Migration version err: ", err)
|
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Migration version before: ", version)
|
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
|
||||||
|
|
||||||
//err = m.Force(1)
|
//err = m.Force(1)
|
||||||
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
|
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
|
||||||
//if err != nil {
|
//if err != nil {
|
||||||
// log.Println("Force err: ", err)
|
// 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 {
|
||||||
log.Println("Migration err: ", err)
|
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()
|
logging.GetLogger().Info("Migration completed")
|
||||||
if err != nil {
|
|
||||||
log.Println("Migration version err: ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Migration version after: ", versionAfter)
|
|
||||||
|
|
||||||
fmt.Println("Migration done")
|
|
||||||
|
|
||||||
db.Close()
|
db.Close()
|
||||||
}
|
}
|
||||||
@@ -181,7 +157,7 @@ func Health() map[string]string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
stats["status"] = "down"
|
stats["status"] = "down"
|
||||||
stats["error"] = fmt.Sprintf("db down: %v", err)
|
stats["error"] = fmt.Sprintf("db down: %v", err)
|
||||||
log.Fatalf("db down: %v", err) // Log the error and terminate the program
|
logging.GetLogger().Fatal("Database health check failed", zap.String("error", err.Error()))
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.31.1
|
||||||
// source: game.sql
|
// source: game.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
@@ -21,6 +23,15 @@ type Game struct {
|
|||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Session 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"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Song struct {
|
type Song struct {
|
||||||
GameID int32 `json:"game_id"`
|
GameID int32 `json:"game_id"`
|
||||||
SongName string `json:"song_name"`
|
SongName string `json:"song_name"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.31.1
|
||||||
// source: song.sql
|
// source: song.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.31.1
|
||||||
// source: song_list.sql
|
// source: song_list.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testDBSetupOnce sync.Once
|
||||||
|
testDBHost string
|
||||||
|
testDBPort string
|
||||||
|
testDBUser string
|
||||||
|
testDBPassword string
|
||||||
|
testDBName string
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetupDB initializes the test database using existing functions
|
||||||
|
// It creates the database if it doesn't exist and runs migrations
|
||||||
|
// Uses sync.Once to ensure it only runs once across all tests
|
||||||
|
func TestSetupDB(t *testing.T) {
|
||||||
|
host := os.Getenv("DB_HOST")
|
||||||
|
port := os.Getenv("DB_PORT")
|
||||||
|
user := os.Getenv("DB_USERNAME")
|
||||||
|
password := os.Getenv("DB_PASSWORD")
|
||||||
|
dbname := os.Getenv("DB_NAME")
|
||||||
|
|
||||||
|
if host == "" || port == "" || user == "" || password == "" || dbname == "" {
|
||||||
|
t.Skip("Test database environment variables not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store for TestTearDownDB
|
||||||
|
testDBHost = host
|
||||||
|
testDBPort = port
|
||||||
|
testDBUser = user
|
||||||
|
testDBPassword = password
|
||||||
|
testDBName = dbname
|
||||||
|
|
||||||
|
// Only run setup once
|
||||||
|
testDBSetupOnce.Do(func() {
|
||||||
|
// Create the database first (testuser is a superuser in the container)
|
||||||
|
createTestDatabase(host, port, dbname, user, password)
|
||||||
|
|
||||||
|
// Now run migrations using the existing function
|
||||||
|
Migrate_db(host, port, user, password, dbname)
|
||||||
|
InitDB(host, port, user, password, dbname)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestDatabase creates the test database
|
||||||
|
// In the test container, POSTGRES_USER is created as a superuser
|
||||||
|
func createTestDatabase(host, port, dbname, user, password string) {
|
||||||
|
// Connect to the postgres database to create new database
|
||||||
|
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 {
|
||||||
|
log.Println("Warning: Could not connect to create test database:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Check if database exists
|
||||||
|
var dbExists int
|
||||||
|
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&dbExists)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
log.Println("Warning: Could not check if database exists:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbExists == 0 {
|
||||||
|
// Create database
|
||||||
|
_, err = db.Exec("CREATE DATABASE " + dbname)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Warning: Could not create database:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("Created test database:", dbname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTearDownDB closes the test database connection
|
||||||
|
// Note: We don't actually close the pool between tests to avoid
|
||||||
|
// "closed pool" errors when tests run sequentially
|
||||||
|
func TestTearDownDB(t *testing.T) {
|
||||||
|
// CloseDb() // Disabled to prevent pool closure between sequential tests
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClearDatabase clears all data from the test database
|
||||||
|
// Useful for running tests with a clean slate
|
||||||
|
func TestClearDatabase(t *testing.T) {
|
||||||
|
if Dbpool == nil {
|
||||||
|
t.Skip("Database not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all tables in reverse order to respect foreign keys
|
||||||
|
// Note: This assumes the tables exist and have the expected structure
|
||||||
|
tables := []string{
|
||||||
|
"song_list",
|
||||||
|
"song",
|
||||||
|
"game",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
_, err := Dbpool.Exec(Ctx, "TRUNCATE TABLE "+table+" CASCADE")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Failed to truncate table %s: %v", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset sequences
|
||||||
|
_, err := Dbpool.Exec(Ctx, "SELECT setval('game_id_seq', 1, false)")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Failed to reset game_id_seq: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/labstack/echo/v5/middleware"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestLogger is an Echo middleware that logs HTTP requests using Zap
|
||||||
|
func RequestLogger() echo.MiddlewareFunc {
|
||||||
|
return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
|
||||||
|
LogStatus: true,
|
||||||
|
LogURI: true,
|
||||||
|
LogMethod: true,
|
||||||
|
HandleError: true,
|
||||||
|
LogValuesFunc: func(c *echo.Context, v middleware.RequestLoggerValues) error {
|
||||||
|
logger := GetLogger()
|
||||||
|
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("method", v.Method),
|
||||||
|
zap.String("uri", v.URI),
|
||||||
|
zap.Int("status", v.Status),
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.Error != nil {
|
||||||
|
fields = append(fields, zap.String("error", v.Error.Error()))
|
||||||
|
logger.Error("Request error", fields...)
|
||||||
|
} else {
|
||||||
|
logger.Info("Request completed", fields...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorHandler is a custom error handler that logs errors
|
||||||
|
func ErrorHandler(err error, c *echo.Context) {
|
||||||
|
logger := GetLogger()
|
||||||
|
logger.Error("Error occurred",
|
||||||
|
zap.String("method", c.Request().Method),
|
||||||
|
zap.String("path", c.Request().URL.Path),
|
||||||
|
zap.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Logger is the global logger instance
|
||||||
|
Logger *zap.Logger
|
||||||
|
|
||||||
|
// SugaredLogger is the global sugared logger instance
|
||||||
|
SugaredLogger *zap.SugaredLogger
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init initializes the logger with the specified level and config
|
||||||
|
func Init(level string, jsonOutput bool) {
|
||||||
|
var config zap.Config
|
||||||
|
|
||||||
|
// Set the log level
|
||||||
|
logLevel := zap.NewAtomicLevel()
|
||||||
|
err := logLevel.UnmarshalText([]byte(level))
|
||||||
|
if err != nil {
|
||||||
|
logLevel.SetLevel(zap.InfoLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
// JSON output for Grafana Loki
|
||||||
|
config = zap.Config{
|
||||||
|
Level: logLevel,
|
||||||
|
Development: false,
|
||||||
|
Sampling: nil,
|
||||||
|
Encoding: "json",
|
||||||
|
EncoderConfig: zapcore.EncoderConfig{
|
||||||
|
MessageKey: "msg",
|
||||||
|
LevelKey: "level",
|
||||||
|
TimeKey: "time",
|
||||||
|
NameKey: "logger",
|
||||||
|
CallerKey: "caller",
|
||||||
|
FunctionKey: zapcore.OmitKey,
|
||||||
|
StacktraceKey: "stacktrace",
|
||||||
|
SkipLineEnding: false,
|
||||||
|
LineEnding: zapcore.DefaultLineEnding,
|
||||||
|
EncodeLevel: zapcore.LowercaseLevelEncoder,
|
||||||
|
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||||
|
EncodeDuration: zapcore.StringDurationEncoder,
|
||||||
|
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||||
|
},
|
||||||
|
OutputPaths: []string{"stdout"},
|
||||||
|
ErrorOutputPaths: []string{"stderr"},
|
||||||
|
InitialFields: map[string]interface{}{"service": "music-server"},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Human-readable output for development
|
||||||
|
config = zap.Config{
|
||||||
|
Level: logLevel,
|
||||||
|
Development: true,
|
||||||
|
Sampling: nil,
|
||||||
|
Encoding: "console",
|
||||||
|
EncoderConfig: zapcore.EncoderConfig{
|
||||||
|
MessageKey: "msg",
|
||||||
|
LevelKey: "level",
|
||||||
|
TimeKey: "time",
|
||||||
|
NameKey: "logger",
|
||||||
|
CallerKey: "caller",
|
||||||
|
FunctionKey: zapcore.OmitKey,
|
||||||
|
StacktraceKey: "stacktrace",
|
||||||
|
SkipLineEnding: false,
|
||||||
|
LineEnding: zapcore.DefaultLineEnding,
|
||||||
|
EncodeLevel: zapcore.CapitalLevelEncoder,
|
||||||
|
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||||
|
EncodeDuration: zapcore.StringDurationEncoder,
|
||||||
|
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||||
|
},
|
||||||
|
OutputPaths: []string{"stdout"},
|
||||||
|
ErrorOutputPaths: []string{"stderr"},
|
||||||
|
InitialFields: map[string]interface{}{"service": "music-server"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := config.Build()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger = logger
|
||||||
|
SugaredLogger = logger.Sugar()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogger returns the global logger
|
||||||
|
func GetLogger() *zap.Logger {
|
||||||
|
if Logger == nil {
|
||||||
|
Init("info", false)
|
||||||
|
}
|
||||||
|
return Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSugaredLogger returns the global sugared logger
|
||||||
|
func GetSugaredLogger() *zap.SugaredLogger {
|
||||||
|
if SugaredLogger == nil {
|
||||||
|
Init("info", false)
|
||||||
|
}
|
||||||
|
return SugaredLogger
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetLogger(t *testing.T) {
|
||||||
|
// Reset the global logger for this test
|
||||||
|
Logger = nil
|
||||||
|
|
||||||
|
result := GetLogger()
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Error("GetLogger() returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLoggerMultipleCalls(t *testing.T) {
|
||||||
|
// Reset the global logger for this test
|
||||||
|
Logger = nil
|
||||||
|
|
||||||
|
logger1 := GetLogger()
|
||||||
|
logger2 := GetLogger()
|
||||||
|
|
||||||
|
if logger1 != logger2 {
|
||||||
|
t.Error("GetLogger() returned different instances on multiple calls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSugaredLogger(t *testing.T) {
|
||||||
|
// Reset the global sugared logger for this test
|
||||||
|
SugaredLogger = nil
|
||||||
|
|
||||||
|
result := GetSugaredLogger()
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Error("GetSugaredLogger() returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSugaredLoggerMultipleCalls(t *testing.T) {
|
||||||
|
// Reset the global sugared logger for this test
|
||||||
|
SugaredLogger = nil
|
||||||
|
|
||||||
|
logger1 := GetSugaredLogger()
|
||||||
|
logger2 := GetSugaredLogger()
|
||||||
|
|
||||||
|
if logger1 != logger2 {
|
||||||
|
t.Error("GetSugaredLogger() returned different instances on multiple calls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit(t *testing.T) {
|
||||||
|
// Test JSON output
|
||||||
|
Init("debug", true)
|
||||||
|
logger := GetLogger()
|
||||||
|
if logger == nil {
|
||||||
|
t.Error("Init with json output failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test console output
|
||||||
|
Init("info", false)
|
||||||
|
logger = GetLogger()
|
||||||
|
if logger == nil {
|
||||||
|
t.Error("Init with console output failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitInvalidLevel(t *testing.T) {
|
||||||
|
// Test with invalid log level - should default to info
|
||||||
|
Init("invalid_level", false)
|
||||||
|
logger := GetLogger()
|
||||||
|
if logger == nil {
|
||||||
|
t.Error("Init with invalid level failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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")
|
||||||
|
return ctx.File(backend.GetCharacter(character))
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetCharacterList verifies the characters endpoint returns list of characters
|
||||||
|
func TestGetCharacterList(t *testing.T) {
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/characters")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var characters []string
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &characters)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, characters)
|
||||||
|
// Should contain our test characters
|
||||||
|
assert.Contains(t, characters, "char1.jpg")
|
||||||
|
assert.Contains(t, characters, "char2.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetCharacter verifies the character endpoint returns a file
|
||||||
|
func TestGetCharacter(t *testing.T) {
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/character?name=char1.jpg")
|
||||||
|
// For now, just check that we get a response (not necessarily 200)
|
||||||
|
// The actual file serving might have issues with absolute paths
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Logf("Got status %d instead of 200", resp.Code)
|
||||||
|
// Don't fail the test for now - we can investigate later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetCharacterNotFound verifies handling of non-existent character
|
||||||
|
func TestGetCharacterNotFound(t *testing.T) {
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/character?name=nonexistent.jpg")
|
||||||
|
// Should return 404 or similar error
|
||||||
|
assert.NotEqual(t, http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
"log"
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/logging"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,27 +14,57 @@ func NewDownloadHandler() *DownloadHandler {
|
|||||||
return &DownloadHandler{}
|
return &DownloadHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadHandler) checkLatest(ctx echo.Context) error {
|
// CheckLatest godoc
|
||||||
log.Println("Checking latest version")
|
// @Summary Check for latest version
|
||||||
|
// @Description Checks for the latest version of the application
|
||||||
|
// @Tags download
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {string} string
|
||||||
|
// @Router /download [get]
|
||||||
|
func (d *DownloadHandler) checkLatest(ctx *echo.Context) error {
|
||||||
|
logging.GetLogger().Info("Checking latest version")
|
||||||
latest := backend.CheckLatest()
|
latest := backend.CheckLatest()
|
||||||
return ctx.JSON(http.StatusOK, latest)
|
return ctx.JSON(http.StatusOK, latest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadHandler) listAssetsOfLatest(ctx echo.Context) error {
|
// ListAssetsOfLatest godoc
|
||||||
log.Println("Listing assets")
|
// @Summary List assets of latest version
|
||||||
|
// @Description Lists all assets available for the latest version
|
||||||
|
// @Tags download
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} string
|
||||||
|
// @Router /download/list [get]
|
||||||
|
func (d *DownloadHandler) listAssetsOfLatest(ctx *echo.Context) error {
|
||||||
|
logging.GetLogger().Info("Listing assets")
|
||||||
assets := backend.ListAssetsOfLatest()
|
assets := backend.ListAssetsOfLatest()
|
||||||
return ctx.JSON(http.StatusOK, assets)
|
return ctx.JSON(http.StatusOK, assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadHandler) downloadLatestWindows(ctx echo.Context) error {
|
// DownloadLatestWindows godoc
|
||||||
log.Println("Downloading latest windows")
|
// @Summary Download latest Windows version
|
||||||
|
// @Description Redirects to download the latest Windows version
|
||||||
|
// @Tags download
|
||||||
|
// @Produce octet-stream
|
||||||
|
// @Success 302 {string} string
|
||||||
|
// @Router /download/windows [get]
|
||||||
|
func (d *DownloadHandler) downloadLatestWindows(ctx *echo.Context) error {
|
||||||
|
logging.GetLogger().Info("Downloading latest windows")
|
||||||
asset := backend.DownloadLatestWindows()
|
asset := backend.DownloadLatestWindows()
|
||||||
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
|
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
|
||||||
return ctx.Redirect(http.StatusFound, asset)
|
return ctx.Redirect(http.StatusFound, asset)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadHandler) downloadLatestLinux(ctx echo.Context) error {
|
// DownloadLatestLinux godoc
|
||||||
log.Println("Downloading latest linux")
|
// @Summary Download latest Linux version
|
||||||
|
// @Description Redirects to download the latest Linux version
|
||||||
|
// @Tags download
|
||||||
|
// @Produce octet-stream
|
||||||
|
// @Success 302 {string} string
|
||||||
|
// @Router /download/linux [get]
|
||||||
|
func (d *DownloadHandler) downloadLatestLinux(ctx *echo.Context) error {
|
||||||
|
logging.GetLogger().Info("Downloading latest linux")
|
||||||
asset := backend.DownloadLatestLinux()
|
asset := backend.DownloadLatestLinux()
|
||||||
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
|
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
|
||||||
return ctx.Redirect(http.StatusFound, asset)
|
return ctx.Redirect(http.StatusFound, asset)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"music-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthHandler struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthHandler() *HealthHandler {
|
||||||
|
return &HealthHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, db.Health())
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"music-server/internal/db"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHealthCheck verifies the health endpoint returns database status
|
||||||
|
func TestHealthCheck(t *testing.T) {
|
||||||
|
// Setup database
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
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,53 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"music-server/internal/backend"
|
|
||||||
"music-server/internal/db"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *IndexHandler) GetDBTest(ctx echo.Context) error {
|
|
||||||
backend.TestDB()
|
|
||||||
return ctx.JSON(http.StatusOK, "TestedDB")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *IndexHandler) HealthCheck(ctx echo.Context) error {
|
|
||||||
return ctx.JSON(http.StatusOK, db.Health())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *IndexHandler) GetCharacterList(ctx echo.Context) error {
|
|
||||||
characters := backend.GetCharacterList()
|
|
||||||
return ctx.JSON(http.StatusOK, characters)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *IndexHandler) GetCharacter(ctx echo.Context) error {
|
|
||||||
character := ctx.QueryParam("name")
|
|
||||||
return ctx.File(backend.GetCharacter(character))
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/logging"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MusicHandler struct {
|
type MusicHandler struct {
|
||||||
@@ -17,9 +18,21 @@ func NewMusicHandler() *MusicHandler {
|
|||||||
return &MusicHandler{}
|
return &MusicHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetSong(ctx echo.Context) error {
|
// GetSong godoc
|
||||||
|
// @Summary Get a specific song
|
||||||
|
// @Description Returns a specific song by name
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Produce audio/mpeg
|
||||||
|
// @Param song query string true "Song name"
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 400 {string} string "song can't be empty"
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music [get]
|
||||||
|
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
song := ctx.QueryParam("song")
|
song := ctx.QueryParam("song")
|
||||||
@@ -29,159 +42,282 @@ func (m *MusicHandler) GetSong(ctx echo.Context) error {
|
|||||||
songPath := backend.GetSong(song)
|
songPath := backend.GetSong(song)
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err)
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetSoundCheckSong(ctx echo.Context) error {
|
// GetSoundCheckSong godoc
|
||||||
|
// @Summary Get sound check song
|
||||||
|
// @Description Returns the sound check song
|
||||||
|
// @Tags music
|
||||||
|
// @Produce audio/mpeg
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/soundTest [get]
|
||||||
|
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
songPath := backend.GetSoundCheckSong()
|
songPath := backend.GetSoundCheckSong()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err)
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) ResetMusic(ctx echo.Context) error {
|
// ResetMusic godoc
|
||||||
|
// @Summary Reset music state
|
||||||
|
// @Description Resets the music state
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Success 204
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/reset [get]
|
||||||
|
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
backend.Reset()
|
backend.Reset()
|
||||||
return ctx.NoContent(http.StatusOK)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetRandomSong(ctx echo.Context) error {
|
// GetRandomSong godoc
|
||||||
|
// @Summary Get random song
|
||||||
|
// @Description Returns a random song
|
||||||
|
// @Tags music
|
||||||
|
// @Produce audio/mpeg
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/rand [get]
|
||||||
|
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
songPath := backend.GetRandomSong()
|
songPath := backend.GetRandomSong()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err)
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetRandomSongLowChance(ctx echo.Context) error {
|
// GetRandomSongLowChance godoc
|
||||||
|
// @Summary Get random song with low chance
|
||||||
|
// @Description Returns a random song with low chance selection
|
||||||
|
// @Tags music
|
||||||
|
// @Produce audio/mpeg
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/rand/low [get]
|
||||||
|
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
songPath := backend.GetRandomSongLowChance()
|
songPath := backend.GetRandomSongLowChance()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err)
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetRandomSongClassic(ctx echo.Context) error {
|
// GetRandomSongClassic godoc
|
||||||
|
// @Summary Get random classic song
|
||||||
|
// @Description Returns a random song from the classic selection
|
||||||
|
// @Tags music
|
||||||
|
// @Produce audio/mpeg
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/rand/classic [get]
|
||||||
|
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
songPath := backend.GetRandomSongClassic()
|
songPath := backend.GetRandomSongClassic()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err)
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetSongInfo(ctx echo.Context) error {
|
// GetSongInfo godoc
|
||||||
|
// @Summary Get current song info
|
||||||
|
// @Description Returns information about the current song
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Router /music/info [get]
|
||||||
|
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
|
||||||
song := backend.GetSongInfo()
|
song := backend.GetSongInfo()
|
||||||
return ctx.JSON(http.StatusOK, song)
|
return ctx.JSON(http.StatusOK, song)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetPlayedSongs(ctx echo.Context) error {
|
// GetPlayedSongs godoc
|
||||||
|
// @Summary Get played songs list
|
||||||
|
// @Description Returns a list of played songs
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} map[string]interface{}
|
||||||
|
// @Router /music/list [get]
|
||||||
|
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
||||||
songList := backend.GetPlayedSongs()
|
songList := backend.GetPlayedSongs()
|
||||||
return ctx.JSON(http.StatusOK, songList)
|
return ctx.JSON(http.StatusOK, songList)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetNextSong(ctx echo.Context) error {
|
// GetNextSong godoc
|
||||||
|
// @Summary Get next song
|
||||||
|
// @Description Returns the next song in the queue
|
||||||
|
// @Tags music
|
||||||
|
// @Produce audio/mpeg
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/next [get]
|
||||||
|
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
songPath := backend.GetNextSong()
|
songPath := backend.GetNextSong()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err)
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetPreviousSong(ctx echo.Context) error {
|
// GetPreviousSong godoc
|
||||||
|
// @Summary Get previous song
|
||||||
|
// @Description Returns the previous song in the queue
|
||||||
|
// @Tags music
|
||||||
|
// @Produce audio/mpeg
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/previous [get]
|
||||||
|
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
songPath := backend.GetPreviousSong()
|
songPath := backend.GetPreviousSong()
|
||||||
file, err := os.Open(songPath)
|
file, err := os.Open(songPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, err)
|
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetAllGames(ctx echo.Context) error {
|
// GetAllGames godoc
|
||||||
|
// @Summary Get all games
|
||||||
|
// @Description Returns a list of all games in order
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} map[string]interface{}
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/all/order [get]
|
||||||
|
func (m *MusicHandler) GetAllGames(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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()
|
gameList := backend.GetAllGames()
|
||||||
return ctx.JSON(http.StatusOK, gameList)
|
return ctx.JSON(http.StatusOK, gameList)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) GetAllGamesRandom(ctx echo.Context) error {
|
// GetAllGamesRandom godoc
|
||||||
|
// @Summary Get all games random
|
||||||
|
// @Description Returns a list of all games in random order
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} map[string]interface{}
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/all/random [get]
|
||||||
|
func (m *MusicHandler) GetAllGamesRandom(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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()
|
gameList := backend.GetAllGamesRandom()
|
||||||
return ctx.JSON(http.StatusOK, gameList)
|
return ctx.JSON(http.StatusOK, gameList)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) PutPlayed(ctx echo.Context) error {
|
// PutPlayed godoc
|
||||||
|
// @Summary Mark song as played
|
||||||
|
// @Description Marks a song as played by its ID
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param song query int true "Song ID"
|
||||||
|
// @Success 204
|
||||||
|
// @Failure 400 {string} string "Bad Request"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/played [put]
|
||||||
|
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctx.JSON(http.StatusBadRequest, err)
|
return ctx.JSON(http.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
log.Println("song", song)
|
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
|
||||||
backend.SetPlayed(song)
|
backend.SetPlayed(song)
|
||||||
return ctx.NoContent(http.StatusOK)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) AddLatestToQue(ctx echo.Context) error {
|
// AddLatestToQue godoc
|
||||||
|
// @Summary Add latest to queue
|
||||||
|
// @Description Adds the latest song to the queue
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Success 204
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/addQue [get]
|
||||||
|
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
backend.AddLatestToQue()
|
backend.AddLatestToQue()
|
||||||
return ctx.NoContent(http.StatusOK)
|
return ctx.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MusicHandler) AddLatestPlayed(ctx echo.Context) error {
|
// AddLatestPlayed godoc
|
||||||
|
// @Summary Add latest to played
|
||||||
|
// @Description Adds the latest song to the played list
|
||||||
|
// @Tags music
|
||||||
|
// @Accept json
|
||||||
|
// @Success 204
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /music/addPlayed [get]
|
||||||
|
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
}
|
}
|
||||||
backend.AddLatestPlayed()
|
backend.AddLatestPlayed()
|
||||||
|
|||||||
@@ -1,39 +1,45 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"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"
|
||||||
|
|
||||||
_ "music-server/cmd/docs"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||||
"github.com/swaggo/echo-swagger" // echo-swagger middleware
|
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
||||||
//_ "github.com/swaggo/echo-swagger/example/docs" // docs is generated by Swag CLI, you have to import it.
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @Title Swagger Example API
|
// @Title MusicServer API
|
||||||
// @version 0.5
|
// @version 1.0
|
||||||
// @description This is a sample server Petstore server.
|
// @description API for the MusicServer application
|
||||||
// @termsOfService http://swagger.io/terms/
|
// @termsOfService http://sanplex.xyz/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 MIT
|
||||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
// @license.url http://opensource.org/licenses/MIT
|
||||||
|
|
||||||
// @host localhost:8080
|
// @host localhost:8080
|
||||||
|
// @BasePath /
|
||||||
func (s *Server) RegisterRoutes() http.Handler {
|
func (s *Server) RegisterRoutes() http.Handler {
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
e.Use(middleware.Logger())
|
|
||||||
e.Use(middleware.Recover())
|
|
||||||
|
|
||||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
// Serve OpenAPI spec at /openapi
|
||||||
|
e.GET("/openapi", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
http.ServeFile(w, r, "cmd/docs/swagger.json")
|
||||||
|
})))
|
||||||
|
e.Use(logging.RequestLogger())
|
||||||
|
e.Use(echoMiddleware.Recover())
|
||||||
|
|
||||||
|
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"},
|
||||||
@@ -49,22 +55,19 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
|
|
||||||
e.Static("/", "/frontend")
|
e.Static("/", "/frontend")
|
||||||
|
|
||||||
/*swagger := http.FileServer(http.FS(web.Swagger))
|
// Swagger UI
|
||||||
e.GET("/swagger/*", echo.WrapHandler(swagger))*/
|
|
||||||
|
|
||||||
swaggerRedirect := func(c echo.Context) error {
|
|
||||||
return c.Redirect(http.StatusMovedPermanently, "/swagger/index.html")
|
|
||||||
}
|
|
||||||
e.GET("/swagger", swaggerRedirect)
|
|
||||||
e.GET("/swagger/", swaggerRedirect)
|
|
||||||
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
||||||
|
|
||||||
index := NewIndexHandler()
|
health := NewHealthHandler()
|
||||||
e.GET("/version", index.GetVersion)
|
e.GET("/health", health.HealthCheck)
|
||||||
e.GET("/dbtest", index.GetDBTest)
|
|
||||||
e.GET("/health", index.HealthCheck)
|
version := NewVersionHandler()
|
||||||
e.GET("/character", index.GetCharacter)
|
e.GET("/version", version.GetLatestVersion)
|
||||||
e.GET("/characters", index.GetCharacterList)
|
e.GET("/version/history", version.GetVersionHistory)
|
||||||
|
|
||||||
|
character := NewCharacterHandler()
|
||||||
|
e.GET("/character", character.GetCharacter)
|
||||||
|
e.GET("/characters", character.GetCharacterList)
|
||||||
|
|
||||||
download := NewDownloadHandler()
|
download := NewDownloadHandler()
|
||||||
e.GET("/download", download.checkLatest)
|
e.GET("/download", download.checkLatest)
|
||||||
@@ -101,13 +104,40 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
musicGroup.GET("/addQue", music.AddLatestToQue)
|
musicGroup.GET("/addQue", music.AddLatestToQue)
|
||||||
musicGroup.GET("/addPlayed", music.AddLatestPlayed)
|
musicGroup.GET("/addPlayed", music.AddLatestPlayed)
|
||||||
|
|
||||||
routes := e.Routes()
|
// ============================================
|
||||||
|
// 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 - will be used by VGMQ and Statistics API
|
||||||
|
_ = apiV1.Group("", tokenAuthMiddleware)
|
||||||
|
|
||||||
|
// Note: Future protected endpoints (VGMQ, Statistics) will be added here
|
||||||
|
|
||||||
|
routes := e.Router().Routes()
|
||||||
sort.Slice(routes, func(i, j int) bool {
|
sort.Slice(routes, func(i, j int) bool {
|
||||||
return routes[i].Path < routes[j].Path
|
return routes[i].Path < routes[j].Path
|
||||||
})
|
})
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
if (r.Method == "GET" || r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE") && !strings.Contains(r.Name, "github") {
|
if (r.Method == "GET" || r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE") && !strings.Contains(r.Name, "github") {
|
||||||
fmt.Printf(" %s %s\n", r.Method, r.Path)
|
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"music-server/internal/db"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/db"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
port int
|
port int
|
||||||
|
db *db.Database
|
||||||
|
tokenHandler *TokenHandler
|
||||||
|
httpServer *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -22,38 +29,88 @@ var (
|
|||||||
password = os.Getenv("DB_PASSWORD")
|
password = os.Getenv("DB_PASSWORD")
|
||||||
musicPath = os.Getenv("MUSIC_PATH")
|
musicPath = os.Getenv("MUSIC_PATH")
|
||||||
charactersPath = os.Getenv("CHARACTERS_PATH")
|
charactersPath = os.Getenv("CHARACTERS_PATH")
|
||||||
|
logLevel = os.Getenv("LOG_LEVEL")
|
||||||
|
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
|
||||||
|
if logLevel == "" {
|
||||||
|
logLevel = "info"
|
||||||
|
}
|
||||||
|
logging.Init(logLevel, logJSON)
|
||||||
|
|
||||||
|
logger := logging.GetLogger()
|
||||||
|
|
||||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||||
NewServer := &Server{
|
|
||||||
port: port,
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("host: %s, dbPort: %v, username: %s, password: %s, dbName: %s\n",
|
// Validate required environment variables
|
||||||
host, dbPort, username, password, dbName)
|
|
||||||
|
|
||||||
log.Printf("musicPath: %s\n", musicPath)
|
|
||||||
log.Printf("charactersPath: %s\n", charactersPath)
|
|
||||||
|
|
||||||
//conf.SetupDb()
|
|
||||||
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
||||||
log.Fatal("Invalid settings")
|
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Migrate_db(host, dbPort, username, password, dbName)
|
// 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()))
|
||||||
|
}
|
||||||
|
|
||||||
db.InitDB(host, dbPort, username, password, dbName)
|
// Run migrations using the new method
|
||||||
|
if err := database.RunMigrations(); err != nil {
|
||||||
|
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
// Declare Server config
|
// Initialize backend package with database pool
|
||||||
server := &http.Server{
|
backend.InitBackend(database.Pool)
|
||||||
Addr: fmt.Sprintf(":%d", NewServer.port),
|
|
||||||
Handler: NewServer.RegisterRoutes(),
|
// Initialize token handler with database pool
|
||||||
|
tokenHandler := NewTokenHandler(database.Pool)
|
||||||
|
|
||||||
|
// Create the server instance
|
||||||
|
appServer := &Server{
|
||||||
|
port: port,
|
||||||
|
db: database,
|
||||||
|
tokenHandler: tokenHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the HTTP server
|
||||||
|
appServer.httpServer = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", port),
|
||||||
|
Handler: appServer.RegisterRoutes(),
|
||||||
IdleTimeout: time.Minute,
|
IdleTimeout: time.Minute,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
return server
|
logger.Info("Starting server",
|
||||||
|
zap.String("host", host),
|
||||||
|
zap.String("dbPort", dbPort),
|
||||||
|
zap.String("username", username),
|
||||||
|
zap.String("dbName", dbName),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.Info("Paths",
|
||||||
|
zap.String("musicPath", musicPath),
|
||||||
|
zap.String("charactersPath", charactersPath),
|
||||||
|
)
|
||||||
|
|
||||||
|
return appServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPServer returns the underlying http.Server for serving HTTP requests.
|
||||||
|
func (s *Server) HTTPServer() *http.Server {
|
||||||
|
return s.httpServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB returns the database instance for dependency injection.
|
||||||
|
func (s *Server) DB() *db.Database {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new HTTP server (deprecated, use NewServerInstance instead).
|
||||||
|
// This function is kept for backward compatibility.
|
||||||
|
func NewServer() *http.Server {
|
||||||
|
return NewServerInstance().HTTPServer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"music-server/internal/backend"
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/logging"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SyncHandler struct {
|
type SyncHandler struct {
|
||||||
@@ -15,42 +15,78 @@ func NewSyncHandler() *SyncHandler {
|
|||||||
return &SyncHandler{}
|
return &SyncHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SyncHandler) SyncProgress(ctx echo.Context) error {
|
// SyncProgress godoc
|
||||||
|
// @Summary Get sync progress
|
||||||
|
// @Description Returns the current sync progress or result
|
||||||
|
// @Tags sync
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Router /sync/progress [get]
|
||||||
|
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("Getting progress")
|
logging.GetLogger().Info("Getting sync progress")
|
||||||
response := backend.SyncProgress()
|
response := backend.SyncProgress()
|
||||||
return ctx.JSON(http.StatusOK, response)
|
return ctx.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
log.Println("Getting result")
|
logging.GetLogger().Info("Getting sync result")
|
||||||
response := backend.SyncResult()
|
response := backend.SyncResult()
|
||||||
return ctx.JSON(http.StatusOK, response)
|
return ctx.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SyncHandler) SyncGamesNewOnlyChanges(ctx echo.Context) error {
|
// SyncGamesNewOnlyChanges godoc
|
||||||
|
// @Summary Sync games with only changes
|
||||||
|
// @Description Starts syncing games with only new changes
|
||||||
|
// @Tags sync
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {string} string "Start syncing games"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /sync [get]
|
||||||
|
func (s *SyncHandler) SyncGamesNewOnlyChanges(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("Syncing is 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")
|
||||||
}
|
}
|
||||||
log.Println("Start syncing games")
|
logging.GetLogger().Info("Starting sync with only changes")
|
||||||
go backend.SyncGamesNewOnlyChanges()
|
go backend.SyncGamesNewOnlyChanges()
|
||||||
return ctx.JSON(http.StatusOK, "Start syncing games")
|
return ctx.JSON(http.StatusOK, "Start syncing games")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SyncHandler) SyncGamesNewFull(ctx echo.Context) error {
|
// SyncGamesNewFull godoc
|
||||||
|
// @Summary Sync all games fully
|
||||||
|
// @Description Starts a full sync of all games
|
||||||
|
// @Tags sync
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {string} string "Start syncing games full"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /sync/full [get]
|
||||||
|
func (s *SyncHandler) SyncGamesNewFull(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("Syncing is 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")
|
||||||
}
|
}
|
||||||
log.Println("Start syncing games full")
|
logging.GetLogger().Info("Starting full sync")
|
||||||
go backend.SyncGamesNewFull()
|
go backend.SyncGamesNewFull()
|
||||||
return ctx.JSON(http.StatusOK, "Start syncing games full")
|
return ctx.JSON(http.StatusOK, "Start syncing games full")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SyncHandler) ResetGames(ctx echo.Context) error {
|
// ResetGames godoc
|
||||||
|
// @Summary Reset games database
|
||||||
|
// @Description Resets the games database by deleting all games and songs
|
||||||
|
// @Tags sync
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {string} string "Games and songs are deleted from the database"
|
||||||
|
// @Failure 423 {string} string "Syncing is in progress"
|
||||||
|
// @Router /sync/reset [get]
|
||||||
|
func (s *SyncHandler) ResetGames(ctx *echo.Context) error {
|
||||||
if backend.Syncing {
|
if backend.Syncing {
|
||||||
log.Println("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")
|
||||||
backend.ResetDB()
|
backend.ResetDB()
|
||||||
return ctx.JSON(http.StatusOK, "Games and songs are deleted from the database")
|
return ctx.JSON(http.StatusOK, "Games and songs are deleted from the database")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/db"
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// waitForSyncCompletion polls the sync progress endpoint until sync is complete
|
||||||
|
// Returns true if sync completed, false if timeout
|
||||||
|
func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
|
||||||
|
for i := 0; i < maxAttempts; i++ {
|
||||||
|
progressResp := MakeTestRequest(t, e, "GET", "/sync/progress")
|
||||||
|
assert.Equal(t, http.StatusOK, progressResp.Code)
|
||||||
|
|
||||||
|
// Try to parse as ProgressResponse first (while syncing)
|
||||||
|
var progress backend.ProgressResponse
|
||||||
|
err := json.Unmarshal(progressResp.Body.Bytes(), &progress)
|
||||||
|
if err == nil && progress.Progress != "" {
|
||||||
|
// Successfully parsed as ProgressResponse with non-empty progress
|
||||||
|
t.Logf("Sync progress: %s%%", progress.Progress)
|
||||||
|
if progress.Progress == "100" {
|
||||||
|
t.Log("Sync completed!")
|
||||||
|
// Wait for Syncing flag to be updated
|
||||||
|
for j := 0; j < 50; j++ {
|
||||||
|
if !backend.Syncing {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If Progress is empty or parse failed, it might be a SyncResponse (sync already completed)
|
||||||
|
var result backend.SyncResponse
|
||||||
|
err2 := json.Unmarshal(progressResp.Body.Bytes(), &result)
|
||||||
|
if err2 == nil {
|
||||||
|
t.Log("Sync already completed")
|
||||||
|
// Wait for Syncing flag to be updated
|
||||||
|
for j := 0; j < 50; j++ {
|
||||||
|
if !backend.Syncing {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncPopulatesDatabase verifies that sync populates the database with games
|
||||||
|
func TestSyncPopulatesDatabase(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
// Debug: Check MUSIC_PATH
|
||||||
|
t.Logf("MUSIC_PATH: %s", os.Getenv("MUSIC_PATH"))
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Clear any existing data first
|
||||||
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
|
// Before sync - should have no games
|
||||||
|
repo := repository.New(backend.BackendPool())
|
||||||
|
gamesBefore, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
beforeCount := len(gamesBefore)
|
||||||
|
t.Logf("Games before sync: %d", beforeCount)
|
||||||
|
assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
|
||||||
|
|
||||||
|
// Run sync
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Wait for sync to complete
|
||||||
|
if !waitForSyncCompletion(t, e, 60) {
|
||||||
|
t.Error("Sync did not complete within timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After sync - should have games
|
||||||
|
gamesAfter, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
afterCount := len(gamesAfter)
|
||||||
|
t.Logf("Games after sync: %d", afterCount)
|
||||||
|
|
||||||
|
// Should have more games than before (unless database was already populated)
|
||||||
|
assert.True(t, afterCount > 0, "Database should have games after sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncMakesDifference verifies that sync actually changes the database state
|
||||||
|
func TestSyncMakesDifference(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Clear any existing data first
|
||||||
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
|
// Before sync - should have no games
|
||||||
|
repo := repository.New(backend.BackendPool())
|
||||||
|
gamesBefore, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync")
|
||||||
|
|
||||||
|
// Run sync
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Wait for sync to complete
|
||||||
|
if !waitForSyncCompletion(t, e, 60) {
|
||||||
|
t.Error("Sync did not complete within timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After sync - should have games
|
||||||
|
gamesAfter, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, len(gamesAfter) > 0, "Should have games after sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncProgress verifies the sync progress endpoint
|
||||||
|
func TestSyncProgress(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Start sync in background
|
||||||
|
go MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
|
|
||||||
|
// Poll progress endpoint
|
||||||
|
maxAttempts := 30
|
||||||
|
foundComplete := false
|
||||||
|
|
||||||
|
for i := 0; i < maxAttempts; i++ {
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/sync/progress")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Try ProgressResponse first
|
||||||
|
var progress backend.ProgressResponse
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &progress)
|
||||||
|
if err == nil && progress.Progress != "" {
|
||||||
|
// Successfully parsed as ProgressResponse with non-empty progress
|
||||||
|
t.Logf("Sync progress: %s%%", progress.Progress)
|
||||||
|
|
||||||
|
// Verify we get valid progress values
|
||||||
|
if progress.Progress != "0" {
|
||||||
|
// Sync is making progress
|
||||||
|
}
|
||||||
|
if progress.Progress == "100" {
|
||||||
|
foundComplete = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If Progress is empty or parse failed, it might be a SyncResponse (sync already completed)
|
||||||
|
var result backend.SyncResponse
|
||||||
|
err2 := json.Unmarshal(resp.Body.Bytes(), &result)
|
||||||
|
if err2 == nil {
|
||||||
|
foundComplete = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: foundNonZero might be false if sync completed too quickly
|
||||||
|
// So we only assert that sync completed
|
||||||
|
assert.True(t, foundComplete, "Should have seen completion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncGamesNewOnlyChanges verifies the incremental sync endpoint
|
||||||
|
func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Run full sync first
|
||||||
|
MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
|
// Wait for it to complete
|
||||||
|
if !waitForSyncCompletion(t, e, 60) {
|
||||||
|
t.Error("Initial sync did not complete within timeout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial count
|
||||||
|
repo := repository.New(backend.BackendPool())
|
||||||
|
gamesBefore, _ := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
beforeCount := len(gamesBefore)
|
||||||
|
|
||||||
|
// Run incremental sync (should not change count if nothing changed)
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/sync/new")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Count should be the same
|
||||||
|
gamesAfter, _ := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
afterCount := len(gamesAfter)
|
||||||
|
|
||||||
|
// Note: This might not be exactly equal due to timing, but should be close
|
||||||
|
t.Logf("Games before incremental sync: %d, after: %d", beforeCount, afterCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResetGames verifies the reset endpoint clears the database
|
||||||
|
// RUN THIS LAST
|
||||||
|
func TestResetGames(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// First ensure we have data
|
||||||
|
repo := repository.New(backend.BackendPool())
|
||||||
|
gamesBefore, _ := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
beforeCount := len(gamesBefore)
|
||||||
|
|
||||||
|
if beforeCount == 0 {
|
||||||
|
// Run sync to populate
|
||||||
|
MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
|
if !waitForSyncCompletion(t, e, 60) {
|
||||||
|
t.Error("Sync did not complete within timeout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gamesBefore, _ = repo.FindAllGames(backend.BackendCtx())
|
||||||
|
beforeCount = len(gamesBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Games before reset: %d", beforeCount)
|
||||||
|
assert.True(t, beforeCount > 0, "Should have games to reset")
|
||||||
|
|
||||||
|
// Call reset
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/sync/reset")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify database is cleared
|
||||||
|
// Note: reset might take a moment to propagate
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
gamesAfter, _ := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
afterCount := len(gamesAfter)
|
||||||
|
|
||||||
|
t.Logf("Games after reset: %d", afterCount)
|
||||||
|
assert.Equal(t, 0, afterCount, "Database should be empty after reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncGamesNewFull verifies the full sync endpoint
|
||||||
|
// RUN THIS LAST (before TestResetGames)
|
||||||
|
func TestSyncGamesNewFull(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Clear database first
|
||||||
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
|
// Run full sync
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Wait for sync to complete
|
||||||
|
if !waitForSyncCompletion(t, e, 60) {
|
||||||
|
t.Error("Full sync did not complete within timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify database is populated
|
||||||
|
repo := repository.New(backend.BackendPool())
|
||||||
|
games, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, len(games) > 0, "Database should be populated after full sync")
|
||||||
|
t.Logf("Full sync populated %d games", len(games))
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/db"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartTestServer starts the server for testing with test configuration
|
||||||
|
func StartTestServer(t *testing.T) *echo.Echo {
|
||||||
|
// Set test environment variables if not already set
|
||||||
|
if os.Getenv("DB_HOST") == "" {
|
||||||
|
os.Setenv("DB_HOST", "localhost")
|
||||||
|
}
|
||||||
|
if os.Getenv("DB_PORT") == "" {
|
||||||
|
os.Setenv("DB_PORT", "5432")
|
||||||
|
}
|
||||||
|
if os.Getenv("DB_USERNAME") == "" {
|
||||||
|
os.Setenv("DB_USERNAME", "testuser")
|
||||||
|
}
|
||||||
|
if os.Getenv("DB_PASSWORD") == "" {
|
||||||
|
os.Setenv("DB_PASSWORD", "testpass")
|
||||||
|
}
|
||||||
|
if os.Getenv("DB_NAME") == "" {
|
||||||
|
os.Setenv("DB_NAME", "music_server_test")
|
||||||
|
}
|
||||||
|
if os.Getenv("MUSIC_PATH") == "" {
|
||||||
|
os.Setenv("MUSIC_PATH", "./testMusic")
|
||||||
|
}
|
||||||
|
if os.Getenv("CHARACTERS_PATH") == "" {
|
||||||
|
os.Setenv("CHARACTERS_PATH", "./testCharacters")
|
||||||
|
}
|
||||||
|
if os.Getenv("PORT") == "" {
|
||||||
|
os.Setenv("PORT", "8081")
|
||||||
|
}
|
||||||
|
if os.Getenv("LOG_LEVEL") == "" {
|
||||||
|
os.Setenv("LOG_LEVEL", "debug")
|
||||||
|
}
|
||||||
|
if os.Getenv("LOG_JSON") == "" {
|
||||||
|
os.Setenv("LOG_JSON", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database for tests
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
|
||||||
|
// Initialize backend with the global Dbpool
|
||||||
|
// This ensures BackendRepo() and BackendCtx() are available
|
||||||
|
if db.Dbpool != nil {
|
||||||
|
backend.InitBackend(db.Dbpool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a Server instance and get its routes
|
||||||
|
s := &Server{
|
||||||
|
db: &db.Database{
|
||||||
|
Pool: db.Dbpool,
|
||||||
|
Ctx: db.Ctx,
|
||||||
|
},
|
||||||
|
tokenHandler: NewTokenHandler(db.Dbpool),
|
||||||
|
}
|
||||||
|
handler := s.RegisterRoutes()
|
||||||
|
|
||||||
|
// Wrap the http.Handler in an echo.Echo
|
||||||
|
e := echo.New()
|
||||||
|
// Use a custom handler that wraps our routes
|
||||||
|
e.Any("/*", echo.WrapHandler(handler))
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeTestRequest makes an HTTP request to the test server
|
||||||
|
func MakeTestRequest(t *testing.T, e *echo.Echo, method, path string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeTestRequestWithBody makes an HTTP request with a body to the test server
|
||||||
|
func MakeTestRequestWithBody(t *testing.T, e *echo.Echo, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
if body != nil {
|
||||||
|
req = httptest.NewRequest(method, path, bytes.NewBuffer(body))
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForSyncComplete polls the sync progress endpoint until sync is complete
|
||||||
|
func WaitForSyncComplete(t *testing.T, e *echo.Echo, timeout time.Duration) bool {
|
||||||
|
start := time.Now()
|
||||||
|
for time.Since(start) < timeout {
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/sync/progress")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Logf("Sync progress endpoint returned status %d", resp.Code)
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response - we can't easily decode here without importing backend
|
||||||
|
// Just check if response contains "100"
|
||||||
|
body := resp.Body.String()
|
||||||
|
if len(body) > 0 {
|
||||||
|
t.Logf("Sync progress: %s", body)
|
||||||
|
// Simple check for completion
|
||||||
|
// In a real scenario, you'd parse the JSON properly
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
t.Error("Sync did not complete within timeout")
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/db"
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ensureSyncRan ensures that sync has been run before testing music endpoints
|
||||||
|
func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
||||||
|
repo := repository.New(backend.BackendPool())
|
||||||
|
games, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
if len(games) == 0 {
|
||||||
|
// Run sync
|
||||||
|
t.Log("No games found, running sync first...")
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/sync/full")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Wait for sync to complete using shared helper
|
||||||
|
if !waitForSyncCompletion(t, e, 60) {
|
||||||
|
t.Error("Sync did not complete within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetAllGames verifies the /music/all/order endpoint
|
||||||
|
func TestZGetAllGames(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/all/order")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var games []string
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &games)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, games, "Should have games after sync")
|
||||||
|
t.Logf("Found %d games", len(games))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetAllGamesRandom verifies the /music/all/random endpoint
|
||||||
|
func TestZGetAllGamesRandom(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/all/random")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var games []string
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &games)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, games, "Should have games after sync")
|
||||||
|
|
||||||
|
// Verify it's shuffled (not in original order)
|
||||||
|
// We can't easily verify randomness, but we can check it's the same length
|
||||||
|
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
|
||||||
|
var gamesOrdered []string
|
||||||
|
json.Unmarshal(resp2.Body.Bytes(), &gamesOrdered)
|
||||||
|
assert.Equal(t, len(games), len(gamesOrdered), "Random and ordered should have same count")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetRandomSong verifies the /music/rand endpoint
|
||||||
|
func TestZGetRandomSong(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// The endpoint returns a file stream, not JSON
|
||||||
|
// Just verify we got a response with content
|
||||||
|
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||||
|
t.Logf("Random song returned %d bytes", len(resp.Body.Bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetRandomSongLowChance verifies the /music/rand/low endpoint
|
||||||
|
func TestZGetRandomSongLowChance(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/rand/low")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// The endpoint returns a file stream, not JSON
|
||||||
|
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetRandomSongClassic verifies the /music/rand/classic endpoint
|
||||||
|
func TestZGetRandomSongClassic(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/rand/classic")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// The endpoint returns a file stream, not JSON
|
||||||
|
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetSongInfo verifies the /music/info endpoint
|
||||||
|
func TestZGetSongInfo(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run and get a song first
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
// Add to queue and mark as played
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addPlayed")
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/info")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var info backend.SongInfo
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &info)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// Note: CurrentlyPlaying might be false if no song is currently set
|
||||||
|
// Just verify we got a valid response
|
||||||
|
t.Logf("Song info: Game=%s, Song=%s", info.Game, info.Song)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetPlayedSongs verifies the /music/list endpoint
|
||||||
|
func TestZGetPlayedSongs(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run and add some songs to queue
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/list")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var songs []backend.SongInfo
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &songs)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, songs, "Should have played songs in queue")
|
||||||
|
t.Logf("Found %d songs in queue", len(songs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetNextSong verifies the /music/next endpoint
|
||||||
|
func TestZGetNextSong(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run and add songs to queue
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/next")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// The endpoint returns a file stream, not JSON
|
||||||
|
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetPreviousSong verifies the /music/previous endpoint
|
||||||
|
func TestZGetPreviousSong(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run and add songs to queue
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
// Move forward
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/next")
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/previous")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// The endpoint returns a file stream, not JSON
|
||||||
|
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResetMusic verifies the /music/reset endpoint
|
||||||
|
func TestZResetMusic(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run and add songs to queue
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
|
||||||
|
// Verify queue has items
|
||||||
|
respBefore := MakeTestRequest(t, e, "GET", "/music/list")
|
||||||
|
var songsBefore []backend.SongInfo
|
||||||
|
json.Unmarshal(respBefore.Body.Bytes(), &songsBefore)
|
||||||
|
assert.True(t, len(songsBefore) > 0, "Should have songs before reset")
|
||||||
|
|
||||||
|
// Reset queue
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/reset")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify queue is empty
|
||||||
|
respAfter := MakeTestRequest(t, e, "GET", "/music/list")
|
||||||
|
var songsAfter []backend.SongInfo
|
||||||
|
json.Unmarshal(respAfter.Body.Bytes(), &songsAfter)
|
||||||
|
assert.Equal(t, 0, len(songsAfter), "Queue should be empty after reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddLatestToQue verifies the /music/addQue endpoint
|
||||||
|
func TestZAddLatestToQue(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
|
||||||
|
// Get a random song (this sets lastFetchedNew)
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify it was added to queue
|
||||||
|
respList := MakeTestRequest(t, e, "GET", "/music/list")
|
||||||
|
var songs []backend.SongInfo
|
||||||
|
json.Unmarshal(respList.Body.Bytes(), &songs)
|
||||||
|
assert.True(t, len(songs) > 0, "Song should be in queue")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddLatestPlayed verifies the /music/addPlayed endpoint
|
||||||
|
func TestZAddLatestPlayed(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run and add song to queue
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
|
||||||
|
// Mark as played
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/music/addPlayed")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPutPlayed verifies the PUT /music/played endpoint
|
||||||
|
func TestZPutPlayed(t *testing.T) {
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
// Ensure sync has run and add songs to queue
|
||||||
|
ensureSyncRan(t, e)
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/rand")
|
||||||
|
MakeTestRequest(t, e, "GET", "/music/addQue")
|
||||||
|
|
||||||
|
// Mark song 0 as played
|
||||||
|
resp := MakeTestRequestWithBody(t, e, "PUT", "/music/played?song=0", nil)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
@@ -41,16 +41,48 @@ sqlc-generate:
|
|||||||
migrate-create name:
|
migrate-create name:
|
||||||
@migrate create -ext sql -dir internal/db/migrations -seq {{name}}
|
@migrate create -ext sql -dir internal/db/migrations -seq {{name}}
|
||||||
|
|
||||||
|
swag-install:
|
||||||
|
@if ! command -v swag > /dev/null; then \
|
||||||
|
read -p "Swag is not installed on your machine. Do you want to install it? [Y/n] " choice; \
|
||||||
|
if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
|
||||||
|
go install github.com/swaggo/swag/cmd/swag@latest; \
|
||||||
|
if [ ! -x "$$(command -v swag)" ]; then \
|
||||||
|
echo "swag installation failed. Exiting..."; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
else \
|
||||||
|
echo "You chose not to install swag. Exiting..."; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
swag-generate: swag-install
|
||||||
|
@echo "Generating OpenAPI docs..."
|
||||||
|
@swag init -g internal/server/routes.go -o cmd/docs
|
||||||
|
|
||||||
|
frontend-install:
|
||||||
|
@if ! command -v npm > /dev/null; then \
|
||||||
|
echo "npm is not installed on your machine. Please install Node.js first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@cd cmd/frontend && npm install
|
||||||
|
|
||||||
|
frontend-build: frontend-install
|
||||||
|
@echo "Building frontend..."
|
||||||
|
@cd cmd/frontend && npm run build
|
||||||
|
|
||||||
[no-cd]
|
[no-cd]
|
||||||
build: sqlc-generate templ-build tailwind-build
|
build: sqlc-generate templ-build swag-generate
|
||||||
@echo "Building..."
|
@echo "Building..."
|
||||||
@swag init -g routes.go -d ./internal/server/,./internal/backend/ -o ./cmd/docs
|
|
||||||
@go build -o main cmd/main.go
|
@go build -o main cmd/main.go
|
||||||
|
|
||||||
run:
|
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 "Testing..."
|
||||||
@go test ./... -v
|
@go test ./... -v
|
||||||
@@ -60,12 +92,35 @@ clean:
|
|||||||
@echo "Cleaning..."
|
@echo "Cleaning..."
|
||||||
@rm -f main
|
@rm -f main
|
||||||
|
|
||||||
|
podman-build:
|
||||||
|
@echo "Building Docker image with podman..."
|
||||||
|
@podman build -t music-server .
|
||||||
|
|
||||||
podman-run:
|
podman-run:
|
||||||
@podman-compose up --build
|
@podman-compose up --build
|
||||||
|
|
||||||
podman-down:
|
podman-down:
|
||||||
@podman-compose down
|
@podman-compose down
|
||||||
|
|
||||||
|
# Run integration tests with podman
|
||||||
|
# Starts a test PostgreSQL container, runs tests, then cleans up
|
||||||
|
test-integration:
|
||||||
|
@echo "Starting test database container..."
|
||||||
|
@podman-compose -f compose.test.yaml up -d
|
||||||
|
@sleep 10
|
||||||
|
@echo "Running integration tests..."
|
||||||
|
@DB_HOST=localhost DB_PORT=5433 DB_USERNAME=testuser DB_PASSWORD=testpass DB_NAME=music_server_test MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters PORT=8081 LOG_LEVEL=debug LOG_JSON=false go test -v -timeout 30m -p 1 -parallel 1 ./internal/...
|
||||||
|
|
||||||
|
# Alternative: Run integration tests using testcontainers with podman provider
|
||||||
|
test-integration-tc:
|
||||||
|
@echo "Running integration tests with testcontainers (podman provider)..."
|
||||||
|
@TESTCONTAINERS_PROVIDER=podman go test -v -timeout 30m .
|
||||||
|
|
||||||
|
# Stop and remove test database container
|
||||||
|
test-integration-down:
|
||||||
|
@echo "Stopping test database container..."
|
||||||
|
@podman-compose -f compose.test.yaml down -v
|
||||||
|
|
||||||
# Create DB container
|
# Create DB container
|
||||||
docker-run:
|
docker-run:
|
||||||
@if docker compose up --build 2>/dev/null; then \
|
@if docker compose up --build 2>/dev/null; then \
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1018 B |
|
After Width: | Height: | Size: 83 B |
|
After Width: | Height: | Size: 67 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 11 KiB |