Compare commits
91
Commits
660d27aa2a
...
5.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
581d91f78b | ||
|
|
a8a2602f50 | ||
|
|
e86c0f8e04 | ||
|
|
eda60e0c07 | ||
|
|
a8df738108 | ||
|
|
dbef39b828 | ||
|
|
4e5bdc4ee2 | ||
|
|
0894d65ec5 | ||
|
|
4033899a68 | ||
|
|
c6a07e69e7 | ||
|
|
6d4a034753 | ||
|
|
b0418b4f38 | ||
|
|
176848bb6d | ||
|
|
fb387901cf | ||
|
|
0f29c33b1a | ||
|
|
cec408187d | ||
|
|
c60f40d7e3 | ||
|
|
2f407f6eef | ||
|
|
4c2db11cc5 | ||
|
|
06cbad708d | ||
|
|
89e884fae9 | ||
|
|
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 | ||
|
|
cff777f278 | ||
|
|
61cab73ffc | ||
|
|
a6294e46f2 | ||
|
|
5f91643b4d | ||
|
|
806e88adeb | ||
|
|
0d1c69d95e | ||
|
|
b024c0b747 | ||
|
|
75ee924783 | ||
|
|
f86c33d5e6 | ||
|
|
ef41d0fa11 | ||
|
|
fd666dd3fa | ||
|
|
231867de40 | ||
|
|
9a9d318771 | ||
|
|
f06a7fe927 | ||
|
|
0f017407ff | ||
|
|
29ba39f5fe | ||
|
|
8d01fe100a | ||
|
|
2821774215 | ||
|
|
00f0981ce4 | ||
|
|
53a9031cb0 | ||
|
|
85204026bb | ||
|
|
1ffddd1154 | ||
|
|
a4ef66a3f8 | ||
|
|
478de6e3d4 | ||
|
|
052b699025 | ||
|
|
999668fc9c | ||
|
|
11e6233753 | ||
|
|
3f73ea1f5e | ||
|
|
d15d1422da | ||
|
|
73d85adc42 | ||
|
|
d653463f58 | ||
|
|
db8214cb02 | ||
|
|
5b640375c3 | ||
|
|
034ba35fbb |
No files matched your search
@@ -0,0 +1,31 @@
|
||||
# Directories
|
||||
.gitea
|
||||
git
|
||||
.idea
|
||||
testMusic
|
||||
testCharacters
|
||||
|
||||
# Files
|
||||
Dockerfile
|
||||
docker-compose*
|
||||
compose.yaml
|
||||
.dockerignore
|
||||
.gitignore
|
||||
*.pprof
|
||||
main
|
||||
conf.yaml
|
||||
output.css
|
||||
tailwindcss
|
||||
.env
|
||||
|
||||
# Node.js (frontend build artifacts)
|
||||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
# Test files
|
||||
*_test.go
|
||||
integration_test.go
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
|
||||
# Session Authentication (for testing)
|
||||
# Set to disable session auth during development
|
||||
DISABLE_SESSION_AUTH=true
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
name: Build
|
||||
|
||||
#on:
|
||||
# release:
|
||||
# types: [published]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
# test:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - run: echo "The release ${{ gitea.ref }} ${{ gitea.ref_name }} was published"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."gitea.sanplex.xyz/sansan"]
|
||||
http = true
|
||||
insecure = true
|
||||
- name: Login to Gitea
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: gitea.sanplex.xyz
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Build
|
||||
uses: https://github.com/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
#tags: "gitea.sanplex.xyz/sansan/musicserver:${{gitea.ref_name}}, gitea.sanplex.xyz/sansan/musicserver:latest"
|
||||
@@ -0,0 +1,41 @@
|
||||
|
||||
name: Publish
|
||||
|
||||
#on:
|
||||
# release:
|
||||
# types: [published]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*.*'
|
||||
|
||||
jobs:
|
||||
# test:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - run: echo "The release ${{ gitea.ref }} ${{ gitea.ref_name }} was published"
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."gitea.sanplex.xyz/sansan"]
|
||||
http = true
|
||||
insecure = true
|
||||
- name: Login to Gitea
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: gitea.sanplex.xyz
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Build and push Docker image
|
||||
uses: https://github.com/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
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 ./...
|
||||
@@ -6,3 +6,9 @@ conf.yaml
|
||||
output.css
|
||||
compose.yaml
|
||||
tailwindcss
|
||||
.env
|
||||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
cpu.pprof
|
||||
main
|
||||
Generated
+7
@@ -25,5 +25,12 @@
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://ssh.sanplex.xyz:9432/music_prod</jdbc-url>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="music_test2@localhost" uuid="a423ab0a-55b0-42e1-8070-25d8ef34bfac">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5432/music_test2</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
+20
-17
@@ -1,33 +1,36 @@
|
||||
FROM golang:1.23-alpine as build_go
|
||||
# Stage 1: Build backend
|
||||
FROM golang:1.25-alpine as build_go
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go install github.com/a-h/templ/cmd/templ@latest && \
|
||||
templ generate && \
|
||||
curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 -o tailwindcss && \
|
||||
chmod +x tailwindcss && \
|
||||
./tailwindcss -i cmd/web/assets/css/input.css -o cmd/web/assets/css/output.css
|
||||
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
RUN find . -name "*_test.go" -delete && \
|
||||
find . -name "integration_test.go" -delete
|
||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
RUN templ generate
|
||||
RUN go build -o main cmd/main.go
|
||||
|
||||
# Stage 2, distribution container
|
||||
FROM golang:1.23-alpine
|
||||
# Stage 2: Final image
|
||||
FROM golang:1.25-alpine
|
||||
EXPOSE 8080
|
||||
VOLUME /sorted
|
||||
VOLUME /frontend
|
||||
VOLUME /characters
|
||||
|
||||
COPY --from=build_go /app/main .
|
||||
COPY --from=gitea.sanplex.xyz/sansan/musicfrontend:latest /usr/share/nginx/html /frontend
|
||||
# Generate config.js with empty API_HOSTNAME (relative paths)
|
||||
RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > /frontend/config.js
|
||||
COPY ./songs/ ./songs/
|
||||
|
||||
ENV PORT 8080
|
||||
ENV DB_HOST ""
|
||||
ENV DB_PORT ""
|
||||
ENV DB_USERNAME ""
|
||||
ENV DB_PASSWORD ""
|
||||
ENV DB_NAME ""
|
||||
|
||||
COPY --from=build_go /app/main .
|
||||
COPY ./songs/ ./songs/
|
||||
ENV MUSIC_PATH ""
|
||||
ENV CHARACTERS_PATH ""
|
||||
|
||||
CMD ./main
|
||||
+1554
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,976 @@
|
||||
definitions:
|
||||
backend.SongInfoForStats:
|
||||
properties:
|
||||
file_name:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
song_name:
|
||||
type: string
|
||||
soundtrack_id:
|
||||
type: integer
|
||||
soundtrack_name:
|
||||
type: string
|
||||
times_played:
|
||||
type: integer
|
||||
type: object
|
||||
backend.SoundtrackWithSongs:
|
||||
properties:
|
||||
songs:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SongInfoForStats'
|
||||
type: array
|
||||
soundtrack_id:
|
||||
type: integer
|
||||
soundtrack_last_played:
|
||||
type: string
|
||||
soundtrack_name:
|
||||
type: string
|
||||
soundtrack_played:
|
||||
type: integer
|
||||
type: object
|
||||
backend.StatisticsSummary:
|
||||
properties:
|
||||
avg_soundtrack_plays:
|
||||
type: number
|
||||
max_soundtrack_plays:
|
||||
type: integer
|
||||
min_soundtrack_plays:
|
||||
type: integer
|
||||
never_played_soundtracks:
|
||||
type: integer
|
||||
played_soundtracks:
|
||||
type: integer
|
||||
total_soundtrack_plays:
|
||||
type: integer
|
||||
total_soundtracks:
|
||||
type: integer
|
||||
type: object
|
||||
backend.VersionData:
|
||||
properties:
|
||||
changelog:
|
||||
example:
|
||||
- '["Initial release"'
|
||||
- '"Bug fixes"]'
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
version:
|
||||
example: 1.0.0
|
||||
type: string
|
||||
type: object
|
||||
server.TokenRequest:
|
||||
properties:
|
||||
client_type:
|
||||
description: 'Optional: "web", "mobile", "api"'
|
||||
type: string
|
||||
password:
|
||||
description: 'Required: API password'
|
||||
type: string
|
||||
type: object
|
||||
server.TokenResponse:
|
||||
properties:
|
||||
client_type:
|
||||
type: string
|
||||
expires_at:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
paths:
|
||||
/api/v1/statistics/songs/least-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the top N least played songs with their soundtrack info
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SongInfoForStats'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get least played songs
|
||||
tags:
|
||||
- statistics
|
||||
/api/v1/statistics/songs/most-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the top N most played songs with their soundtrack info
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SongInfoForStats'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get most played songs
|
||||
tags:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/last-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the most recently played soundtracks
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get last played soundtracks
|
||||
tags:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/least-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the top N least played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get least played soundtracks
|
||||
tags:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/most-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the top N most played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get most played soundtracks
|
||||
tags:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/never-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns all soundtracks that have never been played (times_played
|
||||
= 0)
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get never played soundtracks
|
||||
tags:
|
||||
- statistics
|
||||
/api/v1/statistics/soundtracks/oldest-played:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the least recently played soundtracks (that have been played
|
||||
at least once)
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10)'
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SoundtrackWithSongs'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get oldest played soundtracks
|
||||
tags:
|
||||
- statistics
|
||||
/api/v1/statistics/summary:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns overall statistics about the music library
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/backend.StatisticsSummary'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get statistics summary
|
||||
tags:
|
||||
- statistics
|
||||
/api/v1/token:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Deletes the current session token
|
||||
parameters:
|
||||
- description: Bearer token
|
||||
in: header
|
||||
name: Authorization
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Invalidate session token
|
||||
tags:
|
||||
- auth
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a new session token for API access. Requires valid API
|
||||
password.
|
||||
parameters:
|
||||
- description: Password and client type
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/server.TokenRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/server.TokenResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Create session token
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/token/cleanup:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Removes all expired session tokens from the database
|
||||
parameters:
|
||||
- description: Bearer token
|
||||
in: header
|
||||
name: Authorization
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Cleanup expired sessions
|
||||
tags:
|
||||
- auth
|
||||
/character:
|
||||
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
|
||||
/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 soundtracks 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 soundtracks
|
||||
tags:
|
||||
- music
|
||||
/music/all/random:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a list of all soundtracks 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 soundtracks 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 soundtracks with only new changes
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Start syncing soundtracks
|
||||
schema:
|
||||
type: string
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
type: string
|
||||
summary: Sync soundtracks with only changes
|
||||
tags:
|
||||
- sync
|
||||
/sync/full:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Starts a full sync of all soundtracks
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Start syncing soundtracks full
|
||||
schema:
|
||||
type: string
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
type: string
|
||||
summary: Sync all soundtracks 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 soundtracks database by deleting all soundtracks and
|
||||
songs
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Soundtracks and songs are deleted from the database
|
||||
schema:
|
||||
type: string
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
type: string
|
||||
summary: Reset soundtracks database
|
||||
tags:
|
||||
- sync
|
||||
/version:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: get latest version info
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/backend.VersionData'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
type: string
|
||||
summary: Getting the latest version of the backend
|
||||
tags:
|
||||
- version
|
||||
/version/history:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: get version history
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.VersionData'
|
||||
type: array
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
type: string
|
||||
summary: Getting the version history of the backend
|
||||
tags:
|
||||
- version
|
||||
swagger: "2.0"
|
||||
Generated
+10910
-27818
File diff suppressed because it is too large.
Load diff
+25
-16
@@ -8,24 +8,22 @@
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"config.js": "^0.1.0",
|
||||
"core-js": "^3.8.2",
|
||||
"axios": "^1.7.2",
|
||||
"core-js": "^3.37.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"nodemon": "^2.0.7",
|
||||
"vue": "^3.0.5",
|
||||
"vue-axios": "^3.2.2",
|
||||
"vuex": "^4.0.0-rc.2"
|
||||
"express": "^4.19.2",
|
||||
"vue": "^3.4.31",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.5.10",
|
||||
"@vue/cli-plugin-eslint": "^4.5.10",
|
||||
"@vue/cli-service": "^4.5.10",
|
||||
"@vue/compiler-sfc": "^3.0.5",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-vue": "^7.4.1"
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-eslint": "^5.0.8",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"@vue/compiler-sfc": "^3.4.31",
|
||||
"@babel/eslint-parser": "^7.25.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.27.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
@@ -37,7 +35,18 @@
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
"parser": "@babel/eslint-parser",
|
||||
"requireConfigFile": false,
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"globalReturn": false,
|
||||
"impliedStrict": true,
|
||||
"jsx": true
|
||||
},
|
||||
"babelOptions": {
|
||||
"presets": ["@babel/preset-env"]
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-debugger": 1
|
||||
|
||||
+75
-7
@@ -1,17 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"music-server/pkg/conf"
|
||||
"context"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
//go:embed swagger
|
||||
var swagger embed.FS
|
||||
//
|
||||
// @Title Swagger Example API
|
||||
// @version 0.5
|
||||
// @description This is a sample server Petstore server.
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
//
|
||||
// @contact.name Sebastian Olsson
|
||||
// @contact.email zarnor91@gmail.com
|
||||
|
||||
//
|
||||
// @license.name Apache 2.0
|
||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
|
||||
// @host localhost:8080
|
||||
func main() {
|
||||
conf.SetupDb()
|
||||
/*f, perr := os.Create("cpu.pprof")
|
||||
if perr != nil {
|
||||
log.Fatal(perr)
|
||||
}
|
||||
pprof.StartCPUProfile(f)
|
||||
defer pprof.StopCPUProfile()*/
|
||||
|
||||
conf.SetupRestServer(swagger)
|
||||
appServer := server.NewServerInstance()
|
||||
httpServer := appServer.HTTPServer()
|
||||
|
||||
conf.CloseDb()
|
||||
// Create a done channel to signal when the shutdown is complete
|
||||
done := make(chan bool, 1)
|
||||
|
||||
// Run graceful shutdown in a separate goroutine
|
||||
go gracefulShutdown(appServer, httpServer, done)
|
||||
|
||||
logging.GetLogger().Info("Server starting", zap.String("address", httpServer.Addr))
|
||||
err := httpServer.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
// Wait for the graceful shutdown to complete
|
||||
<-done
|
||||
logging.GetLogger().Info("Graceful shutdown complete")
|
||||
}
|
||||
|
||||
func gracefulShutdown(appServer *server.Server, httpServer *http.Server, done chan bool) {
|
||||
// Create context that listens for the interrupt signal from the OS.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Listen for the interrupt signal.
|
||||
<-ctx.Done()
|
||||
|
||||
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
|
||||
|
||||
// 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 request it is currently handling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Server exiting")
|
||||
|
||||
// Notify the main goroutine that the shutdown is complete
|
||||
done <- true
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB |
@@ -1,34 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b6bierb3v4yoq"
|
||||
path="res://.godot/imported/index.144x144.png-03ea9bc9a40782e35fa8c68f683d2623.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://web/index.144x144.png"
|
||||
dest_files=["res://.godot/imported/index.144x144.png-03ea9bc9a40782e35fa8c68f683d2623.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,34 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b0euyv6xwufjw"
|
||||
path="res://.godot/imported/index.180x180.png-9c97e3aaba17027b76e6e765bcdc0add.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://web/index.180x180.png"
|
||||
dest_files=["res://.godot/imported/index.180x180.png-9c97e3aaba17027b76e6e765bcdc0add.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -1,34 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c30mhkc1yernc"
|
||||
path="res://.godot/imported/index.512x512.png-4cda31773312dccaaf53a4ff122aad13.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://web/index.512x512.png"
|
||||
dest_files=["res://.godot/imported/index.512x512.png-4cda31773312dccaaf53a4ff122aad13.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,34 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d0tny2k1tdlnq"
|
||||
path="res://.godot/imported/index.apple-touch-icon.png-939ec69c79bbf504b92ad8ceed469d51.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://web/index.apple-touch-icon.png"
|
||||
dest_files=["res://.godot/imported/index.apple-touch-icon.png-939ec69c79bbf504b92ad8ceed469d51.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -1,213 +0,0 @@
|
||||
/**************************************************************************/
|
||||
/* audio.worklet.js */
|
||||
/**************************************************************************/
|
||||
/* This file is part of: */
|
||||
/* GODOT ENGINE */
|
||||
/* https://godotengine.org */
|
||||
/**************************************************************************/
|
||||
/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
|
||||
/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */
|
||||
/* */
|
||||
/* Permission is hereby granted, free of charge, to any person obtaining */
|
||||
/* a copy of this software and associated documentation files (the */
|
||||
/* "Software"), to deal in the Software without restriction, including */
|
||||
/* without limitation the rights to use, copy, modify, merge, publish, */
|
||||
/* distribute, sublicense, and/or sell copies of the Software, and to */
|
||||
/* permit persons to whom the Software is furnished to do so, subject to */
|
||||
/* the following conditions: */
|
||||
/* */
|
||||
/* The above copyright notice and this permission notice shall be */
|
||||
/* included in all copies or substantial portions of the Software. */
|
||||
/* */
|
||||
/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
|
||||
/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
|
||||
/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
|
||||
/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
|
||||
/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
|
||||
/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
|
||||
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
|
||||
/**************************************************************************/
|
||||
|
||||
class RingBuffer {
|
||||
constructor(p_buffer, p_state, p_threads) {
|
||||
this.buffer = p_buffer;
|
||||
this.avail = p_state;
|
||||
this.threads = p_threads;
|
||||
this.rpos = 0;
|
||||
this.wpos = 0;
|
||||
}
|
||||
|
||||
data_left() {
|
||||
return this.threads ? Atomics.load(this.avail, 0) : this.avail;
|
||||
}
|
||||
|
||||
space_left() {
|
||||
return this.buffer.length - this.data_left();
|
||||
}
|
||||
|
||||
read(output) {
|
||||
const size = this.buffer.length;
|
||||
let from = 0;
|
||||
let to_write = output.length;
|
||||
if (this.rpos + to_write > size) {
|
||||
const high = size - this.rpos;
|
||||
output.set(this.buffer.subarray(this.rpos, size));
|
||||
from = high;
|
||||
to_write -= high;
|
||||
this.rpos = 0;
|
||||
}
|
||||
if (to_write) {
|
||||
output.set(this.buffer.subarray(this.rpos, this.rpos + to_write), from);
|
||||
}
|
||||
this.rpos += to_write;
|
||||
if (this.threads) {
|
||||
Atomics.add(this.avail, 0, -output.length);
|
||||
Atomics.notify(this.avail, 0);
|
||||
} else {
|
||||
this.avail -= output.length;
|
||||
}
|
||||
}
|
||||
|
||||
write(p_buffer) {
|
||||
const to_write = p_buffer.length;
|
||||
const mw = this.buffer.length - this.wpos;
|
||||
if (mw >= to_write) {
|
||||
this.buffer.set(p_buffer, this.wpos);
|
||||
this.wpos += to_write;
|
||||
if (mw === to_write) {
|
||||
this.wpos = 0;
|
||||
}
|
||||
} else {
|
||||
const high = p_buffer.subarray(0, mw);
|
||||
const low = p_buffer.subarray(mw);
|
||||
this.buffer.set(high, this.wpos);
|
||||
this.buffer.set(low);
|
||||
this.wpos = low.length;
|
||||
}
|
||||
if (this.threads) {
|
||||
Atomics.add(this.avail, 0, to_write);
|
||||
Atomics.notify(this.avail, 0);
|
||||
} else {
|
||||
this.avail += to_write;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GodotProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.threads = false;
|
||||
this.running = true;
|
||||
this.lock = null;
|
||||
this.notifier = null;
|
||||
this.output = null;
|
||||
this.output_buffer = new Float32Array();
|
||||
this.input = null;
|
||||
this.input_buffer = new Float32Array();
|
||||
this.port.onmessage = (event) => {
|
||||
const cmd = event.data['cmd'];
|
||||
const data = event.data['data'];
|
||||
this.parse_message(cmd, data);
|
||||
};
|
||||
}
|
||||
|
||||
process_notify() {
|
||||
if (this.notifier) {
|
||||
Atomics.add(this.notifier, 0, 1);
|
||||
Atomics.notify(this.notifier, 0);
|
||||
}
|
||||
}
|
||||
|
||||
parse_message(p_cmd, p_data) {
|
||||
if (p_cmd === 'start' && p_data) {
|
||||
const state = p_data[0];
|
||||
let idx = 0;
|
||||
this.threads = true;
|
||||
this.lock = state.subarray(idx, ++idx);
|
||||
this.notifier = state.subarray(idx, ++idx);
|
||||
const avail_in = state.subarray(idx, ++idx);
|
||||
const avail_out = state.subarray(idx, ++idx);
|
||||
this.input = new RingBuffer(p_data[1], avail_in, true);
|
||||
this.output = new RingBuffer(p_data[2], avail_out, true);
|
||||
} else if (p_cmd === 'stop') {
|
||||
this.running = false;
|
||||
this.output = null;
|
||||
this.input = null;
|
||||
this.lock = null;
|
||||
this.notifier = null;
|
||||
} else if (p_cmd === 'start_nothreads') {
|
||||
this.output = new RingBuffer(p_data[0], p_data[0].length, false);
|
||||
} else if (p_cmd === 'chunk') {
|
||||
this.output.write(p_data);
|
||||
}
|
||||
}
|
||||
|
||||
static array_has_data(arr) {
|
||||
return arr.length && arr[0].length && arr[0][0].length;
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
if (!this.running) {
|
||||
return false; // Stop processing.
|
||||
}
|
||||
if (this.output === null) {
|
||||
return true; // Not ready yet, keep processing.
|
||||
}
|
||||
const process_input = GodotProcessor.array_has_data(inputs);
|
||||
if (process_input) {
|
||||
const input = inputs[0];
|
||||
const chunk = input[0].length * input.length;
|
||||
if (this.input_buffer.length !== chunk) {
|
||||
this.input_buffer = new Float32Array(chunk);
|
||||
}
|
||||
if (!this.threads) {
|
||||
GodotProcessor.write_input(this.input_buffer, input);
|
||||
this.port.postMessage({ 'cmd': 'input', 'data': this.input_buffer });
|
||||
} else if (this.input.space_left() >= chunk) {
|
||||
GodotProcessor.write_input(this.input_buffer, input);
|
||||
this.input.write(this.input_buffer);
|
||||
} else {
|
||||
// this.port.postMessage('Input buffer is full! Skipping input frame.'); // Uncomment this line to debug input buffer.
|
||||
}
|
||||
}
|
||||
const process_output = GodotProcessor.array_has_data(outputs);
|
||||
if (process_output) {
|
||||
const output = outputs[0];
|
||||
const chunk = output[0].length * output.length;
|
||||
if (this.output_buffer.length !== chunk) {
|
||||
this.output_buffer = new Float32Array(chunk);
|
||||
}
|
||||
if (this.output.data_left() >= chunk) {
|
||||
this.output.read(this.output_buffer);
|
||||
GodotProcessor.write_output(output, this.output_buffer);
|
||||
if (!this.threads) {
|
||||
this.port.postMessage({ 'cmd': 'read', 'data': chunk });
|
||||
}
|
||||
} else {
|
||||
// this.port.postMessage('Output buffer has not enough frames! Skipping output frame.'); // Uncomment this line to debug output buffer.
|
||||
}
|
||||
}
|
||||
this.process_notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
static write_output(dest, source) {
|
||||
const channels = dest.length;
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
for (let sample = 0; sample < dest[ch].length; sample++) {
|
||||
dest[ch][sample] = source[sample * channels + ch];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static write_input(dest, source) {
|
||||
const channels = source.length;
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
for (let sample = 0; sample < source[ch].length; sample++) {
|
||||
dest[sample * channels + ch] = source[ch][sample];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('godot-processor', GodotProcessor);
|
||||
@@ -1,200 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0">
|
||||
<title>MusicSearch</title>
|
||||
<style>
|
||||
html, body, #canvas {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: white;
|
||||
background-color: black;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#canvas:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#status, #status-splash, #status-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#status, #status-splash {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#status {
|
||||
background-color: #242424;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#status-splash {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#status-progress, #status-notice {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status-progress {
|
||||
bottom: 10%;
|
||||
width: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#status-notice {
|
||||
background-color: #5b3943;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #9b3943;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Noto Sans', 'Droid Sans', Arial, sans-serif;
|
||||
line-height: 1.3;
|
||||
margin: 0 2rem;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
<link id="-gd-engine-icon" rel="icon" type="image/png" href="index.icon.png" />
|
||||
<link rel="apple-touch-icon" href="index.apple-touch-icon.png"/>
|
||||
<link rel="manifest" href="index.manifest.json">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas">
|
||||
Your browser does not support the canvas tag.
|
||||
</canvas>
|
||||
|
||||
<noscript>
|
||||
Your browser does not support JavaScript.
|
||||
</noscript>
|
||||
|
||||
<div id="status">
|
||||
<img id="status-splash" src="index.png" alt="">
|
||||
<progress id="status-progress"></progress>
|
||||
<div id="status-notice"></div>
|
||||
</div>
|
||||
|
||||
<script src="index.js"></script>
|
||||
<script>
|
||||
const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"ensureCrossOriginIsolationHeaders":false,"executable":"index","experimentalVK":false,"fileSizes":{"index.pck":86816,"index.wasm":35376909},"focusCanvas":true,"gdextensionLibs":[],"serviceWorker":"index.service.worker.js"};
|
||||
const GODOT_THREADS_ENABLED = false;
|
||||
const engine = new Engine(GODOT_CONFIG);
|
||||
|
||||
(function () {
|
||||
const statusOverlay = document.getElementById('status');
|
||||
const statusProgress = document.getElementById('status-progress');
|
||||
const statusNotice = document.getElementById('status-notice');
|
||||
|
||||
let initializing = true;
|
||||
let statusMode = '';
|
||||
|
||||
function setStatusMode(mode) {
|
||||
if (statusMode === mode || !initializing) {
|
||||
return;
|
||||
}
|
||||
if (mode === 'hidden') {
|
||||
statusOverlay.remove();
|
||||
initializing = false;
|
||||
return;
|
||||
}
|
||||
statusOverlay.style.visibility = 'visible';
|
||||
statusProgress.style.display = mode === 'progress' ? 'block' : 'none';
|
||||
statusNotice.style.display = mode === 'notice' ? 'block' : 'none';
|
||||
statusMode = mode;
|
||||
}
|
||||
|
||||
function setStatusNotice(text) {
|
||||
while (statusNotice.lastChild) {
|
||||
statusNotice.removeChild(statusNotice.lastChild);
|
||||
}
|
||||
const lines = text.split('\n');
|
||||
lines.forEach((line) => {
|
||||
statusNotice.appendChild(document.createTextNode(line));
|
||||
statusNotice.appendChild(document.createElement('br'));
|
||||
});
|
||||
}
|
||||
|
||||
function displayFailureNotice(err) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
setStatusNotice(err.message);
|
||||
} else if (typeof err === 'string') {
|
||||
setStatusNotice(err);
|
||||
} else {
|
||||
setStatusNotice('An unknown error occured');
|
||||
}
|
||||
setStatusMode('notice');
|
||||
initializing = false;
|
||||
}
|
||||
|
||||
const missing = Engine.getMissingFeatures({
|
||||
threads: GODOT_THREADS_ENABLED,
|
||||
});
|
||||
|
||||
if (missing.length !== 0) {
|
||||
if (GODOT_CONFIG['serviceWorker'] && GODOT_CONFIG['ensureCrossOriginIsolationHeaders'] && 'serviceWorker' in navigator) {
|
||||
// There's a chance that installing the service worker would fix the issue
|
||||
Promise.race([
|
||||
navigator.serviceWorker.getRegistration().then((registration) => {
|
||||
if (registration != null) {
|
||||
return Promise.reject(new Error('Service worker already exists.'));
|
||||
}
|
||||
return registration;
|
||||
}).then(() => engine.installServiceWorker()),
|
||||
// For some reason, `getRegistration()` can stall
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(), 2000);
|
||||
}),
|
||||
]).catch((err) => {
|
||||
console.error('Error while registering service worker:', err);
|
||||
}).then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
} else {
|
||||
// Display the message as usual
|
||||
const missingMsg = 'Error\nThe following features required to run Godot projects on the Web are missing:\n';
|
||||
displayFailureNotice(missingMsg + missing.join('\n'));
|
||||
}
|
||||
} else {
|
||||
setStatusMode('progress');
|
||||
engine.startGame({
|
||||
'onProgress': function (current, total) {
|
||||
if (current > 0 && total > 0) {
|
||||
statusProgress.value = current;
|
||||
statusProgress.max = total;
|
||||
} else {
|
||||
statusProgress.removeAttribute('value');
|
||||
statusProgress.removeAttribute('max');
|
||||
}
|
||||
},
|
||||
}).then(() => {
|
||||
setStatusMode('hidden');
|
||||
}, displayFailureNotice);
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 KiB |
@@ -1,34 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c8gblkbcsrrwo"
|
||||
path="res://.godot/imported/index.icon.png-c615af856eabc03ca93d51f6a8f07b60.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://web/index.icon.png"
|
||||
dest_files=["res://.godot/imported/index.icon.png-c615af856eabc03ca93d51f6a8f07b60.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -1,908 +0,0 @@
|
||||
|
||||
var Godot = (() => {
|
||||
var _scriptName = typeof document != 'undefined' ? document.currentScript?.src : undefined;
|
||||
|
||||
return (
|
||||
function(moduleArg = {}) {
|
||||
var moduleRtn;
|
||||
|
||||
var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});["_memory","__Z14godot_web_mainiPPc","__emwebxr_on_input_event","__emwebxr_on_simple_event","___indirect_function_table","_main","onRuntimeInitialized"].forEach(prop=>{if(!Object.getOwnPropertyDescriptor(readyPromise,prop)){Object.defineProperty(readyPromise,prop,{get:()=>abort("You are getting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js"),set:()=>abort("You are setting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js")})}});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string";var ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;if(Module["ENVIRONMENT"]){throw new Error("Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option (for example, -sENVIRONMENT=web or -sENVIRONMENT=node)")}var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_SHELL){if(typeof process=="object"&&typeof require==="function"||typeof window=="object"||typeof importScripts=="function")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)")}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}if(!(typeof window=="object"||typeof importScripts=="function"))throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>{assert(!isFileURI(url),"readAsync does not work with file:// URLs");return fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}}else{throw new Error("environment detection error")}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;checkIncomingModuleAPI();if(Module["arguments"])arguments_=Module["arguments"];legacyModuleProp("arguments","arguments_");if(Module["thisProgram"])thisProgram=Module["thisProgram"];legacyModuleProp("thisProgram","thisProgram");if(Module["quit"])quit_=Module["quit"];legacyModuleProp("quit","quit_");assert(typeof Module["memoryInitializerPrefixURL"]=="undefined","Module.memoryInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["pthreadMainPrefixURL"]=="undefined","Module.pthreadMainPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["cdInitializerPrefixURL"]=="undefined","Module.cdInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["filePackagePrefixURL"]=="undefined","Module.filePackagePrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["read"]=="undefined","Module.read option was removed");assert(typeof Module["readAsync"]=="undefined","Module.readAsync option was removed (modify readAsync in JS)");assert(typeof Module["readBinary"]=="undefined","Module.readBinary option was removed (modify readBinary in JS)");assert(typeof Module["setWindowTitle"]=="undefined","Module.setWindowTitle option was removed (modify emscripten_set_window_title in JS)");assert(typeof Module["TOTAL_MEMORY"]=="undefined","Module.TOTAL_MEMORY has been renamed Module.INITIAL_MEMORY");legacyModuleProp("asm","wasmExports");legacyModuleProp("readAsync","readAsync");legacyModuleProp("readBinary","readBinary");legacyModuleProp("setWindowTitle","setWindowTitle");assert(!ENVIRONMENT_IS_NODE,"node environment detected buLine truncated
|
||||
|
||||
|
||||
return moduleRtn;
|
||||
}
|
||||
);
|
||||
})();
|
||||
if (typeof exports === 'object' && typeof module === 'object')
|
||||
module.exports = Godot;
|
||||
else if (typeof define === 'function' && define['amd'])
|
||||
define([], () => Godot);
|
||||
|
||||
const Features = {
|
||||
/**
|
||||
* Check whether WebGL is available. Optionally, specify a particular version of WebGL to check for.
|
||||
*
|
||||
* @param {number=} [majorVersion=1] The major WebGL version to check for.
|
||||
* @returns {boolean} If the given major version of WebGL is available.
|
||||
* @function Engine.isWebGLAvailable
|
||||
*/
|
||||
isWebGLAvailable: function (majorVersion = 1) {
|
||||
try {
|
||||
return !!document.createElement('canvas').getContext(['webgl', 'webgl2'][majorVersion - 1]);
|
||||
} catch (e) { /* Not available */ }
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether the Fetch API available and supports streaming responses.
|
||||
*
|
||||
* @returns {boolean} If the Fetch API is available and supports streaming responses.
|
||||
* @function Engine.isFetchAvailable
|
||||
*/
|
||||
isFetchAvailable: function () {
|
||||
return 'fetch' in window && 'Response' in window && 'body' in window.Response.prototype;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether the engine is running in a Secure Context.
|
||||
*
|
||||
* @returns {boolean} If the engine is running in a Secure Context.
|
||||
* @function Engine.isSecureContext
|
||||
*/
|
||||
isSecureContext: function () {
|
||||
return window['isSecureContext'] === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether the engine is cross origin isolated.
|
||||
* This value is dependent on Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers sent by the server.
|
||||
*
|
||||
* @returns {boolean} If the engine is running in a Secure Context.
|
||||
* @function Engine.isSecureContext
|
||||
*/
|
||||
isCrossOriginIsolated: function () {
|
||||
return window['crossOriginIsolated'] === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether SharedBufferArray is available.
|
||||
*
|
||||
* Most browsers require the page to be running in a secure context, and the
|
||||
* the server to provide specific CORS headers for SharedArrayBuffer to be available.
|
||||
*
|
||||
* @returns {boolean} If SharedArrayBuffer is available.
|
||||
* @function Engine.isSharedArrayBufferAvailable
|
||||
*/
|
||||
isSharedArrayBufferAvailable: function () {
|
||||
return 'SharedArrayBuffer' in window;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether the AudioContext supports AudioWorkletNodes.
|
||||
*
|
||||
* @returns {boolean} If AudioWorkletNode is available.
|
||||
* @function Engine.isAudioWorkletAvailable
|
||||
*/
|
||||
isAudioWorkletAvailable: function () {
|
||||
return 'AudioContext' in window && 'audioWorklet' in AudioContext.prototype;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return an array of missing required features (as string).
|
||||
*
|
||||
* @returns {Array<string>} A list of human-readable missing features.
|
||||
* @function Engine.getMissingFeatures
|
||||
* @param {{threads: (boolean|undefined)}} supportedFeatures
|
||||
*/
|
||||
getMissingFeatures: function (supportedFeatures = {}) {
|
||||
const {
|
||||
// Quotes are needed for the Closure compiler.
|
||||
'threads': supportsThreads = true,
|
||||
} = supportedFeatures;
|
||||
|
||||
const missing = [];
|
||||
if (!Features.isWebGLAvailable(2)) {
|
||||
missing.push('WebGL2 - Check web browser configuration and hardware support');
|
||||
}
|
||||
if (!Features.isFetchAvailable()) {
|
||||
missing.push('Fetch - Check web browser version');
|
||||
}
|
||||
if (!Features.isSecureContext()) {
|
||||
missing.push('Secure Context - Check web server configuration (use HTTPS)');
|
||||
}
|
||||
|
||||
if (supportsThreads) {
|
||||
if (!Features.isCrossOriginIsolated()) {
|
||||
missing.push('Cross-Origin Isolation - Check that the web server configuration sends the correct headers.');
|
||||
}
|
||||
if (!Features.isSharedArrayBufferAvailable()) {
|
||||
missing.push('SharedArrayBuffer - Check that the web server configuration sends the correct headers.');
|
||||
}
|
||||
}
|
||||
|
||||
// Audio is normally optional since we have a dummy fallback.
|
||||
return missing;
|
||||
},
|
||||
};
|
||||
|
||||
const Preloader = /** @constructor */ function () { // eslint-disable-line no-unused-vars
|
||||
function getTrackedResponse(response, load_status) {
|
||||
function onloadprogress(reader, controller) {
|
||||
return reader.read().then(function (result) {
|
||||
if (load_status.done) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (result.value) {
|
||||
controller.enqueue(result.value);
|
||||
load_status.loaded += result.value.length;
|
||||
}
|
||||
if (!result.done) {
|
||||
return onloadprogress(reader, controller);
|
||||
}
|
||||
load_status.done = true;
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
return new Response(new ReadableStream({
|
||||
start: function (controller) {
|
||||
onloadprogress(reader, controller).then(function () {
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
}), { headers: response.headers });
|
||||
}
|
||||
|
||||
function loadFetch(file, tracker, fileSize, raw) {
|
||||
tracker[file] = {
|
||||
total: fileSize || 0,
|
||||
loaded: 0,
|
||||
done: false,
|
||||
};
|
||||
return fetch(file).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed loading file '${file}'`));
|
||||
}
|
||||
const tr = getTrackedResponse(response, tracker[file]);
|
||||
if (raw) {
|
||||
return Promise.resolve(tr);
|
||||
}
|
||||
return tr.arrayBuffer();
|
||||
});
|
||||
}
|
||||
|
||||
function retry(func, attempts = 1) {
|
||||
function onerror(err) {
|
||||
if (attempts <= 1) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
setTimeout(function () {
|
||||
retry(func, attempts - 1).then(resolve).catch(reject);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
return func().catch(onerror);
|
||||
}
|
||||
|
||||
const DOWNLOAD_ATTEMPTS_MAX = 4;
|
||||
const loadingFiles = {};
|
||||
const lastProgress = { loaded: 0, total: 0 };
|
||||
let progressFunc = null;
|
||||
|
||||
const animateProgress = function () {
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
let totalIsValid = true;
|
||||
let progressIsFinal = true;
|
||||
|
||||
Object.keys(loadingFiles).forEach(function (file) {
|
||||
const stat = loadingFiles[file];
|
||||
if (!stat.done) {
|
||||
progressIsFinal = false;
|
||||
}
|
||||
if (!totalIsValid || stat.total === 0) {
|
||||
totalIsValid = false;
|
||||
total = 0;
|
||||
} else {
|
||||
total += stat.total;
|
||||
}
|
||||
loaded += stat.loaded;
|
||||
});
|
||||
if (loaded !== lastProgress.loaded || total !== lastProgress.total) {
|
||||
lastProgress.loaded = loaded;
|
||||
lastProgress.total = total;
|
||||
if (typeof progressFunc === 'function') {
|
||||
progressFunc(loaded, total);
|
||||
}
|
||||
}
|
||||
if (!progressIsFinal) {
|
||||
requestAnimationFrame(animateProgress);
|
||||
}
|
||||
};
|
||||
|
||||
this.animateProgress = animateProgress;
|
||||
|
||||
this.setProgressFunc = function (callback) {
|
||||
progressFunc = callback;
|
||||
};
|
||||
|
||||
this.loadPromise = function (file, fileSize, raw = false) {
|
||||
return retry(loadFetch.bind(null, file, loadingFiles, fileSize, raw), DOWNLOAD_ATTEMPTS_MAX);
|
||||
};
|
||||
|
||||
this.preloadedFiles = [];
|
||||
this.preload = function (pathOrBuffer, destPath, fileSize) {
|
||||
let buffer = null;
|
||||
if (typeof pathOrBuffer === 'string') {
|
||||
const me = this;
|
||||
return this.loadPromise(pathOrBuffer, fileSize).then(function (buf) {
|
||||
me.preloadedFiles.push({
|
||||
path: destPath || pathOrBuffer,
|
||||
buffer: buf,
|
||||
});
|
||||
return Promise.resolve();
|
||||
});
|
||||
} else if (pathOrBuffer instanceof ArrayBuffer) {
|
||||
buffer = new Uint8Array(pathOrBuffer);
|
||||
} else if (ArrayBuffer.isView(pathOrBuffer)) {
|
||||
buffer = new Uint8Array(pathOrBuffer.buffer);
|
||||
}
|
||||
if (buffer) {
|
||||
this.preloadedFiles.push({
|
||||
path: destPath,
|
||||
buffer: pathOrBuffer,
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('Invalid object for preloading'));
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* An object used to configure the Engine instance based on godot export options, and to override those in custom HTML
|
||||
* templates if needed.
|
||||
*
|
||||
* @header Engine configuration
|
||||
* @summary The Engine configuration object. This is just a typedef, create it like a regular object, e.g.:
|
||||
*
|
||||
* ``const MyConfig = { executable: 'godot', unloadAfterInit: false }``
|
||||
*
|
||||
* @typedef {Object} EngineConfig
|
||||
*/
|
||||
const EngineConfig = {}; // eslint-disable-line no-unused-vars
|
||||
|
||||
/**
|
||||
* @struct
|
||||
* @constructor
|
||||
* @ignore
|
||||
*/
|
||||
const InternalConfig = function (initConfig) { // eslint-disable-line no-unused-vars
|
||||
const cfg = /** @lends {InternalConfig.prototype} */ {
|
||||
/**
|
||||
* Whether to unload the engine automatically after the instance is initialized.
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @default
|
||||
* @type {boolean}
|
||||
*/
|
||||
unloadAfterInit: true,
|
||||
/**
|
||||
* The HTML DOM Canvas object to use.
|
||||
*
|
||||
* By default, the first canvas element in the document will be used is none is specified.
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @default
|
||||
* @type {?HTMLCanvasElement}
|
||||
*/
|
||||
canvas: null,
|
||||
/**
|
||||
* The name of the WASM file without the extension. (Set by Godot Editor export process).
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @default
|
||||
* @type {string}
|
||||
*/
|
||||
executable: '',
|
||||
/**
|
||||
* An alternative name for the game pck to load. The executable name is used otherwise.
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @default
|
||||
* @type {?string}
|
||||
*/
|
||||
mainPack: null,
|
||||
/**
|
||||
* Specify a language code to select the proper localization for the game.
|
||||
*
|
||||
* The browser locale will be used if none is specified. See complete list of
|
||||
* :ref:`supported locales <doc_locales>`.
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @type {?string}
|
||||
* @default
|
||||
*/
|
||||
locale: null,
|
||||
/**
|
||||
* The canvas resize policy determines how the canvas should be resized by Godot.
|
||||
*
|
||||
* ``0`` means Godot won't do any resizing. This is useful if you want to control the canvas size from
|
||||
* javascript code in your template.
|
||||
*
|
||||
* ``1`` means Godot will resize the canvas on start, and when changing window size via engine functions.
|
||||
*
|
||||
* ``2`` means Godot will adapt the canvas size to match the whole browser window.
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @type {number}
|
||||
* @default
|
||||
*/
|
||||
canvasResizePolicy: 2,
|
||||
/**
|
||||
* The arguments to be passed as command line arguments on startup.
|
||||
*
|
||||
* See :ref:`command line tutorial <doc_command_line_tutorial>`.
|
||||
*
|
||||
* **Note**: :js:meth:`startGame <Engine.prototype.startGame>` will always add the ``--main-pack`` argument.
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @type {Array<string>}
|
||||
* @default
|
||||
*/
|
||||
args: [],
|
||||
/**
|
||||
* When enabled, the game canvas will automatically grab the focus when the engine starts.
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @type {boolean}
|
||||
* @default
|
||||
*/
|
||||
focusCanvas: true,
|
||||
/**
|
||||
* When enabled, this will turn on experimental virtual keyboard support on mobile.
|
||||
*
|
||||
* @memberof EngineConfig
|
||||
* @type {boolean}
|
||||
* @default
|
||||
*/
|
||||
experimentalVK: false,
|
||||
/**
|
||||
* The progressive web app service worker to install.
|
||||
* @memberof EngineConfig
|
||||
* @default
|
||||
* @type {string}
|
||||
*/
|
||||
serviceWorker: '',
|
||||
/**
|
||||
* @ignore
|
||||
* @type {Array.<string>}
|
||||
*/
|
||||
persistentPaths: ['/userfs'],
|
||||
/**
|
||||
* @ignore
|
||||
* @type {boolean}
|
||||
*/
|
||||
persistentDrops: false,
|
||||
/**
|
||||
* @ignore
|
||||
* @type {Array.<string>}
|
||||
*/
|
||||
gdextensionLibs: [],
|
||||
/**
|
||||
* @ignore
|
||||
* @type {Array.<string>}
|
||||
*/
|
||||
fileSizes: [],
|
||||
/**
|
||||
* A callback function for handling Godot's ``OS.execute`` calls.
|
||||
*
|
||||
* This is for example used in the Web Editor template to switch between project manager and editor, and for running the game.
|
||||
*
|
||||
* @callback EngineConfig.onExecute
|
||||
* @param {string} path The path that Godot's wants executed.
|
||||
* @param {Array.<string>} args The arguments of the "command" to execute.
|
||||
*/
|
||||
/**
|
||||
* @ignore
|
||||
* @type {?function(string, Array.<string>)}
|
||||
*/
|
||||
onExecute: null,
|
||||
/**
|
||||
* A callback function for being notified when the Godot instance quits.
|
||||
*
|
||||
* **Note**: This function will not be called if the engine crashes or become unresponsive.
|
||||
*
|
||||
* @callback EngineConfig.onExit
|
||||
* @param {number} status_code The status code returned by Godot on exit.
|
||||
*/
|
||||
/**
|
||||
* @ignore
|
||||
* @type {?function(number)}
|
||||
*/
|
||||
onExit: null,
|
||||
/**
|
||||
* A callback function for displaying download progress.
|
||||
*
|
||||
* The function is called once per frame while downloading files, so the usage of ``requestAnimationFrame()``
|
||||
* is not necessary.
|
||||
*
|
||||
* If the callback function receives a total amount of bytes as 0, this means that it is impossible to calculate.
|
||||
* Possible reasons include:
|
||||
*
|
||||
* - Files are delivered with server-side chunked compression
|
||||
* - Files are delivered with server-side compression on Chromium
|
||||
* - Not all file downloads have started yet (usually on servers without multi-threading)
|
||||
*
|
||||
* @callback EngineConfig.onProgress
|
||||
* @param {number} current The current amount of downloaded bytes so far.
|
||||
* @param {number} total The total amount of bytes to be downloaded.
|
||||
*/
|
||||
/**
|
||||
* @ignore
|
||||
* @type {?function(number, number)}
|
||||
*/
|
||||
onProgress: null,
|
||||
/**
|
||||
* A callback function for handling the standard output stream. This method should usually only be used in debug pages.
|
||||
*
|
||||
* By default, ``console.log()`` is used.
|
||||
*
|
||||
* @callback EngineConfig.onPrint
|
||||
* @param {...*} [var_args] A variadic number of arguments to be printed.
|
||||
*/
|
||||
/**
|
||||
* @ignore
|
||||
* @type {?function(...*)}
|
||||
*/
|
||||
onPrint: function () {
|
||||
console.log.apply(console, Array.from(arguments)); // eslint-disable-line no-console
|
||||
},
|
||||
/**
|
||||
* A callback function for handling the standard error stream. This method should usually only be used in debug pages.
|
||||
*
|
||||
* By default, ``console.error()`` is used.
|
||||
*
|
||||
* @callback EngineConfig.onPrintError
|
||||
* @param {...*} [var_args] A variadic number of arguments to be printed as errors.
|
||||
*/
|
||||
/**
|
||||
* @ignore
|
||||
* @type {?function(...*)}
|
||||
*/
|
||||
onPrintError: function (var_args) {
|
||||
console.error.apply(console, Array.from(arguments)); // eslint-disable-line no-console
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
* @struct
|
||||
* @constructor
|
||||
* @param {EngineConfig} opts
|
||||
*/
|
||||
function Config(opts) {
|
||||
this.update(opts);
|
||||
}
|
||||
|
||||
Config.prototype = cfg;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
* @param {EngineConfig} opts
|
||||
*/
|
||||
Config.prototype.update = function (opts) {
|
||||
const config = opts || {};
|
||||
// NOTE: We must explicitly pass the default, accessing it via
|
||||
// the key will fail due to closure compiler renames.
|
||||
function parse(key, def) {
|
||||
if (typeof (config[key]) === 'undefined') {
|
||||
return def;
|
||||
}
|
||||
return config[key];
|
||||
}
|
||||
// Module config
|
||||
this.unloadAfterInit = parse('unloadAfterInit', this.unloadAfterInit);
|
||||
this.onPrintError = parse('onPrintError', this.onPrintError);
|
||||
this.onPrint = parse('onPrint', this.onPrint);
|
||||
this.onProgress = parse('onProgress', this.onProgress);
|
||||
|
||||
// Godot config
|
||||
this.canvas = parse('canvas', this.canvas);
|
||||
this.executable = parse('executable', this.executable);
|
||||
this.mainPack = parse('mainPack', this.mainPack);
|
||||
this.locale = parse('locale', this.locale);
|
||||
this.canvasResizePolicy = parse('canvasResizePolicy', this.canvasResizePolicy);
|
||||
this.persistentPaths = parse('persistentPaths', this.persistentPaths);
|
||||
this.persistentDrops = parse('persistentDrops', this.persistentDrops);
|
||||
this.experimentalVK = parse('experimentalVK', this.experimentalVK);
|
||||
this.focusCanvas = parse('focusCanvas', this.focusCanvas);
|
||||
this.serviceWorker = parse('serviceWorker', this.serviceWorker);
|
||||
this.gdextensionLibs = parse('gdextensionLibs', this.gdextensionLibs);
|
||||
this.fileSizes = parse('fileSizes', this.fileSizes);
|
||||
this.args = parse('args', this.args);
|
||||
this.onExecute = parse('onExecute', this.onExecute);
|
||||
this.onExit = parse('onExit', this.onExit);
|
||||
};
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
* @param {string} loadPath
|
||||
* @param {Response} response
|
||||
*/
|
||||
Config.prototype.getModuleConfig = function (loadPath, response) {
|
||||
let r = response;
|
||||
const gdext = this.gdextensionLibs;
|
||||
return {
|
||||
'print': this.onPrint,
|
||||
'printErr': this.onPrintError,
|
||||
'thisProgram': this.executable,
|
||||
'noExitRuntime': false,
|
||||
'dynamicLibraries': [`${loadPath}.side.wasm`].concat(this.gdextensionLibs),
|
||||
'instantiateWasm': function (imports, onSuccess) {
|
||||
function done(result) {
|
||||
onSuccess(result['instance'], result['module']);
|
||||
}
|
||||
if (typeof (WebAssembly.instantiateStreaming) !== 'undefined') {
|
||||
WebAssembly.instantiateStreaming(Promise.resolve(r), imports).then(done);
|
||||
} else {
|
||||
r.arrayBuffer().then(function (buffer) {
|
||||
WebAssembly.instantiate(buffer, imports).then(done);
|
||||
});
|
||||
}
|
||||
r = null;
|
||||
return {};
|
||||
},
|
||||
'locateFile': function (path) {
|
||||
if (!path.startsWith('godot.')) {
|
||||
return path;
|
||||
} else if (path.endsWith('.worker.js')) {
|
||||
return `${loadPath}.worker.js`;
|
||||
} else if (path.endsWith('.audio.worklet.js')) {
|
||||
return `${loadPath}.audio.worklet.js`;
|
||||
} else if (path.endsWith('.js')) {
|
||||
return `${loadPath}.js`;
|
||||
} else if (path in gdext) {
|
||||
return path;
|
||||
} else if (path.endsWith('.side.wasm')) {
|
||||
return `${loadPath}.side.wasm`;
|
||||
} else if (path.endsWith('.wasm')) {
|
||||
return `${loadPath}.wasm`;
|
||||
}
|
||||
return path;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
* @param {function()} cleanup
|
||||
*/
|
||||
Config.prototype.getGodotConfig = function (cleanup) {
|
||||
// Try to find a canvas
|
||||
if (!(this.canvas instanceof HTMLCanvasElement)) {
|
||||
const nodes = document.getElementsByTagName('canvas');
|
||||
if (nodes.length && nodes[0] instanceof HTMLCanvasElement) {
|
||||
const first = nodes[0];
|
||||
this.canvas = /** @type {!HTMLCanvasElement} */ (first);
|
||||
}
|
||||
if (!this.canvas) {
|
||||
throw new Error('No canvas found in page');
|
||||
}
|
||||
}
|
||||
// Canvas can grab focus on click, or key events won't work.
|
||||
if (this.canvas.tabIndex < 0) {
|
||||
this.canvas.tabIndex = 0;
|
||||
}
|
||||
|
||||
// Browser locale, or custom one if defined.
|
||||
let locale = this.locale;
|
||||
if (!locale) {
|
||||
locale = navigator.languages ? navigator.languages[0] : navigator.language;
|
||||
locale = locale.split('.')[0];
|
||||
}
|
||||
locale = locale.replace('-', '_');
|
||||
const onExit = this.onExit;
|
||||
|
||||
// Godot configuration.
|
||||
return {
|
||||
'canvas': this.canvas,
|
||||
'canvasResizePolicy': this.canvasResizePolicy,
|
||||
'locale': locale,
|
||||
'persistentDrops': this.persistentDrops,
|
||||
'virtualKeyboard': this.experimentalVK,
|
||||
'focusCanvas': this.focusCanvas,
|
||||
'onExecute': this.onExecute,
|
||||
'onExit': function (p_code) {
|
||||
cleanup(); // We always need to call the cleanup callback to free memory.
|
||||
if (typeof (onExit) === 'function') {
|
||||
onExit(p_code);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
return new Config(initConfig);
|
||||
};
|
||||
|
||||
/**
|
||||
* Projects exported for the Web expose the :js:class:`Engine` class to the JavaScript environment, that allows
|
||||
* fine control over the engine's start-up process.
|
||||
*
|
||||
* This API is built in an asynchronous manner and requires basic understanding
|
||||
* of `Promises <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises>`__.
|
||||
*
|
||||
* @module Engine
|
||||
* @header Web export JavaScript reference
|
||||
*/
|
||||
const Engine = (function () {
|
||||
const preloader = new Preloader();
|
||||
|
||||
let loadPromise = null;
|
||||
let loadPath = '';
|
||||
let initPromise = null;
|
||||
|
||||
/**
|
||||
* @classdesc The ``Engine`` class provides methods for loading and starting exported projects on the Web. For default export
|
||||
* settings, this is already part of the exported HTML page. To understand practical use of the ``Engine`` class,
|
||||
* see :ref:`Custom HTML page for Web export <doc_customizing_html5_shell>`.
|
||||
*
|
||||
* @description Create a new Engine instance with the given configuration.
|
||||
*
|
||||
* @global
|
||||
* @constructor
|
||||
* @param {EngineConfig} initConfig The initial config for this instance.
|
||||
*/
|
||||
function Engine(initConfig) { // eslint-disable-line no-shadow
|
||||
this.config = new InternalConfig(initConfig);
|
||||
this.rtenv = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the engine from the specified base path.
|
||||
*
|
||||
* @param {string} basePath Base path of the engine to load.
|
||||
* @param {number=} [size=0] The file size if known.
|
||||
* @returns {Promise} A Promise that resolves once the engine is loaded.
|
||||
*
|
||||
* @function Engine.load
|
||||
*/
|
||||
Engine.load = function (basePath, size) {
|
||||
if (loadPromise == null) {
|
||||
loadPath = basePath;
|
||||
loadPromise = preloader.loadPromise(`${loadPath}.wasm`, size, true);
|
||||
requestAnimationFrame(preloader.animateProgress);
|
||||
}
|
||||
return loadPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unload the engine to free memory.
|
||||
*
|
||||
* This method will be called automatically depending on the configuration. See :js:attr:`unloadAfterInit`.
|
||||
*
|
||||
* @function Engine.unload
|
||||
*/
|
||||
Engine.unload = function () {
|
||||
loadPromise = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe Engine constructor, creates a new prototype for every new instance to avoid prototype pollution.
|
||||
* @ignore
|
||||
* @constructor
|
||||
*/
|
||||
function SafeEngine(initConfig) {
|
||||
const proto = /** @lends Engine.prototype */ {
|
||||
/**
|
||||
* Initialize the engine instance. Optionally, pass the base path to the engine to load it,
|
||||
* if it hasn't been loaded yet. See :js:meth:`Engine.load`.
|
||||
*
|
||||
* @param {string=} basePath Base path of the engine to load.
|
||||
* @return {Promise} A ``Promise`` that resolves once the engine is loaded and initialized.
|
||||
*/
|
||||
init: function (basePath) {
|
||||
if (initPromise) {
|
||||
return initPromise;
|
||||
}
|
||||
if (loadPromise == null) {
|
||||
if (!basePath) {
|
||||
initPromise = Promise.reject(new Error('A base path must be provided when calling `init` and the engine is not loaded.'));
|
||||
return initPromise;
|
||||
}
|
||||
Engine.load(basePath, this.config.fileSizes[`${basePath}.wasm`]);
|
||||
}
|
||||
const me = this;
|
||||
function doInit(promise) {
|
||||
// Care! Promise chaining is bogus with old emscripten versions.
|
||||
// This caused a regression with the Mono build (which uses an older emscripten version).
|
||||
// Make sure to test that when refactoring.
|
||||
return new Promise(function (resolve, reject) {
|
||||
promise.then(function (response) {
|
||||
const cloned = new Response(response.clone().body, { 'headers': [['content-type', 'application/wasm']] });
|
||||
Godot(me.config.getModuleConfig(loadPath, cloned)).then(function (module) {
|
||||
const paths = me.config.persistentPaths;
|
||||
module['initFS'](paths).then(function (err) {
|
||||
me.rtenv = module;
|
||||
if (me.config.unloadAfterInit) {
|
||||
Engine.unload();
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
preloader.setProgressFunc(this.config.onProgress);
|
||||
initPromise = doInit(loadPromise);
|
||||
return initPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a file so it is available in the instance's file system once it runs. Must be called **before** starting the
|
||||
* instance.
|
||||
*
|
||||
* If not provided, the ``path`` is derived from the URL of the loaded file.
|
||||
*
|
||||
* @param {string|ArrayBuffer} file The file to preload.
|
||||
*
|
||||
* If a ``string`` the file will be loaded from that path.
|
||||
*
|
||||
* If an ``ArrayBuffer`` or a view on one, the buffer will used as the content of the file.
|
||||
*
|
||||
* @param {string=} path Path by which the file will be accessible. Required, if ``file`` is not a string.
|
||||
*
|
||||
* @returns {Promise} A Promise that resolves once the file is loaded.
|
||||
*/
|
||||
preloadFile: function (file, path) {
|
||||
return preloader.preload(file, path, this.config.fileSizes[file]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the engine instance using the given override configuration (if any).
|
||||
* :js:meth:`startGame <Engine.prototype.startGame>` can be used in typical cases instead.
|
||||
*
|
||||
* This will initialize the instance if it is not initialized. For manual initialization, see :js:meth:`init <Engine.prototype.init>`.
|
||||
* The engine must be loaded beforehand.
|
||||
*
|
||||
* Fails if a canvas cannot be found on the page, or not specified in the configuration.
|
||||
*
|
||||
* @param {EngineConfig} override An optional configuration override.
|
||||
* @return {Promise} Promise that resolves once the engine started.
|
||||
*/
|
||||
start: function (override) {
|
||||
this.config.update(override);
|
||||
const me = this;
|
||||
return me.init().then(function () {
|
||||
if (!me.rtenv) {
|
||||
return Promise.reject(new Error('The engine must be initialized before it can be started'));
|
||||
}
|
||||
|
||||
let config = {};
|
||||
try {
|
||||
config = me.config.getGodotConfig(function () {
|
||||
me.rtenv = null;
|
||||
});
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
// Godot configuration.
|
||||
me.rtenv['initConfig'](config);
|
||||
|
||||
// Preload GDExtension libraries.
|
||||
if (me.config.gdextensionLibs.length > 0 && !me.rtenv['loadDynamicLibrary']) {
|
||||
return Promise.reject(new Error('GDExtension libraries are not supported by this engine version. '
|
||||
+ 'Enable "Extensions Support" for your export preset and/or build your custom template with "dlink_enabled=yes".'));
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
for (const file of preloader.preloadedFiles) {
|
||||
me.rtenv['copyToFS'](file.path, file.buffer);
|
||||
}
|
||||
preloader.preloadedFiles.length = 0; // Clear memory
|
||||
me.rtenv['callMain'](me.config.args);
|
||||
initPromise = null;
|
||||
me.installServiceWorker();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the game instance using the given configuration override (if any).
|
||||
*
|
||||
* This will initialize the instance if it is not initialized. For manual initialization, see :js:meth:`init <Engine.prototype.init>`.
|
||||
*
|
||||
* This will load the engine if it is not loaded, and preload the main pck.
|
||||
*
|
||||
* This method expects the initial config (or the override) to have both the :js:attr:`executable` and :js:attr:`mainPack`
|
||||
* properties set (normally done by the editor during export).
|
||||
*
|
||||
* @param {EngineConfig} override An optional configuration override.
|
||||
* @return {Promise} Promise that resolves once the game started.
|
||||
*/
|
||||
startGame: function (override) {
|
||||
this.config.update(override);
|
||||
// Add main-pack argument.
|
||||
const exe = this.config.executable;
|
||||
const pack = this.config.mainPack || `${exe}.pck`;
|
||||
this.config.args = ['--main-pack', pack].concat(this.config.args);
|
||||
// Start and init with execName as loadPath if not inited.
|
||||
const me = this;
|
||||
return Promise.all([
|
||||
this.init(exe),
|
||||
this.preloadFile(pack, pack),
|
||||
]).then(function () {
|
||||
return me.start.apply(me);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a file at the specified ``path`` with the passed as ``buffer`` in the instance's file system.
|
||||
*
|
||||
* @param {string} path The location where the file will be created.
|
||||
* @param {ArrayBuffer} buffer The content of the file.
|
||||
*/
|
||||
copyToFS: function (path, buffer) {
|
||||
if (this.rtenv == null) {
|
||||
throw new Error('Engine must be inited before copying files');
|
||||
}
|
||||
this.rtenv['copyToFS'](path, buffer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Request that the current instance quit.
|
||||
*
|
||||
* This is akin the user pressing the close button in the window manager, and will
|
||||
* have no effect if the engine has crashed, or is stuck in a loop.
|
||||
*
|
||||
*/
|
||||
requestQuit: function () {
|
||||
if (this.rtenv) {
|
||||
this.rtenv['request_quit']();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Install the progressive-web app service worker.
|
||||
* @returns {Promise} The service worker registration promise.
|
||||
*/
|
||||
installServiceWorker: function () {
|
||||
if (this.config.serviceWorker && 'serviceWorker' in navigator) {
|
||||
return navigator.serviceWorker.register(this.config.serviceWorker);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
Engine.prototype = proto;
|
||||
// Closure compiler exported instance methods.
|
||||
Engine.prototype['init'] = Engine.prototype.init;
|
||||
Engine.prototype['preloadFile'] = Engine.prototype.preloadFile;
|
||||
Engine.prototype['start'] = Engine.prototype.start;
|
||||
Engine.prototype['startGame'] = Engine.prototype.startGame;
|
||||
Engine.prototype['copyToFS'] = Engine.prototype.copyToFS;
|
||||
Engine.prototype['requestQuit'] = Engine.prototype.requestQuit;
|
||||
Engine.prototype['installServiceWorker'] = Engine.prototype.installServiceWorker;
|
||||
// Also expose static methods as instance methods
|
||||
Engine.prototype['load'] = Engine.load;
|
||||
Engine.prototype['unload'] = Engine.unload;
|
||||
return new Engine(initConfig);
|
||||
}
|
||||
|
||||
// Closure compiler exported static methods.
|
||||
SafeEngine['load'] = Engine.load;
|
||||
SafeEngine['unload'] = Engine.unload;
|
||||
|
||||
// Feature-detection utilities.
|
||||
SafeEngine['isWebGLAvailable'] = Features.isWebGLAvailable;
|
||||
SafeEngine['isFetchAvailable'] = Features.isFetchAvailable;
|
||||
SafeEngine['isSecureContext'] = Features.isSecureContext;
|
||||
SafeEngine['isCrossOriginIsolated'] = Features.isCrossOriginIsolated;
|
||||
SafeEngine['isSharedArrayBufferAvailable'] = Features.isSharedArrayBufferAvailable;
|
||||
SafeEngine['isAudioWorkletAvailable'] = Features.isAudioWorkletAvailable;
|
||||
SafeEngine['getMissingFeatures'] = Features.getMissingFeatures;
|
||||
|
||||
return SafeEngine;
|
||||
}());
|
||||
if (typeof window !== 'undefined') {
|
||||
window['Engine'] = Engine;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"background_color":"#000000","display":"standalone","icons":[{"sizes":"144x144","src":"index.144x144.png","type":"image/png"},{"sizes":"180x180","src":"index.180x180.png","type":"image/png"},{"sizes":"512x512","src":"index.512x512.png","type":"image/png"}],"name":"MusicSearch","orientation":"any","start_url":"./index.html"}
|
||||
@@ -1,41 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>You are offline</title>
|
||||
<style>
|
||||
html {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
padding: 1rem 2rem;
|
||||
margin: 3rem auto 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>You are offline</h1>
|
||||
<p>This application requires an Internet connection to run for the first time.</p>
|
||||
<p>Press the button below to try reloading:</p>
|
||||
<button type="button">Reload</button>
|
||||
<script>
|
||||
document.querySelector('button').addEventListener('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
@@ -1,34 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bxh15ilo8hoq"
|
||||
path="res://.godot/imported/index.png-80964ad67552d78b1e33f58ad16188ff.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://web/index.png"
|
||||
dest_files=["res://.godot/imported/index.png-80964ad67552d78b1e33f58ad16188ff.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -1,166 +0,0 @@
|
||||
// This service worker is required to expose an exported Godot project as a
|
||||
// Progressive Web App. It provides an offline fallback page telling the user
|
||||
// that they need an Internet connection to run the project if desired.
|
||||
// Incrementing CACHE_VERSION will kick off the install event and force
|
||||
// previously cached resources to be updated from the network.
|
||||
/** @type {string} */
|
||||
const CACHE_VERSION = '1726998131|1051448710';
|
||||
/** @type {string} */
|
||||
const CACHE_PREFIX = 'MusicSearch-sw-cache-';
|
||||
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
|
||||
/** @type {string} */
|
||||
const OFFLINE_URL = 'index.offline.html';
|
||||
/** @type {boolean} */
|
||||
const ENSURE_CROSSORIGIN_ISOLATION_HEADERS = false;
|
||||
// Files that will be cached on load.
|
||||
/** @type {string[]} */
|
||||
const CACHED_FILES = ["index.html","index.js","index.offline.html","index.icon.png","index.apple-touch-icon.png","index.worker.js","index.audio.worklet.js"];
|
||||
// Files that we might not want the user to preload, and will only be cached on first load.
|
||||
/** @type {string[]} */
|
||||
const CACHABLE_FILES = ["index.wasm","index.pck"];
|
||||
const FULL_CACHE = CACHED_FILES.concat(CACHABLE_FILES);
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(CACHED_FILES)));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(caches.keys().then(
|
||||
function (keys) {
|
||||
// Remove old caches.
|
||||
return Promise.all(keys.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME).map((key) => caches.delete(key)));
|
||||
}
|
||||
).then(function () {
|
||||
// Enable navigation preload if available.
|
||||
return ('navigationPreload' in self.registration) ? self.registration.navigationPreload.enable() : Promise.resolve();
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that the response has the correct COEP/COOP headers
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function ensureCrossOriginIsolationHeaders(response) {
|
||||
if (response.headers.get('Cross-Origin-Embedder-Policy') === 'require-corp'
|
||||
&& response.headers.get('Cross-Origin-Opener-Policy') === 'same-origin') {
|
||||
return response;
|
||||
}
|
||||
|
||||
const crossOriginIsolatedHeaders = new Headers(response.headers);
|
||||
crossOriginIsolatedHeaders.set('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
crossOriginIsolatedHeaders.set('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
const newResponse = new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: crossOriginIsolatedHeaders,
|
||||
});
|
||||
|
||||
return newResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls fetch and cache the result if it is cacheable
|
||||
* @param {FetchEvent} event
|
||||
* @param {Cache} cache
|
||||
* @param {boolean} isCacheable
|
||||
* @returns {Response}
|
||||
*/
|
||||
async function fetchAndCache(event, cache, isCacheable) {
|
||||
// Use the preloaded response, if it's there
|
||||
/** @type { Response } */
|
||||
let response = await event.preloadResponse;
|
||||
if (response == null) {
|
||||
// Or, go over network.
|
||||
response = await self.fetch(event.request);
|
||||
}
|
||||
|
||||
if (ENSURE_CROSSORIGIN_ISOLATION_HEADERS) {
|
||||
response = ensureCrossOriginIsolationHeaders(response);
|
||||
}
|
||||
|
||||
if (isCacheable) {
|
||||
// And update the cache
|
||||
cache.put(event.request, response.clone());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
self.addEventListener(
|
||||
'fetch',
|
||||
/**
|
||||
* Triggered on fetch
|
||||
* @param {FetchEvent} event
|
||||
*/
|
||||
(event) => {
|
||||
const isNavigate = event.request.mode === 'navigate';
|
||||
const url = event.request.url || '';
|
||||
const referrer = event.request.referrer || '';
|
||||
const base = referrer.slice(0, referrer.lastIndexOf('/') + 1);
|
||||
const local = url.startsWith(base) ? url.replace(base, '') : '';
|
||||
const isCachable = FULL_CACHE.some((v) => v === local) || (base === referrer && base.endsWith(CACHED_FILES[0]));
|
||||
if (isNavigate || isCachable) {
|
||||
event.respondWith((async () => {
|
||||
// Try to use cache first
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
if (isNavigate) {
|
||||
// Check if we have full cache during HTML page request.
|
||||
/** @type {Response[]} */
|
||||
const fullCache = await Promise.all(FULL_CACHE.map((name) => cache.match(name)));
|
||||
const missing = fullCache.some((v) => v === undefined);
|
||||
if (missing) {
|
||||
try {
|
||||
// Try network if some cached file is missing (so we can display offline page in case).
|
||||
const response = await fetchAndCache(event, cache, isCachable);
|
||||
return response;
|
||||
} catch (e) {
|
||||
// And return the hopefully always cached offline page in case of network failure.
|
||||
console.error('Network error: ', e); // eslint-disable-line no-console
|
||||
return caches.match(OFFLINE_URL);
|
||||
}
|
||||
}
|
||||
}
|
||||
let cached = await cache.match(event.request);
|
||||
if (cached != null) {
|
||||
if (ENSURE_CROSSORIGIN_ISOLATION_HEADERS) {
|
||||
cached = ensureCrossOriginIsolationHeaders(cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
// Try network if don't have it in cache.
|
||||
const response = await fetchAndCache(event, cache, isCachable);
|
||||
return response;
|
||||
})());
|
||||
} else if (ENSURE_CROSSORIGIN_ISOLATION_HEADERS) {
|
||||
event.respondWith((async () => {
|
||||
let response = await fetch(event.request);
|
||||
response = ensureCrossOriginIsolationHeaders(response);
|
||||
return response;
|
||||
})());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
// No cross origin
|
||||
if (event.origin !== self.origin) {
|
||||
return;
|
||||
}
|
||||
const id = event.source.id || '';
|
||||
const msg = event.data || '';
|
||||
// Ensure it's one of our clients.
|
||||
self.clients.get(id).then(function (client) {
|
||||
if (!client) {
|
||||
return; // Not a valid client.
|
||||
}
|
||||
if (msg === 'claim') {
|
||||
self.skipWaiting().then(() => self.clients.claim());
|
||||
} else if (msg === 'clear') {
|
||||
caches.delete(CACHE_NAME);
|
||||
} else if (msg === 'update') {
|
||||
self.skipWaiting().then(() => self.clients.claim()).then(() => self.clients.matchAll()).then((all) => all.forEach((c) => c.navigate(c.url)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
#search-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#search_term {
|
||||
width: 60vw;
|
||||
font-size: 2vh;
|
||||
}
|
||||
|
||||
#clear {
|
||||
font-size: 2vh;
|
||||
}
|
||||
|
||||
#games-container{
|
||||
font-size: 2vh;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/* Pure CSS styles for Music Search */
|
||||
|
||||
:root {
|
||||
/* Light mode colors (default) */
|
||||
--bg-primary: #f3f4f6;
|
||||
--bg-secondary: #e5e7eb;
|
||||
--bg-tertiary: #dcfce7;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #374151;
|
||||
--border-primary: #9ca3af;
|
||||
--border-focus: #6b7280;
|
||||
--accent-primary: #f97316;
|
||||
--accent-hover: #ea580c;
|
||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode colors matching frontend */
|
||||
--bg-primary: #555;
|
||||
--bg-secondary: #333;
|
||||
--bg-tertiary: #2a2a2a;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: #ff9c00;
|
||||
--border-primary: #666;
|
||||
--border-focus: #ff9c00;
|
||||
--accent-primary: #ff9c00;
|
||||
--accent-hover: #e68a00;
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
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: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
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 var(--border-primary);
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#search_term:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
#clear {
|
||||
font-size: 1.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
#clear:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
#games-container {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.game-text {
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Dark mode toggle */
|
||||
#dark-mode-toggle {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
font-size: 1.2rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#dark-mode-toggle:hover {
|
||||
background-color: var(--border-primary);
|
||||
}
|
||||
|
||||
/* Game result cards */
|
||||
.bg-green-100 {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.shadow-md {
|
||||
box-shadow: 0 4px 6px -1px var(--shadow-color), 0 2px 4px -2px var(--shadow-color);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -2,15 +2,15 @@ package web
|
||||
|
||||
templ Base() {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-screen">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<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>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<main class="mx-auto p-4">
|
||||
<body>
|
||||
<main>
|
||||
{ children... }
|
||||
</main>
|
||||
</body>
|
||||
|
||||
+4
-1
@@ -3,4 +3,7 @@ package web
|
||||
import "embed"
|
||||
|
||||
//go:embed "assets"
|
||||
var Files embed.FS
|
||||
var Assets embed.FS
|
||||
|
||||
//go:embed "swagger"
|
||||
var Swagger embed.FS
|
||||
@@ -1,117 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"music-server/pkg/server"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var games_added []string
|
||||
|
||||
func FindGameWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
search_term := r.FormValue("search_term")
|
||||
|
||||
search(search_term)
|
||||
|
||||
component := FoundGames(games_added)
|
||||
err = component.Render(r.Context(), w)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Fatalf("Error rendering in FindGameWebHandler: %e", err)
|
||||
}
|
||||
}
|
||||
|
||||
func search(searchText string) {
|
||||
games_added = nil
|
||||
games := server.GetAllGames()
|
||||
for _, game := range games {
|
||||
if is_match_exact(searchText, game) {
|
||||
add_game(game)
|
||||
}
|
||||
}
|
||||
for _, game := range games {
|
||||
if is_match_contains(clean_term(searchText), clean_term(game)) {
|
||||
add_game(game)
|
||||
}
|
||||
}
|
||||
for _, game := range games {
|
||||
if is_match_regex(clean_term(searchText), clean_term(game)) {
|
||||
add_game(game)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func is_match_exact(search_term string, game_name string) bool {
|
||||
search_term = strings.ToLower(search_term)
|
||||
game_name = strings.ToLower(game_name)
|
||||
|
||||
if search_term == "" {
|
||||
return true
|
||||
} else if strings.Contains(game_name, search_term) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func is_match_contains(search_term string, game_name string) bool {
|
||||
if search_term == "" {
|
||||
return true
|
||||
} else if strings.Contains(game_name, search_term) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func is_match_regex(search_term string, game_name string) bool {
|
||||
if search_term == "" {
|
||||
return true
|
||||
} else if compile_regex(search_term).MatchString(game_name) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func add_game(game string) {
|
||||
if !check_if_game_exists(game) {
|
||||
games_added = append(games_added, game)
|
||||
}
|
||||
}
|
||||
|
||||
func check_if_game_exists(gameName string) bool {
|
||||
game_exists := false
|
||||
for _, child := range games_added {
|
||||
if child == gameName {
|
||||
game_exists = true
|
||||
}
|
||||
}
|
||||
return game_exists
|
||||
}
|
||||
|
||||
func compile_regex(search_term string) *regexp.Regexp {
|
||||
regText := ".*"
|
||||
for _, letter := range search_term {
|
||||
regText += string(letter) + ".*"
|
||||
}
|
||||
r, _ := regexp.Compile(regText)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func clean_term(term string) string {
|
||||
term = strings.ReplaceAll(term, " ", "")
|
||||
term = strings.ReplaceAll(term, "é", "e")
|
||||
term = strings.ReplaceAll(term, "+", "plus")
|
||||
term = strings.ReplaceAll(term, "&", "and")
|
||||
term = strings.ReplaceAll(term, "'n", "and")
|
||||
return strings.ToLower(term)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package web
|
||||
|
||||
templ HelloForm() {
|
||||
@Base() {
|
||||
<div id="search-container">
|
||||
<input 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"/>
|
||||
<button type="button" class="bg-orange-500 hover:bg-orange-700 text-white py-2 px-4 rounded" id="clear" name="clear">Clear</button>
|
||||
</div>
|
||||
<div id="games-container"></div>
|
||||
<script>
|
||||
document.addEventListener('readystatechange', () => {
|
||||
if (document.readyState == 'complete') {
|
||||
htmx.ajax('POST', '/find', '#games-container');
|
||||
document.getElementById("search_term").focus();
|
||||
}
|
||||
});
|
||||
document.getElementById("clear").addEventListener("click", function (event) {
|
||||
document.getElementById("search_term").value = "";
|
||||
htmx.ajax('POST', '/find', '#games-container');
|
||||
document.getElementById("search_term").focus();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
templ FoundGames(games []string) {
|
||||
for _, game := range games {
|
||||
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
||||
<p>{ game }</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"music-server/internal/backend"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var soundtracks_added []string
|
||||
|
||||
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
search_term := r.FormValue("search_term")
|
||||
search(search_term)
|
||||
results := soundtracks_added
|
||||
|
||||
component := FoundSoundtracks(results)
|
||||
err = component.Render(r.Context(), w)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Fatalf("Error rendering in FindSoundtrackWebHandler: %e", err)
|
||||
}
|
||||
}
|
||||
|
||||
func search(searchText string) {
|
||||
soundtracks_added = nil
|
||||
soundtrackData := backend.GetSearchCache()
|
||||
results := searchWithData(searchText, soundtrackData)
|
||||
soundtracks_added = results
|
||||
}
|
||||
|
||||
// searchWithData is a testable version of search that takes soundtrack data as a parameter
|
||||
func searchWithData(searchText string, soundtrackData []backend.SoundtrackData) []string {
|
||||
var results []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// Precompute cleaned and lowercase search terms once
|
||||
cleanedSearchTerm := strings.ToLower(searchText)
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, " ", "")
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "é", "e")
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "+", "plus")
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "&", "and")
|
||||
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "'n", "and")
|
||||
lowercaseSearchText := strings.ToLower(searchText)
|
||||
|
||||
// Pass 1: Exact match (case-insensitive contains)
|
||||
for _, data := range soundtrackData {
|
||||
if !seen[data.Original] && (searchText == "" || strings.Contains(data.Lowercase, lowercaseSearchText)) {
|
||||
results = append(results, data.Original)
|
||||
seen[data.Original] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Contains match with cleaned terms
|
||||
for _, data := range soundtrackData {
|
||||
if !seen[data.Original] && (cleanedSearchTerm == "" || strings.Contains(data.Cleaned, cleanedSearchTerm)) {
|
||||
results = append(results, data.Original)
|
||||
seen[data.Original] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Regex match with cleaned terms
|
||||
for _, data := range soundtrackData {
|
||||
if !seen[data.Original] && backend.MatchRegexCached(cleanedSearchTerm, data.Cleaned) {
|
||||
results = append(results, data.Original)
|
||||
seen[data.Original] = true
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package web
|
||||
|
||||
templ SearchForm() {
|
||||
@Base() {
|
||||
<button id="dark-mode-toggle">🌙</button>
|
||||
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
|
||||
<button type="button" id="clear" name="clear">Clear</button>
|
||||
<div id="soundtracks-container"></div>
|
||||
<script>
|
||||
document.addEventListener('readystatechange', () => {
|
||||
if (document.readyState == 'complete') {
|
||||
// Initialize search
|
||||
htmlx.ajax('POST', '/find', '#soundtracks-container');
|
||||
document.getElementById("search_term").focus();
|
||||
|
||||
// Initialize theme from localStorage (default to dark)
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
document.getElementById('dark-mode-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
|
||||
}
|
||||
});
|
||||
|
||||
// Dark mode toggle functionality
|
||||
document.getElementById("dark-mode-toggle").addEventListener("click", function() {
|
||||
const html = document.documentElement;
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Update toggle button text
|
||||
this.textContent = newTheme === 'dark' ? '☀️' : '🌙';
|
||||
});
|
||||
|
||||
document.getElementById("clear").addEventListener("click", function (event) {
|
||||
document.getElementById("search_term").value = "";
|
||||
htmlx.ajax('POST', '/find', '#soundtracks-container');
|
||||
document.getElementById("search_term").focus();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
templ FoundSoundtracks(soundtracks []string) {
|
||||
for _, soundtrack := range soundtracks {
|
||||
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
||||
<p class="soundtrack-text">{ soundtrack }</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"music-server/internal/backend"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCleanTerm tests that search terms are cleaned correctly
|
||||
func TestCleanTerm(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// CleanSearchTermForMatch removes spaces, replaces specific chars, and lowercases
|
||||
{"MGS4", "mgs4"},
|
||||
{"RE2", "re2"},
|
||||
{"FFVII", "ffvii"},
|
||||
{"Ys", "ys"},
|
||||
{" test ", "test"},
|
||||
{"Test 123", "test123"},
|
||||
{"A+B", "aplusb"},
|
||||
{"é", "e"},
|
||||
// Note: CleanSearchTermForMatch does NOT remove apostrophes, it only replaces "'n" with "and"
|
||||
{"It's", "it's"},
|
||||
// But it does replace "'n" with "and"
|
||||
{"I'n", "iand"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := backend.CleanSearchTermForMatch(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CleanSearchTermForMatch(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// searchWithDataHelper is a helper to create backend.SoundtrackData from original names
|
||||
func searchWithDataHelper(originals []string) []backend.SoundtrackData {
|
||||
data := make([]backend.SoundtrackData, len(originals))
|
||||
for i, orig := range originals {
|
||||
data[i] = backend.SoundtrackData{
|
||||
Original: orig,
|
||||
Lowercase: strings.ToLower(orig),
|
||||
Cleaned: backend.CleanSearchTermForMatch(orig),
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestNormalSearch tests the normal search function
|
||||
func TestNormalSearch(t *testing.T) {
|
||||
// Set up comprehensive test data with all user-provided soundtracks
|
||||
testData := searchWithDataHelper([]string{
|
||||
"A Bird Story",
|
||||
"Actraiser",
|
||||
"Actraiser 2",
|
||||
"Bangai-O Spirits",
|
||||
"Ciconia When they Cry",
|
||||
"Civilization IV",
|
||||
"Civilization VI",
|
||||
"Clair Obscur - Expedition 33",
|
||||
"Club House Games (42 All-Time Classics)",
|
||||
"Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv)",
|
||||
"Final Fantasy VII Crisis Core",
|
||||
"Final Fantasy VII Rebirth",
|
||||
"Final Fantasy VII Remake",
|
||||
"Final Fantasy VII",
|
||||
"Joe & Mac",
|
||||
"Judgment",
|
||||
"Kingdom Hearts",
|
||||
"Kingdom Hearts Birth By Sleep",
|
||||
"Kingdom Hearts II",
|
||||
"Kingdom Hearts III",
|
||||
"Lemmings (SNES + Mega Drive)",
|
||||
"Lemmings 3D",
|
||||
"Mega Man X4",
|
||||
"Metal Gear Rising - Revengeance",
|
||||
"Metal Gear Solid (+Twin Snakes)",
|
||||
"Metal Gear Solid 2 - Sons of Liberty",
|
||||
"Metal Gear Solid 3 - Snake Eater",
|
||||
"Metal Gear Solid 4 - Guns of the Patriots",
|
||||
"Metroid Prime",
|
||||
"Octopath Traveler",
|
||||
"Octopath Traveler 2",
|
||||
"Ori and the Blind Forest",
|
||||
"Ori and the Will of the Wisps",
|
||||
"OutRun 2006 - Coast 2 Coast",
|
||||
"Paper Mario - The Thousand Year Door",
|
||||
"Pokémon HeartGold & SoulSilver",
|
||||
"Resident Evil 2 (Remake)",
|
||||
"Shenmue",
|
||||
"Super Mario Galaxy",
|
||||
"Trails in the Sky FC (Sora no Kiseki FC)",
|
||||
"Trails in the Sky SC (Sora no Kiseki SC)",
|
||||
"Turrican II - The Final Fight",
|
||||
"Umineko no Naku Koro ni",
|
||||
"Umineko no Naku Koro ni Chiru",
|
||||
"VVVVVV",
|
||||
"YS VIII - Lacrimosa of DANA",
|
||||
"Yakuza 0",
|
||||
"Ys (Chronicles)",
|
||||
"Ys - Memories of Celceta",
|
||||
"Ys - The Ark of Napishtim",
|
||||
"Ys II (Chronicles)",
|
||||
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
|
||||
"Ys IV - The Dawn of Ys",
|
||||
"Ys Origin",
|
||||
"Ys Seven",
|
||||
"Ys X Nordics",
|
||||
"Call of Duty: Modern Warfare",
|
||||
"Half-Life 3",
|
||||
})
|
||||
|
||||
// Test cases covering all match types
|
||||
tests := []struct {
|
||||
query string
|
||||
wantFound string
|
||||
}{
|
||||
// Exact substring matches
|
||||
{"Metal", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"Resident", "Resident Evil 2 (Remake)"},
|
||||
{"Final", "Final Fantasy VII"},
|
||||
{"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||
|
||||
// Contains matches (on cleaned terms)
|
||||
{"gotp", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"mgs", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"kh", "Kingdom Hearts"},
|
||||
{"mg", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
|
||||
// Regex matches
|
||||
{"mgs4go", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"re2r", "Resident Evil 2 (Remake)"},
|
||||
{"ffv", "Final Fantasy VII"},
|
||||
{"yiwfyt", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||
{"codmw", "Call of Duty: Modern Warfare"},
|
||||
{"mgs4gop", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.query, func(t *testing.T) {
|
||||
results := searchWithData(tt.query, testData)
|
||||
|
||||
// Check that the expected item is in the results
|
||||
found := false
|
||||
for _, result := range results {
|
||||
if result == tt.wantFound {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("searchWithData(%q, ...) did not find expected item %q, got %v", tt.query, tt.wantFound, results)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchOrdering tests that Pass 1 matches come before Pass 2 matches
|
||||
func TestSearchOrdering(t *testing.T) {
|
||||
// "MG" matches Pass 1 (exact substring on original)
|
||||
// "Metal Gear" matches Pass 2 (contains on cleaned) but NOT Pass 1 (no "mg" substring in "metal gear")
|
||||
testData := searchWithDataHelper([]string{
|
||||
"Metal Gear",
|
||||
"MG",
|
||||
})
|
||||
|
||||
results := searchWithData("mg", testData)
|
||||
|
||||
// Find indices
|
||||
indexPass1 := -1
|
||||
indexPass2 := -1
|
||||
for i, result := range results {
|
||||
if result == "MG" {
|
||||
indexPass1 = i
|
||||
}
|
||||
if result == "Metal Gear" {
|
||||
indexPass2 = i
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1 match (MG) should come before Pass 2 match (Metal Gear)
|
||||
if indexPass1 >= 0 && indexPass2 >= 0 && indexPass1 >= indexPass2 {
|
||||
t.Errorf("Expected Pass 1 match 'MG' to come before Pass 2 match 'Metal Gear', but got order: %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The tests above verify:
|
||||
// 1. clean_term - the term cleaning logic
|
||||
// 2. search - the complete normal search with exact, contains, and regex matching
|
||||
// 3. ordering - exact matches come before contains matches
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,6 +0,0 @@
|
||||
package newDb
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed "migrations/*.sql"
|
||||
var MigrationsFs embed.FS
|
||||
@@ -1,49 +0,0 @@
|
||||
-- name: ResetGameIdSeq :one
|
||||
SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);
|
||||
|
||||
-- name: GetGameNameById :one
|
||||
SELECT game_name FROM game WHERE id = $1;
|
||||
|
||||
-- name: GetGameById :one
|
||||
SELECT *
|
||||
FROM game
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL;
|
||||
|
||||
-- name: SetGameDeletionDate :exec
|
||||
UPDATE game SET deleted=now() WHERE deleted IS NULL;
|
||||
|
||||
-- name: ClearGames :exec
|
||||
DELETE FROM game;
|
||||
|
||||
-- name: UpdateGameName :exec
|
||||
UPDATE game SET game_name=sqlc.arg(name), path=sqlc.arg(path), last_changed=now() WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: UpdateGameHash :exec
|
||||
UPDATE game SET hash=sqlc.arg(hash), last_changed=now() WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: RemoveDeletionDate :exec
|
||||
UPDATE game SET deleted=NULL WHERE id=$1;
|
||||
|
||||
-- name: GetIdByGameName :one
|
||||
SELECT id FROM game WHERE game_name = $1;
|
||||
|
||||
-- name: InsertGame :one
|
||||
INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id;
|
||||
|
||||
-- name: InsertGameWithExistingId :exec
|
||||
INSERT INTO game (id, game_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
||||
|
||||
-- name: FindAllGames :many
|
||||
SELECT *
|
||||
FROM game
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY game_name;
|
||||
|
||||
-- name: GetAllGamesIncludingDeleted :many
|
||||
SELECT *
|
||||
FROM game
|
||||
ORDER BY game_name;
|
||||
|
||||
-- name: AddGamePlayed :exec
|
||||
UPDATE game SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
||||
@@ -1,41 +0,0 @@
|
||||
-- name: ClearSongs :exec
|
||||
DELETE FROM song;
|
||||
|
||||
-- name: ClearSongsByGameId :exec
|
||||
DELETE FROM song WHERE game_id = $1;
|
||||
|
||||
-- name: AddSong :exec
|
||||
INSERT INTO song(game_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: CheckSong :one
|
||||
SELECT COUNT(*) FROM song WHERE path = $1;
|
||||
|
||||
-- name: CheckSongWithHash :one
|
||||
SELECT COUNT(*) FROM song WHERE hash = $1;
|
||||
|
||||
-- name: GetSongWithHash :one
|
||||
SELECT * FROM song WHERE hash = $1;
|
||||
|
||||
-- name: UpdateSong :exec
|
||||
UPDATE song SET song_name=$1, file_name=$2, path=$3 where hash=$4;
|
||||
|
||||
-- name: AddHashToSong :exec
|
||||
UPDATE song SET hash=$1 where path=$2;
|
||||
|
||||
-- name: FindSongsFromGame :many
|
||||
SELECT *
|
||||
FROM song
|
||||
WHERE game_id = $1;
|
||||
|
||||
-- name: AddSongPlayed :exec
|
||||
UPDATE song SET times_played = times_played + 1
|
||||
WHERE game_id = $1 AND song_name = $2;
|
||||
|
||||
-- name: FetchAllSongs :many
|
||||
SELECT * FROM song;
|
||||
|
||||
-- name: RemoveBrokenSong :exec
|
||||
DELETE FROM song WHERE path = $1;
|
||||
|
||||
-- name: RemoveBrokenSongs :exec
|
||||
DELETE FROM song where path = any (sqlc.slice('paths'));
|
||||
@@ -1,246 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// source: game.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const addGamePlayed = `-- name: AddGamePlayed :exec
|
||||
UPDATE game SET times_played = times_played + 1, last_played = now() WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) AddGamePlayed(ctx context.Context, id int32) error {
|
||||
_, err := q.db.Exec(ctx, addGamePlayed, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const clearGames = `-- name: ClearGames :exec
|
||||
DELETE FROM game
|
||||
`
|
||||
|
||||
func (q *Queries) ClearGames(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, clearGames)
|
||||
return err
|
||||
}
|
||||
|
||||
const findAllGames = `-- name: FindAllGames :many
|
||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM game
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY game_name
|
||||
`
|
||||
|
||||
func (q *Queries) FindAllGames(ctx context.Context) ([]Game, error) {
|
||||
rows, err := q.db.Query(ctx, findAllGames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.GameName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAllGamesIncludingDeleted = `-- name: GetAllGamesIncludingDeleted :many
|
||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM game
|
||||
ORDER BY game_name
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllGamesIncludingDeleted(ctx context.Context) ([]Game, error) {
|
||||
rows, err := q.db.Query(ctx, getAllGamesIncludingDeleted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.GameName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getGameById = `-- name: GetGameById :one
|
||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM game
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetGameById(ctx context.Context, id int32) (Game, error) {
|
||||
row := q.db.QueryRow(ctx, getGameById, id)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.GameName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGameNameById = `-- name: GetGameNameById :one
|
||||
SELECT game_name FROM game WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetGameNameById(ctx context.Context, id int32) (string, error) {
|
||||
row := q.db.QueryRow(ctx, getGameNameById, id)
|
||||
var game_name string
|
||||
err := row.Scan(&game_name)
|
||||
return game_name, err
|
||||
}
|
||||
|
||||
const getIdByGameName = `-- name: GetIdByGameName :one
|
||||
SELECT id FROM game WHERE game_name = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetIdByGameName(ctx context.Context, gameName string) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, getIdByGameName, gameName)
|
||||
var id int32
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const insertGame = `-- name: InsertGame :one
|
||||
INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
|
||||
`
|
||||
|
||||
type InsertGameParams struct {
|
||||
GameName string `json:"game_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertGame(ctx context.Context, arg InsertGameParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, insertGame, arg.GameName, arg.Path, arg.Hash)
|
||||
var id int32
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const insertGameWithExistingId = `-- name: InsertGameWithExistingId :exec
|
||||
INSERT INTO game (id, game_name, path, hash, added) VALUES ($1, $2, $3, $4, now())
|
||||
`
|
||||
|
||||
type InsertGameWithExistingIdParams struct {
|
||||
ID int32 `json:"id"`
|
||||
GameName string `json:"game_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertGameWithExistingId(ctx context.Context, arg InsertGameWithExistingIdParams) error {
|
||||
_, err := q.db.Exec(ctx, insertGameWithExistingId,
|
||||
arg.ID,
|
||||
arg.GameName,
|
||||
arg.Path,
|
||||
arg.Hash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const removeDeletionDate = `-- name: RemoveDeletionDate :exec
|
||||
UPDATE game SET deleted=NULL WHERE id=$1
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveDeletionDate(ctx context.Context, id int32) error {
|
||||
_, err := q.db.Exec(ctx, removeDeletionDate, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const resetGameIdSeq = `-- name: ResetGameIdSeq :one
|
||||
SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1)
|
||||
`
|
||||
|
||||
func (q *Queries) ResetGameIdSeq(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, resetGameIdSeq)
|
||||
var setval int64
|
||||
err := row.Scan(&setval)
|
||||
return setval, err
|
||||
}
|
||||
|
||||
const setGameDeletionDate = `-- name: SetGameDeletionDate :exec
|
||||
UPDATE game SET deleted=now() WHERE deleted IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) SetGameDeletionDate(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, setGameDeletionDate)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateGameHash = `-- name: UpdateGameHash :exec
|
||||
UPDATE game SET hash=$1, last_changed=now() WHERE id=$2
|
||||
`
|
||||
|
||||
type UpdateGameHashParams struct {
|
||||
Hash string `json:"hash"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateGameHash(ctx context.Context, arg UpdateGameHashParams) error {
|
||||
_, err := q.db.Exec(ctx, updateGameHash, arg.Hash, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateGameName = `-- name: UpdateGameName :exec
|
||||
UPDATE game SET game_name=$1, path=$2, last_changed=now() WHERE id=$3
|
||||
`
|
||||
|
||||
type UpdateGameNameParams struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateGameName(ctx context.Context, arg UpdateGameNameParams) error {
|
||||
_, err := q.db.Exec(ctx, updateGameName, arg.Name, arg.Path, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID int32 `json:"id"`
|
||||
GameName string `json:"game_name"`
|
||||
Added time.Time `json:"added"`
|
||||
Deleted *time.Time `json:"deleted"`
|
||||
LastChanged *time.Time `json:"last_changed"`
|
||||
Path string `json:"path"`
|
||||
TimesPlayed *int32 `json:"times_played"`
|
||||
LastPlayed *time.Time `json:"last_played"`
|
||||
NumberOfSongs int32 `json:"number_of_songs"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
GameID int32 `json:"game_id"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
TimesPlayed *int32 `json:"times_played"`
|
||||
Hash string `json:"hash"`
|
||||
FileName *string `json:"file_name"`
|
||||
}
|
||||
|
||||
type SongList struct {
|
||||
MatchDate time.Time `json:"match_date"`
|
||||
MatchID int32 `json:"match_id"`
|
||||
SongNo int32 `json:"song_no"`
|
||||
GameName *string `json:"game_name"`
|
||||
SongName *string `json:"song_name"`
|
||||
}
|
||||
|
||||
type Vgmq struct {
|
||||
SongNo int32 `json:"song_no"`
|
||||
Path *string `json:"path"`
|
||||
Clue *string `json:"clue"`
|
||||
Answered bool `json:"answered"`
|
||||
Answer *string `json:"answer"`
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// source: song.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const addHashToSong = `-- name: AddHashToSong :exec
|
||||
UPDATE song SET hash=$1 where path=$2
|
||||
`
|
||||
|
||||
type AddHashToSongParams struct {
|
||||
Hash string `json:"hash"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) error {
|
||||
_, err := q.db.Exec(ctx, addHashToSong, arg.Hash, arg.Path)
|
||||
return err
|
||||
}
|
||||
|
||||
const addSong = `-- name: AddSong :exec
|
||||
INSERT INTO song(game_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
|
||||
`
|
||||
|
||||
type AddSongParams struct {
|
||||
GameID int32 `json:"game_id"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
FileName *string `json:"file_name"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
||||
_, err := q.db.Exec(ctx, addSong,
|
||||
arg.GameID,
|
||||
arg.SongName,
|
||||
arg.Path,
|
||||
arg.FileName,
|
||||
arg.Hash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const addSongPlayed = `-- name: AddSongPlayed :exec
|
||||
UPDATE song SET times_played = times_played + 1
|
||||
WHERE game_id = $1 AND song_name = $2
|
||||
`
|
||||
|
||||
type AddSongPlayedParams struct {
|
||||
GameID int32 `json:"game_id"`
|
||||
SongName string `json:"song_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error {
|
||||
_, err := q.db.Exec(ctx, addSongPlayed, arg.GameID, arg.SongName)
|
||||
return err
|
||||
}
|
||||
|
||||
const checkSong = `-- name: CheckSong :one
|
||||
SELECT COUNT(*) FROM song WHERE path = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CheckSong(ctx context.Context, path string) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, checkSong, path)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const checkSongWithHash = `-- name: CheckSongWithHash :one
|
||||
SELECT COUNT(*) FROM song WHERE hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CheckSongWithHash(ctx context.Context, hash string) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, checkSongWithHash, hash)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const clearSongs = `-- name: ClearSongs :exec
|
||||
DELETE FROM song
|
||||
`
|
||||
|
||||
func (q *Queries) ClearSongs(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, clearSongs)
|
||||
return err
|
||||
}
|
||||
|
||||
const clearSongsByGameId = `-- name: ClearSongsByGameId :exec
|
||||
DELETE FROM song WHERE game_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ClearSongsByGameId(ctx context.Context, gameID int32) error {
|
||||
_, err := q.db.Exec(ctx, clearSongsByGameId, gameID)
|
||||
return err
|
||||
}
|
||||
|
||||
const fetchAllSongs = `-- name: FetchAllSongs :many
|
||||
SELECT game_id, song_name, path, times_played, hash, file_name FROM song
|
||||
`
|
||||
|
||||
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||
rows, err := q.db.Query(ctx, fetchAllSongs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Song
|
||||
for rows.Next() {
|
||||
var i Song
|
||||
if err := rows.Scan(
|
||||
&i.GameID,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.Hash,
|
||||
&i.FileName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const findSongsFromGame = `-- name: FindSongsFromGame :many
|
||||
SELECT game_id, song_name, path, times_played, hash, file_name
|
||||
FROM song
|
||||
WHERE game_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song, error) {
|
||||
rows, err := q.db.Query(ctx, findSongsFromGame, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Song
|
||||
for rows.Next() {
|
||||
var i Song
|
||||
if err := rows.Scan(
|
||||
&i.GameID,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.Hash,
|
||||
&i.FileName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getSongWithHash = `-- name: GetSongWithHash :one
|
||||
SELECT game_id, song_name, path, times_played, hash, file_name FROM song WHERE hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
|
||||
row := q.db.QueryRow(ctx, getSongWithHash, hash)
|
||||
var i Song
|
||||
err := row.Scan(
|
||||
&i.GameID,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.Hash,
|
||||
&i.FileName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const removeBrokenSong = `-- name: RemoveBrokenSong :exec
|
||||
DELETE FROM song WHERE path = $1
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveBrokenSong(ctx context.Context, path string) error {
|
||||
_, err := q.db.Exec(ctx, removeBrokenSong, path)
|
||||
return err
|
||||
}
|
||||
|
||||
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
|
||||
DELETE FROM song where path = any ($1)
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveBrokenSongs(ctx context.Context, paths []string) error {
|
||||
_, err := q.db.Exec(ctx, removeBrokenSongs, paths)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSong = `-- name: UpdateSong :exec
|
||||
UPDATE song SET song_name=$1, file_name=$2, path=$3 where hash=$4
|
||||
`
|
||||
|
||||
type UpdateSongParams struct {
|
||||
SongName string `json:"song_name"`
|
||||
FileName *string `json:"file_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) error {
|
||||
_, err := q.db.Exec(ctx, updateSong,
|
||||
arg.SongName,
|
||||
arg.FileName,
|
||||
arg.Path,
|
||||
arg.Hash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -1,56 +1,99 @@
|
||||
module music-server
|
||||
|
||||
go 1.22.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/MShekow/directory-checksum v1.4.6
|
||||
github.com/a-h/templ v0.2.793
|
||||
github.com/gin-contrib/static v1.1.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1
|
||||
github.com/jackc/pgtype v1.14.3
|
||||
github.com/jackc/pgx/v5 v5.5.5
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/spf13/afero v1.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
github.com/MShekow/directory-checksum v1.4.18
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/labstack/echo/v5 v5.1.1
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/panjf2000/ants/v2 v2.12.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
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/testcontainers/testcontainers-go v0.42.0
|
||||
go.uber.org/zap v1.28.0
|
||||
)
|
||||
|
||||
require github.com/google/uuid v1.6.0
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/docker/docker v27.3.1+incompatible // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
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/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.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-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-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // 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/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.32.0 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.27.0 // indirect
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.1 // indirect
|
||||
github.com/moby/moby/client v0.4.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sv-tools/openapi v0.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,350 +1,238 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/MShekow/directory-checksum v1.4.6 h1:2fhlCYbpjEN1iH9S0tdmEM0p1wvNT9x5x0rIchGI7nE=
|
||||
github.com/MShekow/directory-checksum v1.4.6/go.mod h1:bMfFBkaIlNk7O9VgEi8D2X7Q2Jfk3c7d67z3t6cpIi4=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
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/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/MShekow/directory-checksum v1.4.18 h1:1nPPVl7uREa6WMTAPKoWW/GylhnASs0C9C+GPiwLwXA=
|
||||
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/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
|
||||
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
|
||||
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
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/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
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/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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
|
||||
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
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-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
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/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
|
||||
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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
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/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
||||
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
|
||||
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
|
||||
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
||||
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||
github.com/jackc/pgtype v1.14.3 h1:h6W9cPuHsRWQFTWUZMAKMgG5jSwQI0Zurzdvlx3Plus=
|
||||
github.com/jackc/pgtype v1.14.3/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
||||
github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU=
|
||||
github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
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/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
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/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
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/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I=
|
||||
github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
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/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
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/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
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/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8=
|
||||
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/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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
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.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94=
|
||||
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
|
||||
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
||||
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
|
||||
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
|
||||
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
|
||||
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4=
|
||||
github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE=
|
||||
github.com/swaggo/echo-swagger/v2 v2.0.1 h1:jKR3QiK+ciGjxE0+7qZ/azjtlx/pTVls7pJFJqdJoJI=
|
||||
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/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk=
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
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 v1.0.0-20180628173108-788fd7840127/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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"music-server/internal/logging"
|
||||
)
|
||||
|
||||
func GetCharacterList() []string {
|
||||
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)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to read characters directory", zap.String("path", charactersPath), zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
var characters []string
|
||||
for _, file := range files {
|
||||
if isImage(file) {
|
||||
characters = append(characters, file.Name())
|
||||
}
|
||||
}
|
||||
return characters
|
||||
}
|
||||
|
||||
func GetCharacter(character string) string {
|
||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
||||
// 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 {
|
||||
return !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".jpeg") ||
|
||||
strings.HasSuffix(entry.Name(), ".png"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"music-server/internal/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type giteaResponse struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Assets []assetResponse `json:"assets"`
|
||||
}
|
||||
|
||||
type assetResponse struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DownloadUrl string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
func CheckLatest() string {
|
||||
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to check latest version", zap.String("error", err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
//Create a variable of the same type as our model
|
||||
var cResp giteaResponse
|
||||
//Decode the data
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Debug("Checked latest version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||
return cResp.Name
|
||||
}
|
||||
|
||||
func ListAssetsOfLatest() []string {
|
||||
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to list assets", zap.String("error", err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
//Create a variable of the same type as our model
|
||||
var cResp giteaResponse
|
||||
//Decode the data
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Debug("Listing assets", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||
var assets []string
|
||||
for _, asset := range cResp.Assets {
|
||||
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)
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func DownloadLatestWindows() string {
|
||||
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to download latest Windows version", zap.String("error", err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
//Create a variable of the same type as our model
|
||||
var cResp giteaResponse
|
||||
//Decode the data
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Debug("Downloading Windows version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||
for _, asset := range cResp.Assets {
|
||||
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") {
|
||||
return asset.DownloadUrl
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func DownloadLatestLinux() string {
|
||||
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to download latest Linux version", zap.String("error", err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
//Create a variable of the same type as our model
|
||||
var cResp giteaResponse
|
||||
//Decode the data
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
|
||||
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Debug("Downloading Linux version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
|
||||
for _, asset := range cResp.Assets {
|
||||
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") {
|
||||
return asset.DownloadUrl
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type SongInfo struct {
|
||||
Soundtrack string `json:"Soundtrack"`
|
||||
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
|
||||
Song string `json:"Song"`
|
||||
SongPlayed int32 `json:"SongPlayed"`
|
||||
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
|
||||
SongNo int `json:"SongNo"`
|
||||
}
|
||||
|
||||
var currentSong = -1
|
||||
|
||||
var soundtracksNew []repository.Soundtrack
|
||||
|
||||
var songQueNew []repository.Song
|
||||
|
||||
var lastFetchedNew repository.Song
|
||||
|
||||
func initRepo() {
|
||||
// This function is kept for backward compatibility
|
||||
// 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 getAllSoundtracks() []repository.Soundtrack {
|
||||
if len(soundtracksNew) == 0 {
|
||||
initRepo()
|
||||
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||
}
|
||||
return soundtracksNew
|
||||
|
||||
}
|
||||
|
||||
func GetSoundCheckSong() string {
|
||||
files, err := os.ReadDir("songs")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to read songs directory", zap.String("error", err.Error()))
|
||||
}
|
||||
fileInfo := files[rand.Intn(len(files))]
|
||||
return "songs/" + fileInfo.Name()
|
||||
}
|
||||
|
||||
func Reset() {
|
||||
songQueNew = nil
|
||||
currentSong = -1
|
||||
initRepo()
|
||||
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||
}
|
||||
|
||||
func AddLatestToQue() {
|
||||
if lastFetchedNew.Path != "" {
|
||||
currentSong = len(songQueNew)
|
||||
songQueNew = append(songQueNew, lastFetchedNew)
|
||||
lastFetchedNew = repository.Song{}
|
||||
}
|
||||
}
|
||||
|
||||
func AddLatestPlayed() {
|
||||
if len(songQueNew) == 0 {
|
||||
return
|
||||
}
|
||||
currentSongData := songQueNew[currentSong]
|
||||
|
||||
initRepo()
|
||||
BackendRepo().AddSoundtrackPlayed(BackendCtx(), currentSongData.SoundtrackID)
|
||||
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: currentSongData.SoundtrackID, SongName: currentSongData.SongName})
|
||||
}
|
||||
|
||||
func SetPlayed(songNumber int) {
|
||||
if len(songQueNew) == 0 || songNumber >= len(songQueNew) {
|
||||
return
|
||||
}
|
||||
songData := songQueNew[songNumber]
|
||||
initRepo()
|
||||
BackendRepo().AddSoundtrackPlayed(BackendCtx(), songData.SoundtrackID)
|
||||
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: songData.SoundtrackID, SongName: songData.SongName})
|
||||
}
|
||||
|
||||
func GetRandomSong() string {
|
||||
getAllSoundtracks()
|
||||
if len(soundtracksNew) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
song := getSongFromList(soundtracksNew)
|
||||
lastFetchedNew = song
|
||||
return song.Path
|
||||
}
|
||||
|
||||
func GetRandomSongLowChance() string {
|
||||
getAllSoundtracks()
|
||||
|
||||
var listOfSoundtracks []repository.Soundtrack
|
||||
|
||||
var averagePlayed = getAveragePlayed()
|
||||
|
||||
for _, data := range soundtracksNew {
|
||||
timesToAdd := averagePlayed - data.TimesPlayed
|
||||
if timesToAdd <= 0 {
|
||||
listOfSoundtracks = append(listOfSoundtracks, data)
|
||||
} else {
|
||||
for i := int32(0); i < timesToAdd; i++ {
|
||||
listOfSoundtracks = append(listOfSoundtracks, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
song := getSongFromList(listOfSoundtracks)
|
||||
|
||||
lastFetchedNew = song
|
||||
return song.Path
|
||||
|
||||
}
|
||||
|
||||
func GetRandomSongClassic() string {
|
||||
getAllSoundtracks()
|
||||
|
||||
var listOfAllSongs []repository.Song
|
||||
for _, soundtrack := range soundtracksNew {
|
||||
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
|
||||
listOfAllSongs = append(listOfAllSongs, songList...)
|
||||
}
|
||||
|
||||
songFound := false
|
||||
var song repository.Song
|
||||
for !songFound {
|
||||
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
|
||||
soundtrackData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
|
||||
|
||||
if err != nil {
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("soundtrack", soundtrackData.SoundtrackName),
|
||||
zap.String("filename", *song.FileName))
|
||||
continue
|
||||
}
|
||||
|
||||
//Check if file exists and open
|
||||
openFile, err := os.Open(song.Path)
|
||||
if err != nil || (song.FileName != nil && soundtrackData.Path+*song.FileName != song.Path) {
|
||||
//File not found
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("soundtrack", soundtrackData.SoundtrackName),
|
||||
zap.String("filename", *song.FileName))
|
||||
} else {
|
||||
songFound = true
|
||||
}
|
||||
err = openFile.Close()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to close file", zap.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
lastFetchedNew = song
|
||||
return song.Path
|
||||
}
|
||||
|
||||
func GetSongInfo() SongInfo {
|
||||
if songQueNew == nil {
|
||||
return SongInfo{}
|
||||
}
|
||||
var currentSongData = songQueNew[currentSong]
|
||||
|
||||
currentSoundtrackData := getCurrentSoundtrack(currentSongData)
|
||||
|
||||
return SongInfo{
|
||||
Soundtrack: currentSoundtrackData.SoundtrackName,
|
||||
SoundtrackPlayed: currentSoundtrackData.TimesPlayed,
|
||||
Song: currentSongData.SongName,
|
||||
SongPlayed: currentSongData.TimesPlayed,
|
||||
CurrentlyPlaying: true,
|
||||
SongNo: currentSong,
|
||||
}
|
||||
}
|
||||
|
||||
func GetPlayedSongs() []SongInfo {
|
||||
var songList []SongInfo
|
||||
|
||||
for i, song := range songQueNew {
|
||||
soundtrackData := getCurrentSoundtrack(song)
|
||||
songList = append(songList, SongInfo{
|
||||
Soundtrack: soundtrackData.SoundtrackName,
|
||||
SoundtrackPlayed: soundtrackData.TimesPlayed,
|
||||
Song: song.SongName,
|
||||
SongPlayed: song.TimesPlayed,
|
||||
CurrentlyPlaying: i == currentSong,
|
||||
SongNo: i,
|
||||
})
|
||||
}
|
||||
return songList
|
||||
}
|
||||
|
||||
func GetSong(song string) string {
|
||||
currentSong, _ = strconv.Atoi(song)
|
||||
if currentSong >= len(songQueNew) {
|
||||
currentSong = len(songQueNew) - 1
|
||||
} else if currentSong < 0 {
|
||||
currentSong = 0
|
||||
}
|
||||
songData := songQueNew[currentSong]
|
||||
return songData.Path
|
||||
}
|
||||
|
||||
func GetAllSoundtracks() []string {
|
||||
getAllSoundtracks()
|
||||
|
||||
var jsonArray []string
|
||||
for _, soundtrack := range soundtracksNew {
|
||||
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
|
||||
}
|
||||
return jsonArray
|
||||
}
|
||||
|
||||
func GetAllSoundtracksRandom() []string {
|
||||
getAllSoundtracks()
|
||||
|
||||
var jsonArray []string
|
||||
for _, soundtrack := range soundtracksNew {
|
||||
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
|
||||
}
|
||||
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
|
||||
return jsonArray
|
||||
}
|
||||
|
||||
func GetNextSong() string {
|
||||
if songQueNew == nil {
|
||||
return ""
|
||||
}
|
||||
if currentSong == len(songQueNew)-1 || currentSong == -1 {
|
||||
songData := songQueNew[currentSong]
|
||||
return songData.Path
|
||||
} else {
|
||||
currentSong = currentSong + 1
|
||||
songData := songQueNew[currentSong]
|
||||
return songData.Path
|
||||
}
|
||||
}
|
||||
|
||||
func GetPreviousSong() string {
|
||||
if songQueNew == nil {
|
||||
return ""
|
||||
}
|
||||
if currentSong == -1 || currentSong == 0 {
|
||||
songData := songQueNew[0]
|
||||
return songData.Path
|
||||
} else {
|
||||
currentSong = currentSong - 1
|
||||
songData := songQueNew[currentSong]
|
||||
return songData.Path
|
||||
}
|
||||
}
|
||||
|
||||
func getSongFromList(soundtracks []repository.Soundtrack) repository.Song {
|
||||
songFound := false
|
||||
var song repository.Song
|
||||
for !songFound {
|
||||
soundtrack := getRandomSoundtrack(soundtracks)
|
||||
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
|
||||
if len(songs) == 0 {
|
||||
continue
|
||||
}
|
||||
song = songs[rand.Intn(len(songs))]
|
||||
logging.GetLogger().Debug("Selected song", zap.String("song", song.SongName), zap.String("path", song.Path))
|
||||
|
||||
//Check if file exists and open
|
||||
openFile, err := os.Open(song.Path)
|
||||
if err != nil || (song.FileName != nil && soundtrack.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
||||
//File not found
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("soundtrack", soundtrack.SoundtrackName),
|
||||
zap.Any("filename", song.FileName))
|
||||
} else {
|
||||
songFound = true
|
||||
}
|
||||
|
||||
err = openFile.Close()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to close file", zap.String("path", song.Path), zap.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
|
||||
for _, soundtrack := range soundtracksNew {
|
||||
if soundtrack.ID == currentSongData.SoundtrackID {
|
||||
return soundtrack
|
||||
}
|
||||
}
|
||||
return repository.Soundtrack{}
|
||||
}
|
||||
|
||||
func getAveragePlayed() int32 {
|
||||
getAllSoundtracks()
|
||||
var sum int32
|
||||
for _, data := range soundtracksNew {
|
||||
sum += data.TimesPlayed
|
||||
}
|
||||
return sum / int32(len(soundtracksNew))
|
||||
}
|
||||
|
||||
func getRandomSoundtrack(listOfSoundtracks []repository.Soundtrack) repository.Soundtrack {
|
||||
return listOfSoundtracks[rand.Intn(len(listOfSoundtracks))]
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||
}
|
||||
|
||||
var sum int32
|
||||
for _, data := range soundtracks {
|
||||
sum += data.TimesPlayed
|
||||
}
|
||||
result := sum / int32(len(soundtracks))
|
||||
expected := int32(20)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Average calculation = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAverageEmpty(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{}
|
||||
|
||||
if len(soundtracks) == 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 soundtracks {
|
||||
sum += data.TimesPlayed
|
||||
}
|
||||
result := sum / int32(len(soundtracks))
|
||||
expected := int32(0)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Average calculation with empty list = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAverageSingle(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{SoundtrackName: "Soundtrack1", TimesPlayed: 42},
|
||||
}
|
||||
|
||||
var sum int32
|
||||
for _, data := range soundtracks {
|
||||
sum += data.TimesPlayed
|
||||
}
|
||||
result := sum / int32(len(soundtracks))
|
||||
expected := int32(42)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Average calculation with single soundtrack = %v, want %v", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRandomSoundtrack(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||
}
|
||||
|
||||
// Set seed for reproducible tests
|
||||
rand.Seed(42)
|
||||
|
||||
result := soundtracks[rand.Intn(len(soundtracks))]
|
||||
|
||||
if result.SoundtrackName == "" {
|
||||
t.Error("random soundtrack selection returned empty soundtrack")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, s := range soundtracks {
|
||||
if s.SoundtrackName == result.SoundtrackName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("random soundtrack selection returned soundtrack not in list: %v", result.SoundtrackName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSoundtrackByID(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{ID: 1, SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||
{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
{ID: 3, SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
soundtracks []repository.Soundtrack
|
||||
soundtrackID int32
|
||||
expected repository.Soundtrack
|
||||
}{
|
||||
{
|
||||
name: "existing soundtrack",
|
||||
soundtracks: soundtracks,
|
||||
soundtrackID: 2,
|
||||
expected: repository.Soundtrack{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
},
|
||||
{
|
||||
name: "non-existing soundtrack",
|
||||
soundtracks: soundtracks,
|
||||
soundtrackID: 99,
|
||||
expected: repository.Soundtrack{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var result repository.Soundtrack
|
||||
for _, s := range tt.soundtracks {
|
||||
if s.ID == tt.soundtrackID {
|
||||
result = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName {
|
||||
t.Errorf("findSoundtrackByID() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSoundtrackNames(t *testing.T) {
|
||||
soundtracks := []repository.Soundtrack{
|
||||
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
|
||||
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
|
||||
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
|
||||
}
|
||||
|
||||
var result []string
|
||||
for _, s := range soundtracks {
|
||||
result = append(result, s.SoundtrackName)
|
||||
}
|
||||
|
||||
expected := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
|
||||
|
||||
if len(result) != len(expected) {
|
||||
t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected))
|
||||
return
|
||||
}
|
||||
|
||||
for i, v := range result {
|
||||
if v != expected[i] {
|
||||
t.Errorf("extractSoundtrackNames()[%d] = %v, want %v", i, v, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffleSoundtrackNames(t *testing.T) {
|
||||
soundtracks := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
|
||||
|
||||
// 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(soundtracks))
|
||||
copy(original, soundtracks)
|
||||
|
||||
// Simple shuffle implementation for testing
|
||||
for i := range soundtracks {
|
||||
j := i // In real code this would be random
|
||||
soundtracks[i], soundtracks[j] = soundtracks[j], soundtracks[i]
|
||||
}
|
||||
|
||||
if len(soundtracks) != len(original) {
|
||||
t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(soundtracks))
|
||||
return
|
||||
}
|
||||
|
||||
// Check all original elements are still present
|
||||
for _, orig := range original {
|
||||
found := false
|
||||
for _, s := range soundtracks {
|
||||
if s == orig {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("shuffleSoundtrackNames() lost element: %v", orig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SoundtrackData holds precomputed search data for a soundtrack
|
||||
type SoundtrackData struct {
|
||||
Original string
|
||||
Lowercase string
|
||||
Cleaned string
|
||||
}
|
||||
|
||||
var (
|
||||
searchCache []SoundtrackData
|
||||
searchCacheMutex sync.Mutex
|
||||
searchRegexCache = make(map[string]*regexp.Regexp)
|
||||
searchRegexCacheMutex sync.Mutex
|
||||
)
|
||||
|
||||
// GetSearchCache returns the precomputed search cache, initializing it if needed
|
||||
func GetSearchCache() []SoundtrackData {
|
||||
searchCacheMutex.Lock()
|
||||
defer searchCacheMutex.Unlock()
|
||||
|
||||
if len(searchCache) == 0 {
|
||||
loadSearchCache()
|
||||
}
|
||||
return searchCache
|
||||
}
|
||||
|
||||
// ReloadSearchCache clears and reloads the search cache.
|
||||
// This should be called after a sync operation to ensure search results are up to date.
|
||||
func ReloadSearchCache() {
|
||||
searchCacheMutex.Lock()
|
||||
defer searchCacheMutex.Unlock()
|
||||
searchCache = nil
|
||||
loadSearchCache()
|
||||
}
|
||||
|
||||
// loadSearchCache loads the soundtrack data into the cache
|
||||
func loadSearchCache() {
|
||||
soundtracks := GetAllSoundtracks()
|
||||
searchCache = make([]SoundtrackData, len(soundtracks))
|
||||
|
||||
for i, soundtrack := range soundtracks {
|
||||
cleaned := cleanSearchTerm(soundtrack)
|
||||
lowercase := strings.ToLower(soundtrack)
|
||||
searchCache[i] = SoundtrackData{
|
||||
Original: soundtrack,
|
||||
Lowercase: lowercase,
|
||||
Cleaned: cleaned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanSearchTermForMatch cleans a search term for better matching
|
||||
func CleanSearchTermForMatch(term string) string {
|
||||
term = strings.ReplaceAll(term, " ", "")
|
||||
term = strings.ReplaceAll(term, "é", "e")
|
||||
term = strings.ReplaceAll(term, "+", "plus")
|
||||
term = strings.ReplaceAll(term, "&", "and")
|
||||
term = strings.ReplaceAll(term, "'n", "and")
|
||||
return strings.ToLower(term)
|
||||
}
|
||||
|
||||
// cleanSearchTerm cleans a search term for better matching (internal)
|
||||
func cleanSearchTerm(term string) string {
|
||||
return CleanSearchTermForMatch(term)
|
||||
}
|
||||
|
||||
// MatchRegexCached checks if a search term matches using cached compiled regex
|
||||
func MatchRegexCached(searchTerm string, soundtrackName string) bool {
|
||||
if searchTerm == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
searchRegexCacheMutex.Lock()
|
||||
re, exists := searchRegexCache[searchTerm]
|
||||
if !exists {
|
||||
re = compileSearchRegex(searchTerm)
|
||||
searchRegexCache[searchTerm] = re
|
||||
}
|
||||
searchRegexCacheMutex.Unlock()
|
||||
|
||||
return re.MatchString(soundtrackName)
|
||||
}
|
||||
|
||||
// compileSearchRegex compiles a search term into a regex pattern
|
||||
func compileSearchRegex(searchTerm string) *regexp.Regexp {
|
||||
regText := ".*"
|
||||
for _, letter := range searchTerm {
|
||||
regText += string(letter) + ".*"
|
||||
}
|
||||
re, _ := regexp.Compile(regText)
|
||||
return re
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
|
||||
type SoundtrackWithSongs struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
|
||||
Songs []SongInfoForStats `json:"songs"`
|
||||
}
|
||||
|
||||
// SongInfoForStats represents a song with soundtrack info for statistics
|
||||
type SongInfoForStats struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
FileName *string `json:"file_name,omitempty"`
|
||||
}
|
||||
|
||||
// StatisticsSummary holds overall statistics
|
||||
type StatisticsSummary struct {
|
||||
TotalSoundtracks int64 `json:"total_soundtracks"`
|
||||
PlayedSoundtracks int64 `json:"played_soundtracks"`
|
||||
NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"`
|
||||
TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"`
|
||||
AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"`
|
||||
MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"`
|
||||
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
|
||||
}
|
||||
|
||||
// StatisticsHandler manages statistics operations
|
||||
type StatisticsHandler struct {
|
||||
// Uses the global backend repo initialized via InitBackend
|
||||
}
|
||||
|
||||
// NewStatisticsHandler creates a new StatisticsHandler
|
||||
func NewStatisticsHandler() *StatisticsHandler {
|
||||
return &StatisticsHandler{}
|
||||
}
|
||||
|
||||
// GetMostPlayedSoundtracksWithSongs returns the top N most played soundtracks with their songs
|
||||
func (h *StatisticsHandler) GetMostPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
// Get raw results
|
||||
rows, err := queries.GetMostPlayedSoundtracksWithSongs(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to SoundtrackWithSongs
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
// Parse JSON songs array
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
// Fallback: if JSON parsing fails, create empty song entries
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
Songs: songs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLeastPlayedSoundtracksWithSongs returns the top N least played soundtracks with their songs
|
||||
func (h *StatisticsHandler) GetLeastPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetLeastPlayedSoundtracksWithSongs(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
Songs: songs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetMostPlayedSongsWithSoundtrack returns the top N most played songs with their soundtrack info
|
||||
func (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SongInfoForStats
|
||||
for _, row := range rows {
|
||||
result = append(result, SongInfoForStats{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SongName: row.SongName,
|
||||
Path: row.Path,
|
||||
TimesPlayed: row.TimesPlayed,
|
||||
FileName: row.FileName,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLeastPlayedSongsWithSoundtrack returns the top N least played songs with their soundtrack info
|
||||
func (h *StatisticsHandler) GetLeastPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SongInfoForStats
|
||||
for _, row := range rows {
|
||||
result = append(result, SongInfoForStats{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SongName: row.SongName,
|
||||
Path: row.Path,
|
||||
TimesPlayed: row.TimesPlayed,
|
||||
FileName: row.FileName,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||
func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetNeverPlayedSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: nil,
|
||||
Songs: songs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLastPlayedSoundtracks returns the most recently played soundtracks
|
||||
func (h *StatisticsHandler) GetLastPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetLastPlayedSoundtracks(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
Songs: songs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
|
||||
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
rows, err := queries.GetOldestPlayedSoundtracks(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []SoundtrackWithSongs
|
||||
for _, row := range rows {
|
||||
var songs []SongInfoForStats
|
||||
if row.Songs != nil {
|
||||
if err := json.Unmarshal(row.Songs, &songs); err != nil {
|
||||
songs = make([]SongInfoForStats, 0)
|
||||
}
|
||||
}
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
Songs: songs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStatisticsSummary returns overall statistics
|
||||
func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
|
||||
queries := BackendRepo()
|
||||
ctx := BackendCtx()
|
||||
|
||||
row, err := queries.GetStatisticsSummary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &StatisticsSummary{
|
||||
TotalSoundtracks: int64(row.TotalSoundtracks),
|
||||
PlayedSoundtracks: int64(row.PlayedSoundtracks),
|
||||
NeverPlayedSoundtracks: int64(row.NeverPlayedSoundtracks),
|
||||
TotalSoundtrackPlays: int64(row.TotalSoundtrackPlays),
|
||||
AvgSoundtrackPlays: float64(row.AvgSoundtrackPlays),
|
||||
MaxSoundtrackPlays: int64(row.MaxSoundtrackPlays),
|
||||
MinSoundtrackPlays: int64(row.MinSoundtrackPlays),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Log helper for statistics operations
|
||||
func logStatisticsError(err error, operation string) {
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Statistics error",
|
||||
zap.String("operation", operation),
|
||||
zap.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/panjf2000/ants/v2"
|
||||
|
||||
"github.com/MShekow/directory-checksum/directory_checksum"
|
||||
"github.com/spf13/afero"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var Syncing = false
|
||||
var foldersSynced float32
|
||||
var numberOfFoldersToSync float32
|
||||
var start time.Time
|
||||
var totalTime time.Duration
|
||||
var timeSpent time.Duration
|
||||
|
||||
var allSoundtracks []repository.Soundtrack
|
||||
var soundtracksBeforeSync []repository.Soundtrack
|
||||
var soundtracksAfterSync []repository.Soundtrack
|
||||
var soundtracksAdded []string
|
||||
var soundtracksReAdded []string
|
||||
var soundtracksChangedTitle map[string]string
|
||||
var soundtracksChangedContent []string
|
||||
var soundtracksRemoved []string
|
||||
var catchedErrors []string
|
||||
|
||||
type brokenSong struct {
|
||||
SoundtrackID int32
|
||||
Path string
|
||||
}
|
||||
|
||||
var brokenSongs []brokenSong
|
||||
var pool *ants.Pool
|
||||
var poolSong *ants.Pool
|
||||
|
||||
type SyncResponse struct {
|
||||
SoundtracksAdded []string `json:"soundtracks_added"`
|
||||
SoundtracksReAdded []string `json:"soundtracks_re_added"`
|
||||
SoundtracksChangedTitle map[string]string `json:"soundtracks_changed_title"`
|
||||
SoundtracksChangedContent []string `json:"soundtracks_changed_content"`
|
||||
SoundtracksRemoved []string `json:"soundtracks_removed"`
|
||||
CatchedErrors []string `json:"catched_errors"`
|
||||
TotalTime string `json:"total_time"`
|
||||
}
|
||||
|
||||
type ProgressResponse struct {
|
||||
Progress string `json:"progress"`
|
||||
TimeSpent string `json:"time_spent"`
|
||||
}
|
||||
|
||||
type SoundtrackStatus int
|
||||
|
||||
const (
|
||||
NotChanged SoundtrackStatus = iota
|
||||
TitleChanged
|
||||
SoundtrackChanged
|
||||
NewSoundtrack
|
||||
)
|
||||
|
||||
var statusName = map[SoundtrackStatus]string{
|
||||
NotChanged: "Not changed",
|
||||
TitleChanged: "Title changed",
|
||||
SoundtrackChanged: "Soundtrack changed",
|
||||
NewSoundtrack: "New soundtrack",
|
||||
}
|
||||
|
||||
func (ss SoundtrackStatus) String() string {
|
||||
return statusName[ss]
|
||||
}
|
||||
|
||||
func ResetDB() {
|
||||
repo.ClearSongs(BackendCtx())
|
||||
repo.ClearSoundtracks(BackendCtx())
|
||||
}
|
||||
|
||||
func SyncProgress() ProgressResponse {
|
||||
progress := int((foldersSynced / numberOfFoldersToSync) * 100)
|
||||
currentTime := time.Now()
|
||||
timeSpent = currentTime.Sub(start)
|
||||
out := time.Time{}.Add(timeSpent)
|
||||
logging.GetLogger().Debug("Sync progress",
|
||||
zap.Int("progress_percent", 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{
|
||||
Progress: fmt.Sprintf("%v", progress),
|
||||
TimeSpent: out.Format("15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func SyncResult() SyncResponse {
|
||||
logging.GetLogger().Info("Sync completed",
|
||||
zap.Int("soundtracks_before", len(soundtracksBeforeSync)),
|
||||
zap.Int("soundtracks_after", len(soundtracksAfterSync)))
|
||||
|
||||
if len(soundtracksAdded) > 0 {
|
||||
logging.GetLogger().Debug("Soundtracks added", zap.Strings("soundtracks", soundtracksAdded))
|
||||
}
|
||||
|
||||
if len(soundtracksReAdded) > 0 {
|
||||
logging.GetLogger().Debug("Soundtracks readded", zap.Strings("soundtracks", soundtracksReAdded))
|
||||
}
|
||||
|
||||
if len(soundtracksChangedTitle) > 0 {
|
||||
logging.GetLogger().Debug("Soundtracks with changed title", zap.Any("changes", soundtracksChangedTitle))
|
||||
}
|
||||
|
||||
if len(soundtracksChangedContent) > 0 {
|
||||
logging.GetLogger().Debug("Soundtracks with changed content", zap.Strings("soundtracks", soundtracksChangedContent))
|
||||
}
|
||||
|
||||
var soundtracksRemovedTemp []string
|
||||
for _, beforeSoundtrack := range soundtracksBeforeSync {
|
||||
var found = false
|
||||
for _, afterSoundtrack := range soundtracksAfterSync {
|
||||
if beforeSoundtrack.SoundtrackName == afterSoundtrack.SoundtrackName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
soundtracksRemovedTemp = append(soundtracksRemovedTemp, beforeSoundtrack.SoundtrackName)
|
||||
}
|
||||
}
|
||||
|
||||
for _, soundtrack := range soundtracksRemovedTemp {
|
||||
var found bool = false
|
||||
for key := range soundtracksChangedTitle {
|
||||
if soundtrack == key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
soundtracksRemoved = append(soundtracksRemoved, soundtrack)
|
||||
}
|
||||
}
|
||||
|
||||
if len(soundtracksRemoved) > 0 {
|
||||
logging.GetLogger().Debug("Soundtracks removed", zap.Strings("soundtracks", soundtracksRemoved))
|
||||
}
|
||||
|
||||
if len(catchedErrors) > 0 {
|
||||
logging.GetLogger().Error("Errors caught during sync", zap.Strings("errors", catchedErrors))
|
||||
}
|
||||
|
||||
out := time.Time{}.Add(totalTime)
|
||||
logging.GetLogger().Info("Sync completed", zap.String("total_time", out.Format("15:04:05.00000")))
|
||||
|
||||
return SyncResponse{
|
||||
SoundtracksAdded: soundtracksAdded,
|
||||
SoundtracksReAdded: soundtracksReAdded,
|
||||
SoundtracksChangedTitle: soundtracksChangedTitle,
|
||||
SoundtracksChangedContent: soundtracksChangedContent,
|
||||
SoundtracksRemoved: soundtracksRemoved,
|
||||
CatchedErrors: catchedErrors,
|
||||
TotalTime: out.Format("15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func SyncSoundtracksFull() {
|
||||
syncSoundtracks(true)
|
||||
Reset()
|
||||
ReloadSearchCache()
|
||||
}
|
||||
|
||||
func SyncSoundtracksOnlyChanges() {
|
||||
syncSoundtracks(false)
|
||||
Reset()
|
||||
ReloadSearchCache()
|
||||
}
|
||||
|
||||
func syncSoundtracks(full bool) {
|
||||
musicPath := os.Getenv("MUSIC_PATH")
|
||||
fmt.Printf("dir: %s\n", musicPath)
|
||||
logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath))
|
||||
if !strings.HasSuffix(musicPath, "/") {
|
||||
musicPath += "/"
|
||||
}
|
||||
|
||||
var syncWg sync.WaitGroup
|
||||
|
||||
initRepo()
|
||||
start = time.Now()
|
||||
foldersToSkip := []string{".sync", "characters", "dist", "old"}
|
||||
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
|
||||
|
||||
var err error
|
||||
soundtracksAdded = nil
|
||||
soundtracksReAdded = nil
|
||||
soundtracksChangedTitle = nil
|
||||
soundtracksChangedContent = nil
|
||||
soundtracksRemoved = nil
|
||||
catchedErrors = nil
|
||||
brokenSongs = nil
|
||||
|
||||
soundtracksBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
|
||||
handleError("FindAllSoundtracks Before", err, "")
|
||||
logging.GetLogger().Info("Starting sync", zap.Int("soundtracks_before", len(soundtracksBeforeSync)))
|
||||
|
||||
allSoundtracks, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx())
|
||||
handleError("GetAllSoundtracksIncludingDeleted", err, "")
|
||||
err = repo.SetSoundtrackDeletionDate(BackendCtx())
|
||||
handleError("SetSoundtrackDeletionDate", err, "")
|
||||
|
||||
directories, err := os.ReadDir(musicPath)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to read music directory", zap.String("path", musicPath), zap.String("error", err.Error()))
|
||||
}
|
||||
pool, _ = ants.NewPool(10, ants.WithPreAlloc(true))
|
||||
poolSong, _ = ants.NewPool(10, ants.WithPreAlloc(true))
|
||||
defer pool.Release()
|
||||
defer poolSong.Release()
|
||||
|
||||
foldersSynced = 0
|
||||
numberOfFoldersToSync = float32(len(directories))
|
||||
syncWg.Add(int(numberOfFoldersToSync))
|
||||
for _, dir := range directories {
|
||||
pool.Submit(func() {
|
||||
defer syncWg.Done()
|
||||
syncSoundtrack(dir, foldersToSkip, musicPath, full)
|
||||
})
|
||||
}
|
||||
syncWg.Wait()
|
||||
checkBrokenSongs()
|
||||
|
||||
soundtracksAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
|
||||
handleError("FindAllSoundtracks After", err, "")
|
||||
|
||||
finished := time.Now()
|
||||
totalTime = finished.Sub(start)
|
||||
out := time.Time{}.Add(totalTime)
|
||||
logging.GetLogger().Info("Sync completed", zap.Duration("total_time", totalTime), zap.String("formatted_time", out.Format("15:04:05.00000")))
|
||||
|
||||
Syncing = false
|
||||
}
|
||||
|
||||
func checkBrokenSongs() {
|
||||
allSongs, err := repo.FetchAllSongs(BackendCtx())
|
||||
handleError("FetchAllSongs", err, "")
|
||||
var brokenWg sync.WaitGroup
|
||||
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
|
||||
defer poolBroken.Release()
|
||||
|
||||
brokenWg.Add(len(allSongs))
|
||||
for _, song := range allSongs {
|
||||
poolBroken.Submit(func() {
|
||||
defer brokenWg.Done()
|
||||
checkBrokenSong(song)
|
||||
})
|
||||
}
|
||||
brokenWg.Wait()
|
||||
for _, bs := range brokenSongs {
|
||||
err = repo.RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: bs.SoundtrackID, Path: bs.Path})
|
||||
handleError("RemoveBrokenSong", err, "")
|
||||
}
|
||||
}
|
||||
|
||||
func checkBrokenSong(song repository.Song) {
|
||||
//Check if file exists and open
|
||||
openFile, err := os.Open(song.Path)
|
||||
if err != nil {
|
||||
//File not found
|
||||
brokenSongs = append(brokenSongs, brokenSong{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
logging.GetLogger().Warn("Broken song found", zap.String("path", song.Path))
|
||||
} else {
|
||||
err = openFile.Close()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to close file", zap.String("path", song.Path), zap.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) {
|
||||
if file.IsDir() && !contains(foldersToSkip, file.Name()) {
|
||||
logging.GetLogger().Debug("Syncing soundtrack", zap.String("soundtrack", file.Name()))
|
||||
soundtrackDir := baseDir + file.Name() + "/"
|
||||
dirHash := getHashForDir(soundtrackDir)
|
||||
|
||||
var status SoundtrackStatus = NewSoundtrack
|
||||
var oldSoundtrack repository.Soundtrack
|
||||
var id int32 = -1
|
||||
var soundtrackUuid uuid.UUID
|
||||
|
||||
//fmt.Printf("Soundtracks before: %d\n", len(soundtracksBeforeSync))
|
||||
|
||||
for _, currentSoundtrack := range allSoundtracks {
|
||||
oldSoundtrack = currentSoundtrack
|
||||
//fmt.Printf("%s | %s\n", oldSoundtrack.SoundtrackName, oldSoundtrack.Hash)
|
||||
if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash == dirHash {
|
||||
status = NotChanged
|
||||
id = oldSoundtrack.ID
|
||||
soundtrackUuid = oldSoundtrack.Uuid
|
||||
//fmt.Printf("Soundtrack not changed\n")
|
||||
break
|
||||
} else if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash != dirHash {
|
||||
status = SoundtrackChanged
|
||||
id = oldSoundtrack.ID
|
||||
soundtrackUuid = oldSoundtrack.Uuid
|
||||
//fmt.Printf("Soundtrack changed\n")
|
||||
break
|
||||
} else if oldSoundtrack.SoundtrackName != file.Name() && oldSoundtrack.Hash == dirHash {
|
||||
status = TitleChanged
|
||||
id = oldSoundtrack.ID
|
||||
soundtrackUuid = oldSoundtrack.Uuid
|
||||
//fmt.Printf("SoundtrackName changed\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if full && status != NewSoundtrack {
|
||||
status = TitleChanged
|
||||
}
|
||||
entries, err := os.ReadDir(soundtrackDir)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to read soundtrack directory", zap.String("path", soundtrackDir), zap.String("error", err.Error()))
|
||||
}
|
||||
switch status {
|
||||
case NewSoundtrack:
|
||||
id, soundtrackUuid = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
|
||||
logging.GetLogger().Debug("New soundtrack detected",
|
||||
zap.Int32("id", id),
|
||||
zap.String("uuid", soundtrackUuid.String()),
|
||||
zap.String("soundtrack", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
soundtracksAdded = append(soundtracksAdded, file.Name())
|
||||
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||
case SoundtrackChanged:
|
||||
logging.GetLogger().Debug("Soundtrack changed",
|
||||
zap.Int32("id", id),
|
||||
zap.String("uuid", soundtrackUuid.String()),
|
||||
zap.String("soundtrack", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
|
||||
handleError("UpdateSoundtrackHash", err, "")
|
||||
soundtracksChangedContent = append(soundtracksChangedContent, file.Name())
|
||||
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||
case TitleChanged:
|
||||
logging.GetLogger().Debug("Soundtrack title changed",
|
||||
zap.Int32("id", id),
|
||||
zap.String("uuid", soundtrackUuid.String()),
|
||||
zap.String("oldName", oldSoundtrack.SoundtrackName),
|
||||
zap.String("newName", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id})
|
||||
handleError("UpdateSoundtrackName", err, "")
|
||||
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||
if soundtracksChangedTitle == nil {
|
||||
soundtracksChangedTitle = make(map[string]string)
|
||||
}
|
||||
soundtracksChangedTitle[oldSoundtrack.SoundtrackName] = file.Name()
|
||||
case NotChanged:
|
||||
var found bool = false
|
||||
for _, beforeSoundtrack := range soundtracksBeforeSync {
|
||||
if dirHash == beforeSoundtrack.Hash {
|
||||
found = true
|
||||
logging.GetLogger().Debug("Soundtrack not changed",
|
||||
zap.Int32("id", id),
|
||||
zap.String("newName", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||
soundtracksReAdded = append(soundtracksReAdded, file.Name())
|
||||
logging.GetLogger().Debug("Soundtrack added again",
|
||||
zap.Int32("id", id),
|
||||
zap.String("uuid", soundtrackUuid.String()),
|
||||
zap.String("newName", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
|
||||
}
|
||||
}
|
||||
logging.GetLogger().Debug("Soundtrack sync status",
|
||||
zap.Int32("id", id),
|
||||
zap.String("soundtrack", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
|
||||
handleError("RemoveSoundtrackDeletionDate", err, "")
|
||||
|
||||
// Remove .id files and update hash if any were removed
|
||||
if removeIdFilesInSoundtrack(soundtrackDir) {
|
||||
newHash := getHashForDir(soundtrackDir)
|
||||
if newHash != dirHash {
|
||||
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: newHash, ID: id})
|
||||
handleError("UpdateSoundtrackHash for .id cleanup", err, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
foldersSynced++
|
||||
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 insertSoundtrack(name string, path string, hash string) (int32, uuid.UUID) {
|
||||
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
||||
soundtrackUuid := uuid.New()
|
||||
result, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{
|
||||
SoundtrackName: name,
|
||||
Path: path,
|
||||
Hash: hash,
|
||||
Uuid: soundtrackUuid,
|
||||
})
|
||||
handleError("InsertSoundtrack", err, "")
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("ID collision or UUID duplicate detected, resetting sequence")
|
||||
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
||||
logging.GetLogger().Debug("Resetting soundtrack ID sequence")
|
||||
_, err = repo.ResetSoundtrackIdSeq(BackendCtx())
|
||||
handleError("ResetSoundtrackIdSeq", err, "")
|
||||
id, uuid := insertSoundtrack(name, path, hash)
|
||||
return id, uuid
|
||||
}
|
||||
}
|
||||
return result.ID, result.Uuid
|
||||
|
||||
}
|
||||
|
||||
func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) int32 {
|
||||
//hasher := md5.New()
|
||||
var numberOfSongs int32
|
||||
numberOfFiles := len(entries)
|
||||
|
||||
var songWg sync.WaitGroup
|
||||
songWg.Add(numberOfFiles)
|
||||
for _, entry := range entries {
|
||||
poolSong.Submit(func() {
|
||||
defer songWg.Done()
|
||||
if checkSong(entry, soundtrackDir, id, soundtrackUuid) {
|
||||
numberOfSongs++
|
||||
}
|
||||
})
|
||||
}
|
||||
songWg.Wait()
|
||||
return numberOfSongs
|
||||
}
|
||||
|
||||
func checkSong(entry os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) bool {
|
||||
fileInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
|
||||
return false
|
||||
}
|
||||
|
||||
if isSong(fileInfo) {
|
||||
path := soundtrackDir + entry.Name()
|
||||
|
||||
songHash := getHashForFile(path)
|
||||
//numberOfSongs++
|
||||
|
||||
fileName := entry.Name()
|
||||
songName, _ := strings.CutSuffix(fileName, ".mp3")
|
||||
|
||||
song, err := repo.GetSongWithHash(BackendCtx(), songHash)
|
||||
handleError("GetSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
if err == nil {
|
||||
if song.SongName == songName && song.Path == path {
|
||||
return false
|
||||
}
|
||||
}
|
||||
logging.GetLogger().Debug("Song changed",
|
||||
zap.Int32("soundtrack_id", id),
|
||||
zap.String("path", path),
|
||||
zap.String("song_name", songName),
|
||||
zap.String("song_hash", songHash))
|
||||
|
||||
count, err := repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||
handleError("CheckSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||
if err != nil {
|
||||
count2, err := repo.CheckSong(BackendCtx(), repository.CheckSongParams{SoundtrackID: id, Path: path})
|
||||
handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||
if count2 > 0 {
|
||||
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path})
|
||||
handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
count, err = repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||
handleError("CheckSongWithHash 2", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
}
|
||||
}
|
||||
|
||||
//count, _ := repo.CheckSong(ctx, path)
|
||||
if count > 0 {
|
||||
err = repo.UpdateSong(BackendCtx(), repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
|
||||
handleError("UpdateSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
} else {
|
||||
count2, err := repo.CheckSong(BackendCtx(), repository.CheckSongParams{SoundtrackID: id, Path: path})
|
||||
handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
if count2 > 0 {
|
||||
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path})
|
||||
handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
} else {
|
||||
songUuid := uuid.New()
|
||||
err = repo.AddSong(BackendCtx(), repository.AddSongParams{
|
||||
SoundtrackID: id,
|
||||
SongName: songName,
|
||||
Path: path,
|
||||
FileName: &fileName,
|
||||
Hash: songHash,
|
||||
Uuid: songUuid,
|
||||
SoundtrackUuid: soundtrackUuid,
|
||||
})
|
||||
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | UUID: %s | Path: %s | SongName: %s | SongHash: %s", id, songUuid.String(), path, entry.Name(), songHash))
|
||||
|
||||
}
|
||||
}
|
||||
return true
|
||||
} else if isCoverImage(fileInfo) {
|
||||
//TODO: Later add cover art image here in db
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleError(funcName string, err error, msg string) {
|
||||
var compareError = errors.New("no rows in result set")
|
||||
if err != nil {
|
||||
if compareError.Error() != err.Error() {
|
||||
logging.GetLogger().Error("Database error",
|
||||
zap.String("function", funcName),
|
||||
zap.String("error", err.Error()))
|
||||
if msg != "" {
|
||||
logging.GetLogger().Debug("Error context", zap.String("message", msg))
|
||||
catchedErrors = append(catchedErrors, fmt.Sprintf("Func: %s\nError message: %s\nDebug message: %s", funcName, err, msg))
|
||||
} else {
|
||||
catchedErrors = append(catchedErrors, fmt.Sprintf("Func: %s\nError message: %s", funcName, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getHashForDir(soundtrackDir string) string {
|
||||
directory, _ := directory_checksum.ScanDirectory(soundtrackDir, afero.NewOsFs())
|
||||
hash, _ := directory.ComputeDirectoryChecksums()
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
func getHashForFile(path string) string {
|
||||
hasher := md5.New()
|
||||
readFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to open file for hashing", zap.String("path", path), zap.String("error", err.Error()))
|
||||
}
|
||||
defer readFile.Close()
|
||||
hasher.Reset()
|
||||
_, err = io.Copy(hasher, readFile)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to hash file", zap.String("path", path), zap.String("error", err.Error()))
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
func getIdFromFile(file os.FileInfo) int32 {
|
||||
name := file.Name()
|
||||
if !file.IsDir() && strings.HasSuffix(name, ".id") {
|
||||
name = strings.Replace(name, ".id", "", 1)
|
||||
name = strings.Replace(name, ".", "", 1)
|
||||
i, _ := strconv.Atoi(name)
|
||||
return int32(i)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isSong(entry fs.FileInfo) bool {
|
||||
return !entry.IsDir() && strings.HasSuffix(entry.Name(), ".mp3")
|
||||
}
|
||||
|
||||
func isCoverImage(entry fs.FileInfo) bool {
|
||||
return !entry.IsDir() && strings.Contains(entry.Name(), "cover") &&
|
||||
(strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".png"))
|
||||
}
|
||||
|
||||
func removeIdFilesInSoundtrack(soundtrackDir string) bool {
|
||||
entries, err := os.ReadDir(soundtrackDir)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to read soundtrack directory for .id file cleanup",
|
||||
zap.String("path", soundtrackDir),
|
||||
zap.String("error", err.Error()))
|
||||
return false
|
||||
}
|
||||
|
||||
anyRemoved := false
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".id") {
|
||||
filePath := soundtrackDir + entry.Name()
|
||||
err := os.Remove(filePath)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to remove .id file",
|
||||
zap.String("path", filePath),
|
||||
zap.String("error", err.Error()))
|
||||
} else {
|
||||
logging.GetLogger().Info("Removed .id file", zap.String("path", filePath))
|
||||
anyRemoved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return anyRemoved
|
||||
}
|
||||
|
||||
func contains(s []string, searchTerm string) bool {
|
||||
i := sort.SearchStrings(s, searchTerm)
|
||||
return i < len(s) && s[i] == searchTerm
|
||||
}
|
||||
@@ -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 := getIdFromFile(tt.fileInfo)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getIdFromFile() = %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,115 @@
|
||||
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",
|
||||
Changelog: []string{
|
||||
"#9 - Major routes refactor and expansion",
|
||||
"#15 - Added API for statistics",
|
||||
"#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",
|
||||
"Added search cache backend",
|
||||
"Backend sync improvements",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "4.5.0",
|
||||
Changelog: []string{
|
||||
"#1 - Created request to check newest version of the app",
|
||||
"#2 - Added request to download the newest version of the app",
|
||||
"#3 - Added request to check progress during sync",
|
||||
"#4 - Now blocking all request while sync is in progress",
|
||||
"#5 - Implemented ants for thread pooling",
|
||||
"#6 - Changed the sync request to now only start the sync",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "4.0.0",
|
||||
Changelog: []string{
|
||||
"Changed framework from gin to Echo",
|
||||
"Reorganized the code",
|
||||
"Implemented sqlc",
|
||||
"Added support to send character images from the server",
|
||||
"Added function to create a new database of no one exists",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "3.2",
|
||||
Changelog: []string{"Upgraded Go version and the version of all dependencies. Fixed som more bugs."},
|
||||
},
|
||||
{
|
||||
Version: "3.1",
|
||||
Changelog: []string{"Fixed some bugs with songs not found made the application crash. Now checking if song exists and if not, remove song from DB and find another one. Frontend is now decoupled from the backend."},
|
||||
},
|
||||
{
|
||||
Version: "3.0",
|
||||
Changelog: []string{"Changed routing framework from mux to Gin. Swagger doc is now included in the application. A fronted can now be hosted from the application."},
|
||||
},
|
||||
{
|
||||
Version: "2.3.0",
|
||||
Changelog: []string{"Images should not be included in the database, removes songs where the path doesn't work."},
|
||||
},
|
||||
{
|
||||
Version: "2.2.0",
|
||||
Changelog: []string{"Changed the structure of the whole application, should be no changes to functionality."},
|
||||
},
|
||||
{
|
||||
Version: "2.1.4",
|
||||
Changelog: []string{"Game list should now be sorted, a new endpoint with the game list in random order have been added."},
|
||||
},
|
||||
{
|
||||
Version: "2.1.3",
|
||||
Changelog: []string{"Added a check to see if song exists before returning it, if not a new song will be picked up."},
|
||||
},
|
||||
{
|
||||
Version: "2.1.2",
|
||||
Changelog: []string{"Added test server to swagger file."},
|
||||
},
|
||||
{
|
||||
Version: "2.1.1",
|
||||
Changelog: []string{"Fixed bug where wrong song was showed as currently played."},
|
||||
},
|
||||
{
|
||||
Version: "2.1.0",
|
||||
Changelog: []string{
|
||||
"Added /addQue to add the last received song to the songQue.",
|
||||
"Changed /rand and /rand/low to not add song to the que.",
|
||||
"Changed /next to not call /rand when the end of the que is reached, instead the last song in the que will be resent.",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "2.0.3",
|
||||
Changelog: []string{"Another small change that should fix the caching problem."},
|
||||
},
|
||||
{
|
||||
Version: "2.0.2",
|
||||
Changelog: []string{"Hopefully fixed the caching problem with random."},
|
||||
},
|
||||
{
|
||||
Version: "2.0.1",
|
||||
Changelog: []string{"Fixed CORS"},
|
||||
},
|
||||
{
|
||||
Version: "2.0.0",
|
||||
Changelog: []string{"Rebuilt the application in Go."},
|
||||
},
|
||||
}
|
||||
|
||||
func GetLatestVersion() VersionData {
|
||||
return data[0]
|
||||
}
|
||||
|
||||
func GetVersionHistory() []VersionData {
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/lib/pq"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Database holds the database connection pool and context
|
||||
type Database struct {
|
||||
Pool *pgxpool.Pool
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
// NewDatabase creates a new Database instance with connection pool
|
||||
func NewDatabase(host, port, user, password, dbname string) (*Database, error) {
|
||||
ctx := context.Background()
|
||||
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
|
||||
logging.GetLogger().Debug("Database connection info",
|
||||
zap.String("host", host),
|
||||
zap.String("port", port),
|
||||
zap.String("dbname", dbname))
|
||||
|
||||
pool, err := pgxpool.New(ctx, psqlInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
var success string
|
||||
err = pool.QueryRow(ctx, "select 'Successfully connected!'").Scan(&success)
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("database query failed: %w", err)
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Database connected", zap.String("status", success))
|
||||
|
||||
return &Database{Pool: pool, Ctx: ctx}, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection pool
|
||||
func (db *Database) Close() {
|
||||
if db.Pool != nil {
|
||||
logging.GetLogger().Info("Closing database connection")
|
||||
db.Pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Health checks the health of the database connection by pinging the database.
|
||||
// It returns a map with keys indicating various health statistics.
|
||||
func (db *Database) Health() map[string]string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats := make(map[string]string)
|
||||
|
||||
// Ping the database
|
||||
err := db.Pool.Ping(ctx)
|
||||
if err != nil {
|
||||
stats["status"] = "down"
|
||||
stats["error"] = err.Error()
|
||||
return stats
|
||||
}
|
||||
|
||||
stats["status"] = "up"
|
||||
return stats
|
||||
}
|
||||
|
||||
// RunMigrations runs all pending database migrations to the latest version.
|
||||
// Uses the existing pool to extract connection details.
|
||||
func (db *Database) RunMigrations() error {
|
||||
// Extract connection info from pool config
|
||||
connConfig := db.Pool.Config().ConnConfig
|
||||
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||
connConfig.User,
|
||||
connConfig.Password,
|
||||
connConfig.Host,
|
||||
connConfig.Port,
|
||||
connConfig.Database)
|
||||
|
||||
logging.GetLogger().Debug("Migration info", zap.String("url", migrationURL))
|
||||
|
||||
sqlDb, err := sql.Open("postgres", migrationURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database for migration: %w", err)
|
||||
}
|
||||
defer sqlDb.Close()
|
||||
|
||||
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
files, err := iofs.New(MigrationsFs, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration files: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrator: %w", err)
|
||||
}
|
||||
|
||||
// Get current version for logging
|
||||
version, _, err := m.Version()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
|
||||
|
||||
// Run all pending migrations to latest version
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
if err == migrate.ErrNoChange {
|
||||
logging.GetLogger().Info("Database already up to date")
|
||||
} else {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Get new version after migration
|
||||
versionAfter, _, _ := m.Version()
|
||||
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration completed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/lib/pq"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TODO: DEPRECATED - Remove these global variables once all code is migrated to use Database struct
|
||||
// Use database.go's Database struct instead. These globals remain for backward compatibility
|
||||
// with legacy code paths. New code should use the Database struct from database.go.
|
||||
var Dbpool *pgxpool.Pool
|
||||
var Ctx = context.Background()
|
||||
|
||||
//go:embed "migrations/*.sql"
|
||||
var MigrationsFs embed.FS
|
||||
|
||||
func InitDB(host string, port string, user string, password string, dbname string) {
|
||||
|
||||
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))
|
||||
|
||||
var err error
|
||||
Dbpool, err = pgxpool.New(Ctx, psqlInfo)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Unable to connect to database", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
var success string
|
||||
err = Dbpool.QueryRow(Ctx, "select 'Successfully connected!'").Scan(&success)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Database query failed", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Info("Database connected", zap.String("status", success))
|
||||
}
|
||||
|
||||
func CloseDb() {
|
||||
logging.GetLogger().Info("Closing database connection")
|
||||
Dbpool.Close()
|
||||
}
|
||||
|
||||
func ResetSoundtrackIdSeq() {
|
||||
_, err := Dbpool.Query(Ctx, "SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);")
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to reset soundtrack ID sequence", zap.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func createDb(host string, port string, user string, password string, dbname string) {
|
||||
// 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)
|
||||
defer db.Close()
|
||||
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to connect for database creation", zap.String("error", err.Error()))
|
||||
}
|
||||
_, err = db.Exec("create database " + dbname)
|
||||
if err != nil {
|
||||
//handle the error
|
||||
logging.GetLogger().Fatal("Failed to create database", zap.String("error", err.Error()))
|
||||
}
|
||||
logging.GetLogger().Info("Database created", zap.String("dbname", dbname))
|
||||
}
|
||||
|
||||
func Migrate_db(host string, port string, user string, password string, dbname string) {
|
||||
migrationInfo := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
user, password, host, port, dbname)
|
||||
|
||||
logging.GetLogger().Debug("Migration info", zap.String("url", migrationInfo))
|
||||
|
||||
db, err := sql.Open("postgres", migrationInfo)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to open database for migration", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to create migration driver", zap.String("error", err.Error()))
|
||||
}
|
||||
files, err := iofs.New(MigrationsFs, "migrations")
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to create migration files", zap.String("error", err.Error()))
|
||||
}
|
||||
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to create migrator", zap.String("error", err.Error()))
|
||||
}
|
||||
/*m, err := migrate.NewWithDatabaseInstance(
|
||||
"file://./db/migrations/",
|
||||
"postgres", driver)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Migration setup error", zap.String("error", err.Error()))
|
||||
}*/
|
||||
|
||||
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))
|
||||
|
||||
//err = m.Force(1)
|
||||
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
|
||||
//if err != nil {
|
||||
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
|
||||
//}
|
||||
|
||||
// Use Up() to apply all pending migrations instead of Migrate(2)
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration completed")
|
||||
|
||||
db.Close()
|
||||
}
|
||||
|
||||
// Health checks the health of the database connection by pinging the database.
|
||||
// It returns a map with keys indicating various health statistics.
|
||||
func Health() map[string]string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats := make(map[string]string)
|
||||
|
||||
// Ping the database
|
||||
//err := s.db.PingContext(ctx)
|
||||
err := Dbpool.Ping(ctx)
|
||||
if err != nil {
|
||||
stats["status"] = "down"
|
||||
stats["error"] = fmt.Sprintf("db down: %v", err)
|
||||
logging.GetLogger().Fatal("Database health check failed", zap.String("error", err.Error()))
|
||||
return stats
|
||||
}
|
||||
|
||||
// Database is up, add more statistics
|
||||
stats["status"] = "up"
|
||||
stats["message"] = "It's healthy"
|
||||
|
||||
// Get database stats (like open connections, in use, idle, etc.)
|
||||
//dbStats := s.db.Stats()
|
||||
dbStats := Dbpool.Stat()
|
||||
//stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
|
||||
stats["open_connections"] = strconv.Itoa(int(dbStats.NewConnsCount()))
|
||||
//stats["in_use"] = strconv.Itoa(dbStats.InUse)
|
||||
stats["in_use"] = strconv.Itoa(int(dbStats.AcquiredConns()))
|
||||
//stats["idle"] = strconv.Itoa(dbStats.Idle)
|
||||
stats["idle"] = strconv.Itoa(int(dbStats.IdleConns()))
|
||||
//stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
|
||||
stats["wait_count"] = strconv.FormatInt(dbStats.AcquireCount(), 10)
|
||||
//stats["wait_duration"] = dbStats.WaitDuration.String()
|
||||
stats["wait_duration"] = dbStats.AcquireDuration().String()
|
||||
//stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
|
||||
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleDestroyCount(), 10)
|
||||
//stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
|
||||
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeDestroyCount(), 10)
|
||||
|
||||
// Evaluate stats to provide a health message
|
||||
if int(dbStats.NewConnsCount()) > 40 { // Assuming 50 is the max for this example
|
||||
stats["message"] = "The database is experiencing heavy load."
|
||||
}
|
||||
|
||||
if dbStats.AcquireCount() > 1000 {
|
||||
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
|
||||
}
|
||||
|
||||
if dbStats.MaxIdleDestroyCount() > int64(dbStats.NewConnsCount())/2 {
|
||||
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
|
||||
}
|
||||
|
||||
if dbStats.MaxLifetimeDestroyCount() > int64(dbStats.NewConnsCount())/2 {
|
||||
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMigrationsStepByStep tests applying migrations incrementally
|
||||
// Then adding data manually, then completing migrations
|
||||
func TestMigrationsStepByStep(t *testing.T) {
|
||||
host := os.Getenv("DB_HOST")
|
||||
port := os.Getenv("DB_PORT")
|
||||
user := os.Getenv("DB_USERNAME")
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
// Use a unique database name for this test
|
||||
dbname := "music_server_migration_test"
|
||||
|
||||
if host == "" || port == "" || user == "" || password == "" {
|
||||
t.Skip("Test database environment variables not set (DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD)")
|
||||
}
|
||||
|
||||
// Clean up: drop database if it exists
|
||||
cleanupDB(t, host, port, user, password, dbname)
|
||||
defer cleanupDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Create the database
|
||||
createTestDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Step 1: Apply first 4 migrations (before soundtrack rename)
|
||||
// This creates: game, song, vgmq, song_list tables
|
||||
// And sessions table with indexes
|
||||
t.Run("ApplyFirst4Migrations", func(t *testing.T) {
|
||||
applyMigrations(t, host, port, user, password, dbname, 4)
|
||||
})
|
||||
|
||||
// Step 2: Add data manually to game and song tables
|
||||
t.Run("AddManualData", func(t *testing.T) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Insert 5 games manually
|
||||
for i := 1; i <= 5; i++ {
|
||||
gameName := fmt.Sprintf("Manual Game %d", i)
|
||||
path := fmt.Sprintf("/manual/path/game%d", i)
|
||||
hash := fmt.Sprintf("hash-%d", i)
|
||||
|
||||
_, err := db.Exec(`INSERT INTO game (game_name, path, hash, added)
|
||||
VALUES ($1, $2, $3, NOW())`,
|
||||
gameName, path, hash)
|
||||
require.NoError(t, err, "Failed to insert game %d", i)
|
||||
}
|
||||
|
||||
// Insert songs for each game
|
||||
songs := []struct {
|
||||
gameID int
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{1, "Song A", "/path/a.mp3"},
|
||||
{1, "Song B", "/path/b.mp3"},
|
||||
{2, "Song C", "/path/c.mp3"},
|
||||
{2, "Song D", "/path/d.mp3"},
|
||||
{3, "Song E", "/path/e.mp3"},
|
||||
{4, "Song F", "/path/f.mp3"},
|
||||
{4, "Song G", "/path/g.mp3"},
|
||||
{4, "Song H", "/path/h.mp3"},
|
||||
{5, "Song I", "/path/i.mp3"},
|
||||
}
|
||||
|
||||
for _, s := range songs {
|
||||
_, err := db.Exec(`INSERT INTO song (game_id, song_name, path, hash)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name))
|
||||
require.NoError(t, err, "Failed to insert song %s", s.name)
|
||||
}
|
||||
|
||||
// Verify data was inserted
|
||||
var gameCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM game").Scan(&gameCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, gameCount, "Expected 5 games")
|
||||
|
||||
var songCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 9, songCount, "Expected 9 songs")
|
||||
|
||||
t.Log("✓ Manually inserted 5 games with 9 songs")
|
||||
})
|
||||
|
||||
// Step 3: Apply migration 5 (rename game→soundtrack)
|
||||
t.Run("ApplyMigration5", func(t *testing.T) {
|
||||
// Apply the remaining migrations (just migration 5)
|
||||
applyMigrations(t, host, port, user, password, dbname, 1)
|
||||
|
||||
// Verify tables were renamed
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Check that soundtrack table exists
|
||||
var soundtrackCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, soundtrackCount, "Expected 5 soundtracks after migration")
|
||||
|
||||
// Check that game table no longer exists
|
||||
_, err = db.Exec("SELECT 1 FROM game LIMIT 1")
|
||||
require.Error(t, err, "game table should not exist after migration")
|
||||
|
||||
// Check that song table has soundtrack_id column
|
||||
var songCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 9, songCount, "Expected 9 songs after migration")
|
||||
|
||||
// Verify data integrity: soundtrack_name values
|
||||
rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id")
|
||||
require.NoError(t, err)
|
||||
defer rows.Close()
|
||||
|
||||
expectedNames := []string{"Manual Game 1", "Manual Game 2", "Manual Game 3", "Manual Game 4", "Manual Game 5"}
|
||||
actualNames := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
err := rows.Scan(&name)
|
||||
require.NoError(t, err)
|
||||
actualNames = append(actualNames, name)
|
||||
}
|
||||
require.Equal(t, expectedNames, actualNames, "Soundtrack names should match original game names")
|
||||
|
||||
t.Log("✓ Migration 5 applied successfully, data preserved")
|
||||
})
|
||||
}
|
||||
|
||||
// cleanupDB drops the test database
|
||||
func cleanupDB(t *testing.T, host, port, user, password, dbname string) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||
host, port, user, password)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not connect to cleanup DB: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Check if database exists before dropping
|
||||
var exists int
|
||||
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Logf("Warning: could not check if DB exists: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists == 1 {
|
||||
_, err = db.Exec("DROP DATABASE " + dbname + " WITH (FORCE)")
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not drop DB: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createTestDB creates a fresh test database
|
||||
func createTestDB(t *testing.T, host, port, user, password, dbname string) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||
host, port, user, password)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Drop if exists
|
||||
cleanupDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Create database
|
||||
_, err = db.Exec("CREATE DATABASE " + dbname)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Enable UUID extension if needed
|
||||
connStrDB := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db2, err := sql.Open("postgres", connStrDB)
|
||||
require.NoError(t, err)
|
||||
defer db2.Close()
|
||||
|
||||
_, err = db2.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
|
||||
if err != nil {
|
||||
t.Logf("Note: uuid-ossp extension may not be available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyMigrations applies n migrations to the database using Go migrate library
|
||||
func applyMigrations(t *testing.T, host, port, user, password, dbname string, steps int) {
|
||||
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
user, password, host, port, dbname)
|
||||
|
||||
db, err := sql.Open("postgres", migrationURL)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
"file://migrations",
|
||||
"postgres", driver)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get current version
|
||||
version, _, err := m.Version()
|
||||
if err != nil && err != migrate.ErrNilVersion {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if err == migrate.ErrNilVersion {
|
||||
version = 0
|
||||
}
|
||||
t.Logf("Current migration version: %d", version)
|
||||
|
||||
// Apply exactly 'steps' migrations
|
||||
if steps > 0 {
|
||||
err = m.Steps(steps)
|
||||
if err != nil && err != migrate.ErrNoChange {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
} else if steps < 0 {
|
||||
err = m.Steps(steps)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get new version
|
||||
newVersion, _, err := m.Version()
|
||||
if err != nil && err != migrate.ErrNilVersion {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if err == migrate.ErrNilVersion {
|
||||
newVersion = 0
|
||||
}
|
||||
t.Logf("Migration version after applying %d steps: %d", steps, newVersion)
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
Alter table game
|
||||
alter column times_played set null;
|
||||
|
||||
Alter table song
|
||||
alter column times_played set null;
|
||||
@@ -0,0 +1,5 @@
|
||||
Alter table game
|
||||
alter column times_played set not null;
|
||||
|
||||
Alter table song
|
||||
alter column times_played set not null;
|
||||
@@ -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,64 @@
|
||||
-- Rollback: Remove UUID columns and revert rename
|
||||
|
||||
-- Step 1: Drop UUID-related indexes
|
||||
DROP INDEX IF EXISTS idx_song_uuid;
|
||||
DROP INDEX IF EXISTS idx_song_soundtrack_uuid;
|
||||
DROP INDEX IF EXISTS idx_soundtrack_uuid;
|
||||
DROP INDEX IF EXISTS idx_song_soundtrack_id;
|
||||
DROP INDEX IF EXISTS idx_song_path;
|
||||
|
||||
-- Step 1.5: Remove NOT NULL constraints before dropping columns (Issue #28 rollback)
|
||||
ALTER TABLE song ALTER COLUMN soundtrack_uuid DROP NOT NULL;
|
||||
ALTER TABLE song ALTER COLUMN uuid DROP NOT NULL;
|
||||
ALTER TABLE soundtrack ALTER COLUMN uuid DROP NOT NULL;
|
||||
|
||||
-- Step 2: Drop UUID-related constraints
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_path_unique;
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_uuid_fkey;
|
||||
ALTER TABLE soundtrack DROP CONSTRAINT IF EXISTS soundtrack_uuid_unique;
|
||||
|
||||
-- Step 3: Drop UUID columns from song table
|
||||
ALTER TABLE song DROP COLUMN IF EXISTS soundtrack_uuid;
|
||||
ALTER TABLE song DROP COLUMN IF EXISTS uuid;
|
||||
|
||||
-- Step 4: Drop UUID column from soundtrack table
|
||||
ALTER TABLE soundtrack DROP COLUMN IF EXISTS uuid;
|
||||
|
||||
-- Step 5: Drop the uuid-based primary key
|
||||
ALTER TABLE song DROP CONSTRAINT song_pkey;
|
||||
|
||||
-- Step 6: Restore composite primary key on song
|
||||
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (soundtrack_id, path);
|
||||
|
||||
-- Step 7: Drop foreign key constraint
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
||||
|
||||
-- Step 8: Revert rename operations
|
||||
-- Revert song_list table references
|
||||
ALTER TABLE song_list RENAME COLUMN soundtrack_name TO game_name;
|
||||
|
||||
-- Revert song table: rename soundtrack_id back to game_id
|
||||
ALTER TABLE song RENAME COLUMN soundtrack_id TO game_id;
|
||||
|
||||
-- Revert columns in game table
|
||||
ALTER TABLE game RENAME COLUMN soundtrack_name TO game_name;
|
||||
|
||||
-- Revert primary key sequence
|
||||
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
|
||||
|
||||
-- Revert: Rename soundtrack table back to game
|
||||
ALTER TABLE soundtrack RENAME TO game;
|
||||
|
||||
-- Revert foreign key constraint
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey;
|
||||
ALTER TABLE song ADD CONSTRAINT song_game_id_fkey
|
||||
FOREIGN KEY (game_id) REFERENCES game(id);
|
||||
|
||||
-- Revert indexes
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_deleted RENAME TO idx_game_deleted;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_hash RENAME TO idx_game_hash;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_path RENAME TO idx_game_path;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_name RENAME TO idx_game_name;
|
||||
ALTER INDEX IF EXISTS idx_song_soundtrack_id RENAME TO idx_song_game_id;
|
||||
ALTER INDEX IF EXISTS idx_song_soundtrack_id_song_name RENAME TO idx_song_game_id_song_name;
|
||||
ALTER INDEX IF EXISTS song_list_soundtrack_name_idx RENAME TO song_list_game_name_idx;
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Migration: Rename game to soundtrack AND add UUID columns
|
||||
-- Combines original migrations 000005 (rename), 000006 (UUID columns), and Issue #28 (Phase 2: NOT NULL constraints)
|
||||
-- Phase 1 & 2: Dual-write with required UUIDs (integer IDs still exist alongside UUIDs)
|
||||
|
||||
-- ============ PART 1: RENAME (from original migration 000005) ============
|
||||
|
||||
-- Rename game table to soundtrack
|
||||
ALTER TABLE game RENAME TO soundtrack;
|
||||
|
||||
-- Rename primary key sequence
|
||||
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
|
||||
|
||||
-- Rename columns in soundtrack table
|
||||
ALTER TABLE soundtrack RENAME COLUMN game_name TO soundtrack_name;
|
||||
|
||||
-- Update song table: rename game_id to soundtrack_id
|
||||
ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id;
|
||||
|
||||
-- Update song_list table references
|
||||
ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name;
|
||||
|
||||
-- Rename foreign key constraint
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey;
|
||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
||||
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
||||
|
||||
-- Rename indexes
|
||||
ALTER INDEX IF EXISTS idx_game_deleted RENAME TO idx_soundtrack_deleted;
|
||||
ALTER INDEX IF EXISTS idx_game_hash RENAME TO idx_soundtrack_hash;
|
||||
ALTER INDEX IF EXISTS idx_game_path RENAME TO idx_soundtrack_path;
|
||||
ALTER INDEX IF EXISTS idx_game_name RENAME TO idx_soundtrack_name;
|
||||
ALTER INDEX IF EXISTS idx_song_game_id RENAME TO idx_song_soundtrack_id;
|
||||
ALTER INDEX IF EXISTS idx_song_game_id_song_name RENAME TO idx_song_soundtrack_id_song_name;
|
||||
ALTER INDEX IF EXISTS song_list_game_name_idx RENAME TO song_list_soundtrack_name_idx;
|
||||
|
||||
-- ============ PART 2: UUID COLUMNS (from original migration 000006) ============
|
||||
|
||||
-- Step 0: Enable uuid-ossp extension for UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Add UUID column to soundtrack table
|
||||
ALTER TABLE soundtrack ADD COLUMN uuid UUID DEFAULT gen_random_uuid();
|
||||
|
||||
-- Add unique constraint on soundtrack.uuid
|
||||
ALTER TABLE soundtrack ADD CONSTRAINT soundtrack_uuid_unique UNIQUE (uuid);
|
||||
|
||||
-- Backfill existing soundtracks with UUIDs (should already have values from DEFAULT)
|
||||
UPDATE soundtrack SET uuid = gen_random_uuid() WHERE uuid IS NULL;
|
||||
|
||||
-- Add index on soundtrack.uuid for query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_soundtrack_uuid ON soundtrack(uuid);
|
||||
|
||||
-- Add UUID columns to song table
|
||||
ALTER TABLE song ADD COLUMN uuid UUID DEFAULT gen_random_uuid();
|
||||
ALTER TABLE song ADD COLUMN soundtrack_uuid UUID;
|
||||
|
||||
-- Backfill existing songs with UUIDs
|
||||
UPDATE song SET uuid = gen_random_uuid() WHERE uuid IS NULL;
|
||||
|
||||
-- Backfill song.soundtrack_uuid from soundtrack.uuid via JOIN
|
||||
UPDATE song s
|
||||
SET soundtrack_uuid = st.uuid
|
||||
FROM soundtrack st
|
||||
WHERE s.soundtrack_id = st.id AND s.soundtrack_uuid IS NULL;
|
||||
|
||||
-- Add foreign key constraint from song.soundtrack_uuid to soundtrack.uuid
|
||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_uuid_fkey
|
||||
FOREIGN KEY (soundtrack_uuid) REFERENCES soundtrack(uuid);
|
||||
|
||||
-- Add indexes on song UUID columns
|
||||
CREATE INDEX IF NOT EXISTS idx_song_uuid ON song(uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_song_soundtrack_uuid ON song(soundtrack_uuid);
|
||||
|
||||
-- Drop the composite primary key (soundtrack_id, path)
|
||||
ALTER TABLE song DROP CONSTRAINT song_pkey;
|
||||
|
||||
-- Add new primary key on uuid column
|
||||
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (uuid);
|
||||
|
||||
-- Add unique constraint to maintain same semantics as old PK
|
||||
-- This ensures (soundtrack, path) combinations remain unique
|
||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_path_unique UNIQUE (soundtrack_uuid, path);
|
||||
|
||||
-- Create index on soundtrack_id for query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_song_soundtrack_id ON song(soundtrack_id);
|
||||
|
||||
-- Create index on path for lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
|
||||
|
||||
-- ============ PART 3: MAKE UUID COLUMNS NOT NULL (Issue #28 - Phase 2) ============
|
||||
|
||||
-- Add NOT NULL constraint to soundtrack.uuid
|
||||
ALTER TABLE soundtrack ALTER COLUMN uuid SET NOT NULL;
|
||||
|
||||
-- Add NOT NULL constraint to song.uuid
|
||||
ALTER TABLE song ALTER COLUMN uuid SET NOT NULL;
|
||||
|
||||
-- Add NOT NULL constraint to song.soundtrack_uuid
|
||||
ALTER TABLE song ALTER COLUMN soundtrack_uuid SET NOT NULL;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- name: ClearSongs :exec
|
||||
DELETE FROM song;
|
||||
|
||||
-- name: ClearSongsBySoundtrackId :exec
|
||||
DELETE FROM song WHERE soundtrack_id = $1;
|
||||
|
||||
-- name: AddSong :exec
|
||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7);
|
||||
|
||||
-- name: CheckSong :one
|
||||
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||
|
||||
-- name: CheckSongWithHash :one
|
||||
SELECT COUNT(*) FROM song WHERE hash = $1;
|
||||
|
||||
-- name: GetSongWithHash :one
|
||||
SELECT * FROM song WHERE hash = $1;
|
||||
|
||||
-- name: UpdateSong :exec
|
||||
UPDATE song SET song_name=$1, file_name=$2, path=$3 where hash=$4;
|
||||
|
||||
-- name: AddHashToSong :exec
|
||||
UPDATE song SET hash=$1 where soundtrack_id = $2 AND path = $3;
|
||||
|
||||
-- name: FindSongsFromSoundtrack :many
|
||||
SELECT *
|
||||
FROM song
|
||||
WHERE soundtrack_id = $1;
|
||||
|
||||
-- name: AddSongPlayed :exec
|
||||
UPDATE song SET times_played = times_played + 1
|
||||
WHERE soundtrack_id = $1 AND song_name = $2;
|
||||
|
||||
-- name: FetchAllSongs :many
|
||||
SELECT * FROM song;
|
||||
|
||||
-- name: GetSongByUUID :one
|
||||
SELECT * FROM song WHERE uuid = $1;
|
||||
|
||||
-- name: RemoveBrokenSong :exec
|
||||
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||
|
||||
-- name: RemoveBrokenSongs :exec
|
||||
DELETE FROM song WHERE uuid = ANY($1);
|
||||
|
||||
-- name: GetSongsBySoundtrackUUID :many
|
||||
SELECT *
|
||||
FROM song
|
||||
WHERE soundtrack_uuid = $1;
|
||||
|
||||
-- name: GetSongUUIDsBySoundtrackId :many
|
||||
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
|
||||
@@ -1,5 +1,5 @@
|
||||
-- name: InsertSongInList :exec
|
||||
INSERT INTO song_list (match_date, match_id, song_no, game_name, song_name)
|
||||
INSERT INTO song_list (match_date, match_id, song_no, soundtrack_name, song_name)
|
||||
VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: GetSongList :many
|
||||
@@ -0,0 +1,58 @@
|
||||
-- name: ResetSoundtrackIdSeq :one
|
||||
SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);
|
||||
|
||||
-- name: GetSoundtrackNameById :one
|
||||
SELECT soundtrack_name FROM soundtrack WHERE id = $1;
|
||||
|
||||
-- name: GetSoundtrackById :one
|
||||
SELECT *
|
||||
FROM soundtrack
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL;
|
||||
|
||||
-- name: SetSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL;
|
||||
|
||||
-- name: ClearSoundtracks :exec
|
||||
DELETE FROM soundtrack;
|
||||
|
||||
-- name: UpdateSoundtrackName :exec
|
||||
UPDATE soundtrack SET soundtrack_name=sqlc.arg(name), path=sqlc.arg(path), last_changed=now() WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: UpdateSoundtrackHash :exec
|
||||
UPDATE soundtrack SET hash=sqlc.arg(hash), last_changed=now() WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: RemoveSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=NULL WHERE id=$1;
|
||||
|
||||
-- name: GetIdBySoundtrackName :one
|
||||
SELECT id FROM soundtrack WHERE soundtrack_name = $1;
|
||||
|
||||
-- name: InsertSoundtrack :one
|
||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid;
|
||||
|
||||
-- name: InsertSoundtrackWithExistingId :exec
|
||||
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
||||
|
||||
-- name: FindAllSoundtracks :many
|
||||
SELECT *
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY soundtrack_name;
|
||||
|
||||
-- name: GetAllSoundtracksIncludingDeleted :many
|
||||
SELECT *
|
||||
FROM soundtrack
|
||||
ORDER BY soundtrack_name;
|
||||
|
||||
-- name: AddSoundtrackPlayed :exec
|
||||
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
||||
|
||||
-- name: GetSoundtrackByUUID :one
|
||||
SELECT *
|
||||
FROM soundtrack
|
||||
WHERE uuid = $1
|
||||
AND deleted IS NULL;
|
||||
|
||||
-- name: GetSoundtrackUUIDById :one
|
||||
SELECT uuid FROM soundtrack WHERE id = $1;
|
||||
@@ -0,0 +1,148 @@
|
||||
-- Most played soundtracks with their songs
|
||||
-- name: GetMostPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played DESC, g.soundtrack_name
|
||||
LIMIT $1;
|
||||
|
||||
-- Least played soundtracks with their songs
|
||||
-- name: GetLeastPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played ASC, g.soundtrack_name
|
||||
LIMIT $1;
|
||||
|
||||
-- Most played songs with their soundtrack info
|
||||
-- name: GetMostPlayedSongsWithSoundtrack :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
s.times_played,
|
||||
s.file_name
|
||||
FROM song s
|
||||
JOIN soundtrack g ON s.soundtrack_id = g.id
|
||||
WHERE g.deleted IS NULL
|
||||
ORDER BY s.times_played DESC, s.song_name
|
||||
LIMIT $1;
|
||||
|
||||
-- Least played songs with their soundtrack info
|
||||
-- name: GetLeastPlayedSongsWithSoundtrack :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
s.times_played,
|
||||
s.file_name
|
||||
FROM song s
|
||||
JOIN soundtrack g ON s.soundtrack_id = g.id
|
||||
WHERE g.deleted IS NULL
|
||||
ORDER BY s.times_played ASC, s.song_name
|
||||
LIMIT $1;
|
||||
|
||||
-- Soundtracks that have never been played (times_played = 0)
|
||||
-- name: GetNeverPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.added,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.times_played = 0
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
|
||||
ORDER BY g.soundtrack_name;
|
||||
|
||||
-- Last played soundtracks (most recently played)
|
||||
-- name: GetLastPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.last_played DESC
|
||||
LIMIT $1;
|
||||
|
||||
-- Oldest played soundtracks (least recently played, but has been played at least once)
|
||||
-- name: GetOldestPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.last_played ASC
|
||||
LIMIT $1;
|
||||
|
||||
-- Get statistics summary
|
||||
-- name: GetStatisticsSummary :one
|
||||
SELECT
|
||||
COUNT(*) as total_soundtracks,
|
||||
COALESCE(SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END), 0)::bigint as played_soundtracks,
|
||||
COALESCE(SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END), 0)::bigint as never_played_soundtracks,
|
||||
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
|
||||
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
|
||||
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
|
||||
COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL;
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user