Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e2280969 | ||
|
|
439ffede18 | ||
|
|
c70eb4d13c | ||
|
|
bb8de5548d | ||
|
|
f79f51ea4c | ||
|
|
c62b65d7cd | ||
|
|
76a1b946eb | ||
|
|
d31bf3551c | ||
|
|
a705d7b476 | ||
|
|
b5dc42b654 | ||
|
|
080bacae80 | ||
|
|
fc7d787014 | ||
|
|
6da96f79e7 | ||
|
|
973ebf224b | ||
|
|
0ec2425f78 | ||
|
|
bc7d406e41 | ||
|
|
2c3e97190d | ||
|
|
8c974a49cc | ||
|
|
9db61956d3 |
No files matched your search
@@ -6,7 +6,7 @@ tmp_dir = "tmp"
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ./cmd"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "swagger", "cmd/swagger"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: CI
|
||||
|
||||
# Branches and pull requests: unit tests, integration tests and a check that the image builds (amd64 only).
|
||||
# Version tags (v1.2.3): the same tests, then build amd64 + arm64 and push the image.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same branch cancels the run that is still going.
|
||||
concurrency:
|
||||
group: ci-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Generate templ components
|
||||
run: go tool templ generate
|
||||
- name: Vet
|
||||
run: go vet ./... && go vet -tags integration ./...
|
||||
- name: Unit tests
|
||||
run: go test -race ./...
|
||||
- name: Cross-compile for arm64
|
||||
run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The test helper stops its own container, so the reaper container is not needed.
|
||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Generate templ components
|
||||
run: go tool templ generate
|
||||
- name: Integration tests (PostgreSQL via testcontainers)
|
||||
run: go test -tags integration -race -timeout 15m ./internal/db/... ./internal/backend/...
|
||||
|
||||
# Checks that the image builds. Runs in parallel with the tests; nothing is pushed.
|
||||
docker:
|
||||
if: ${{ !startsWith(gitea.ref, 'refs/tags/') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: 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@v3
|
||||
with:
|
||||
registry: gitea.sanplex.xyz
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Build (amd64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
|
||||
# Only for version tags, and only when all tests have passed.
|
||||
publish:
|
||||
if: ${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||
needs: [test, integration]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: 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@v3
|
||||
with:
|
||||
registry: gitea.sanplex.xyz
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Build and push (amd64 + arm64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
tags: |
|
||||
gitea.sanplex.xyz/sansan/musicserver:${{ gitea.ref_name }}
|
||||
gitea.sanplex.xyz/sansan/musicserver:latest
|
||||
@@ -1,48 +0,0 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Generate templ components
|
||||
run: go tool templ generate
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Unit tests
|
||||
run: go test -race ./...
|
||||
|
||||
docker:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: 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@v3
|
||||
with:
|
||||
registry: gitea.sanplex.xyz
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*.*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Generate templ components
|
||||
run: go tool templ generate
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Unit tests
|
||||
run: go test -race ./...
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: 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@v3
|
||||
with:
|
||||
registry: gitea.sanplex.xyz
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
gitea.sanplex.xyz/sansan/musicserver:${{ gitea.ref_name }}
|
||||
gitea.sanplex.xyz/sansan/musicserver:latest
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Integration Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger only
|
||||
|
||||
jobs:
|
||||
integration-test:
|
||||
# Needs access to a Docker daemon: the tests start PostgreSQL with testcontainers.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Generate templ components
|
||||
run: go tool templ generate
|
||||
- name: Integration tests
|
||||
run: go test -tags integration -race -timeout 15m ./...
|
||||
@@ -10,3 +10,10 @@ main
|
||||
|
||||
# Database dumps
|
||||
*_dump.sql
|
||||
|
||||
# Generated with `just openapi`
|
||||
/openapi.json
|
||||
/openapi-3.0.json
|
||||
|
||||
# Local cover images (COVERS_PATH)
|
||||
/covers/
|
||||
+13
-7
@@ -3,21 +3,21 @@ ARG FRONTEND_IMAGE=gitea.sanplex.xyz/sansan/musicfrontend:latest
|
||||
|
||||
# Stage 1: Build the backend on the build machine and cross-compile for the target platform,
|
||||
# so multi-platform builds need no emulation.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||
ARG TARGETOS TARGETARCH
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
RUN go tool templ generate
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
go tool templ generate
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||
go build -trimpath -ldflags="-s -w" -tags timetzdata -o main ./cmd
|
||||
|
||||
# Stage 2: Frontend static files (the same for every platform)
|
||||
FROM --platform=$BUILDPLATFORM ${FRONTEND_IMAGE} AS frontend
|
||||
# Empty API_HOSTNAME makes the frontend use relative paths
|
||||
RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > /usr/share/nginx/html/config.js
|
||||
|
||||
# Stage 3: Final image. No RUN steps here, so no emulation is needed for other architectures.
|
||||
FROM alpine:3.22
|
||||
@@ -27,13 +27,19 @@ COPY --from=build /app/main .
|
||||
COPY --from=frontend /usr/share/nginx/html /frontend
|
||||
COPY ./songs/ ./songs/
|
||||
|
||||
# The music volume must be readable by uid 1000, and writable for the .id file cleanup during sync.
|
||||
# The music volume must be readable by uid 1000, and writable for the .id file cleanup during sync
|
||||
# and for soundtrack uploads (kept in .uploads until finished).
|
||||
USER 1000:1000
|
||||
EXPOSE 8080
|
||||
# Prometheus metrics (METRICS_PORT). Keep it off the public proxy.
|
||||
EXPOSE 2112
|
||||
VOLUME /sorted
|
||||
VOLUME /characters
|
||||
|
||||
# Required: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME, MUSIC_PATH, CHARACTERS_PATH
|
||||
# Covers (optional): COVERS_PATH, a writable folder such as a /covers volume, turns covers on.
|
||||
# STEAMGRIDDB_API_KEY also turns on the automatic search (SteamGridDB, then AniList).
|
||||
# Uploads (optional): UPLOAD_MAX_FILE_MB (default 50) and UPLOAD_MAX_TOTAL_MB (default 2048).
|
||||
ENV PORT=8080
|
||||
|
||||
CMD ["./main"]
|
||||
-1538
File diff suppressed because it is too large.
Load diff
@@ -1,224 +0,0 @@
|
||||
#!/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
@@ -1,997 +0,0 @@
|
||||
basePath: /
|
||||
definitions:
|
||||
backend.SongInfo:
|
||||
properties:
|
||||
CurrentlyPlaying:
|
||||
type: boolean
|
||||
Song:
|
||||
type: string
|
||||
SongNo:
|
||||
type: integer
|
||||
SongPlayed:
|
||||
type: integer
|
||||
Soundtrack:
|
||||
type: string
|
||||
SoundtrackPlayed:
|
||||
type: integer
|
||||
type: object
|
||||
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.SyncResponse:
|
||||
properties:
|
||||
catched_errors:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
soundtracks_added:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
soundtracks_changed_content:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
soundtracks_changed_title:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
soundtracks_re_added:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
soundtracks_removed:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
total_time:
|
||||
type: string
|
||||
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: API password, required when API_PASSWORD is set
|
||||
type: string
|
||||
type: object
|
||||
server.TokenResponse:
|
||||
properties:
|
||||
client_type:
|
||||
type: string
|
||||
expires_at:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact:
|
||||
email: zarnor91@gmail.com
|
||||
name: Sebastian Olsson
|
||||
description: API for the MusicServer application
|
||||
license:
|
||||
name: MIT
|
||||
url: http://opensource.org/licenses/MIT
|
||||
termsOfService: http://sanplex.xyz/terms/
|
||||
title: MusicServer API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/api/v1/statistics/songs/least-played:
|
||||
get:
|
||||
description: Returns the top N least played songs with their soundtrack info
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
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:
|
||||
description: Returns the top N most played songs with their soundtrack info
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
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:
|
||||
description: Returns the most recently played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
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:
|
||||
description: Returns the top N least played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
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:
|
||||
description: Returns the top N most played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
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:
|
||||
description: Returns all soundtracks that have never been played
|
||||
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:
|
||||
description: Returns the least recently played soundtracks with their songs
|
||||
parameters:
|
||||
- description: 'Number of results (default: 10, max: 100)'
|
||||
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:
|
||||
description: Returns overall play statistics
|
||||
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:
|
||||
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 the API password
|
||||
when one is configured.
|
||||
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
|
||||
"429":
|
||||
description: Too Many Requests
|
||||
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:
|
||||
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:
|
||||
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: Cleanup expired sessions
|
||||
tags:
|
||||
- auth
|
||||
/character:
|
||||
get:
|
||||
description: Returns the image for a specific character
|
||||
parameters:
|
||||
- description: Character file name
|
||||
in: query
|
||||
name: name
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- image/png
|
||||
- image/jpeg
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
"400":
|
||||
description: invalid name
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: not found
|
||||
schema:
|
||||
type: string
|
||||
summary: Get character image
|
||||
tags:
|
||||
- characters
|
||||
/characters:
|
||||
get:
|
||||
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:
|
||||
description: Returns the latest version of the client application
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: string
|
||||
"502":
|
||||
description: upstream service unavailable
|
||||
schema:
|
||||
type: string
|
||||
summary: Check for latest version
|
||||
tags:
|
||||
- download
|
||||
/download/linux:
|
||||
get:
|
||||
description: Redirects to download the latest Linux version
|
||||
responses:
|
||||
"302":
|
||||
description: Found
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: not found
|
||||
schema:
|
||||
type: string
|
||||
"502":
|
||||
description: upstream service unavailable
|
||||
schema:
|
||||
type: string
|
||||
summary: Download latest Linux version
|
||||
tags:
|
||||
- download
|
||||
/download/list:
|
||||
get:
|
||||
description: Lists all assets available for the latest version
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
"502":
|
||||
description: upstream service unavailable
|
||||
schema:
|
||||
type: string
|
||||
summary: List assets of latest version
|
||||
tags:
|
||||
- download
|
||||
/download/windows:
|
||||
get:
|
||||
description: Redirects to download the latest Windows version
|
||||
responses:
|
||||
"302":
|
||||
description: Found
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: not found
|
||||
schema:
|
||||
type: string
|
||||
"502":
|
||||
description: upstream service unavailable
|
||||
schema:
|
||||
type: string
|
||||
summary: Download latest Windows version
|
||||
tags:
|
||||
- download
|
||||
/health:
|
||||
get:
|
||||
description: Returns the health status of the server
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Check server health
|
||||
tags:
|
||||
- health
|
||||
/music:
|
||||
get:
|
||||
description: Returns the song at the given position in the queue
|
||||
parameters:
|
||||
- description: Song number in the queue
|
||||
in: query
|
||||
name: song
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- audio/mpeg
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
"206":
|
||||
description: Partial Content
|
||||
schema:
|
||||
type: file
|
||||
"400":
|
||||
description: song must be a number
|
||||
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:
|
||||
description: Marks the current song as played
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
type: string
|
||||
summary: Add latest to played
|
||||
tags:
|
||||
- music
|
||||
/music/addQue:
|
||||
get:
|
||||
description: Adds the latest song to the queue
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
type: string
|
||||
summary: Add latest to queue
|
||||
tags:
|
||||
- music
|
||||
/music/all/order:
|
||||
get:
|
||||
description: Returns a list of all soundtracks in order
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
type: string
|
||||
summary: Get all soundtracks
|
||||
tags:
|
||||
- music
|
||||
/music/all/random:
|
||||
get:
|
||||
description: Returns a list of all soundtracks in random order
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
"423":
|
||||
description: Syncing is in progress
|
||||
schema:
|
||||
type: string
|
||||
summary: Get all soundtracks random
|
||||
tags:
|
||||
- music
|
||||
/music/info:
|
||||
get:
|
||||
description: Returns information about the current song
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/backend.SongInfo'
|
||||
summary: Get current song info
|
||||
tags:
|
||||
- music
|
||||
/music/list:
|
||||
get:
|
||||
description: Returns a list of played songs
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.SongInfo'
|
||||
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:
|
||||
description: Marks the song at the given queue position as played
|
||||
parameters:
|
||||
- description: Song number in the queue
|
||||
in: query
|
||||
name: song
|
||||
required: true
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
"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 where every song has the same chance
|
||||
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, favouring soundtracks that have been played
|
||||
less
|
||||
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:
|
||||
description: Resets the music state
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
"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:
|
||||
post:
|
||||
description: Starts syncing soundtracks that have changed
|
||||
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:
|
||||
post:
|
||||
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:
|
||||
description: Returns the progress of the running sync, or the result of the
|
||||
last sync
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/backend.SyncResponse'
|
||||
summary: Get sync progress
|
||||
tags:
|
||||
- sync
|
||||
/sync/reset:
|
||||
post:
|
||||
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:
|
||||
description: get latest version info
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/backend.VersionData'
|
||||
summary: Getting the latest version of the backend
|
||||
tags:
|
||||
- version
|
||||
/version/history:
|
||||
get:
|
||||
description: get version history
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.VersionData'
|
||||
type: array
|
||||
summary: Getting the version history of the backend
|
||||
tags:
|
||||
- version
|
||||
swagger: "2.0"
|
||||
@@ -0,0 +1,51 @@
|
||||
// Command openapi writes the MusicServer API description without starting the server.
|
||||
//
|
||||
// go run ./cmd/openapi -out .
|
||||
//
|
||||
// It writes openapi.json (OpenAPI 3.1) and openapi-3.0.json (OpenAPI 3.0, for tools
|
||||
// that don't support 3.1 yet). A running server serves the same files at /openapi.json
|
||||
// and /openapi-3.0.json.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"music-server/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", ".", "directory to write openapi.json and openapi-3.0.json to")
|
||||
flag.Parse()
|
||||
|
||||
if err := run(*out); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "openapi:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(out string) error {
|
||||
spec := server.OpenAPISpec()
|
||||
v31, err := spec.MarshalJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v30, err := spec.Downgrade()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, data := range map[string][]byte{"openapi.json": v31, "openapi-3.0.json": v30} {
|
||||
path := filepath.Join(out, name)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Wrote", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunWritesBothVersions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, run(dir))
|
||||
|
||||
for name, version := range map[string]string{"openapi.json": "3.1.0", "openapi-3.0.json": "3.0.3"} {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
require.NoError(t, err)
|
||||
var spec struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths map[string]any `json:"paths"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(data, &spec), name)
|
||||
assert.Equal(t, version, spec.OpenAPI, name)
|
||||
assert.Contains(t, spec.Paths, "/api/v1/music/info", name)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,12 @@ main {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.matched-name {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
#search_term {
|
||||
|
||||
+27
-9
@@ -33,15 +33,33 @@ func NewSearchHandler(cache *backend.SearchCache) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// SearchResult is a soundtrack found by search. MatchedName is the alternative name that
|
||||
// matched, or empty when the soundtrack's own name matched.
|
||||
type SearchResult struct {
|
||||
Name string
|
||||
MatchedName string
|
||||
}
|
||||
|
||||
// search returns the soundtracks matching searchText: exact matches first, then matches
|
||||
// ignoring spaces and special characters, then matches with the letters in order.
|
||||
func search(searchText string, soundtrackData []backend.SoundtrackData) []string {
|
||||
var results []string
|
||||
// ignoring spaces and special characters, then matches with the letters in order. Each
|
||||
// step tries the soundtrack's own name before its alternative names.
|
||||
func search(searchText string, soundtrackData []backend.SoundtrackData) []SearchResult {
|
||||
var results []SearchResult
|
||||
seen := make(map[string]bool)
|
||||
add := func(match func(backend.SoundtrackData) bool) {
|
||||
add := func(match func(backend.SearchName) bool) {
|
||||
for _, data := range soundtrackData {
|
||||
if !seen[data.Original] && match(data) {
|
||||
results = append(results, data.Original)
|
||||
if seen[data.Original] {
|
||||
continue
|
||||
}
|
||||
matched, ok := "", match(data.SearchName)
|
||||
for _, alternative := range data.Alternatives {
|
||||
if ok {
|
||||
break
|
||||
}
|
||||
matched, ok = alternative.Original, match(alternative)
|
||||
}
|
||||
if ok {
|
||||
results = append(results, SearchResult{Name: data.Original, MatchedName: matched})
|
||||
seen[data.Original] = true
|
||||
}
|
||||
}
|
||||
@@ -50,8 +68,8 @@ func search(searchText string, soundtrackData []backend.SoundtrackData) []string
|
||||
lowercase := strings.ToLower(searchText)
|
||||
cleaned := backend.CleanSearchTerm(searchText)
|
||||
|
||||
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Lowercase, lowercase) })
|
||||
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Cleaned, cleaned) })
|
||||
add(func(d backend.SoundtrackData) bool { return backend.MatchesSubsequence(cleaned, d.Cleaned) })
|
||||
add(func(n backend.SearchName) bool { return strings.Contains(n.Lowercase, lowercase) })
|
||||
add(func(n backend.SearchName) bool { return strings.Contains(n.Cleaned, cleaned) })
|
||||
add(func(n backend.SearchName) bool { return backend.MatchesSubsequence(cleaned, n.Cleaned) })
|
||||
return results
|
||||
}
|
||||
@@ -3,14 +3,12 @@ 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"/>
|
||||
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="load, 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)
|
||||
@@ -35,17 +33,20 @@ templ SearchForm() {
|
||||
|
||||
document.getElementById("clear").addEventListener("click", function (event) {
|
||||
document.getElementById("search_term").value = "";
|
||||
htmlx.ajax('POST', '/find', '#soundtracks-container');
|
||||
htmx.ajax('POST', '/find', '#soundtracks-container');
|
||||
document.getElementById("search_term").focus();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
templ FoundSoundtracks(soundtracks []string) {
|
||||
templ FoundSoundtracks(soundtracks []SearchResult) {
|
||||
for _, soundtrack := range soundtracks {
|
||||
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
||||
<p class="soundtrack-text">{ soundtrack }</p>
|
||||
<p class="soundtrack-text">{ soundtrack.Name }</p>
|
||||
if soundtrack.MatchedName != "" {
|
||||
<p class="matched-name">Also known as { soundtrack.MatchedName }</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+49
-6
@@ -28,8 +28,24 @@ var soundtracks = []string{
|
||||
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
|
||||
}
|
||||
|
||||
func newCache(names []string) *backend.SearchCache {
|
||||
return backend.NewSearchCache(func(context.Context) ([]string, error) { return names, nil })
|
||||
func newCache(names []string, alternatives ...map[string][]string) *backend.SearchCache {
|
||||
soundtracks := make([]backend.SearchSoundtrack, len(names))
|
||||
for i, name := range names {
|
||||
soundtracks[i] = backend.SearchSoundtrack{Name: name}
|
||||
for _, a := range alternatives {
|
||||
soundtracks[i].AlternativeNames = a[name]
|
||||
}
|
||||
}
|
||||
return backend.NewSearchCache(func(context.Context) ([]backend.SearchSoundtrack, error) { return soundtracks, nil })
|
||||
}
|
||||
|
||||
// names returns the soundtrack names of results.
|
||||
func names(results []SearchResult) []string {
|
||||
var n []string
|
||||
for _, r := range results {
|
||||
n = append(n, r.Name)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func searchData(t *testing.T) []backend.SoundtrackData {
|
||||
@@ -62,23 +78,50 @@ func TestSearch(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.query, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, search(tt.query, data))
|
||||
assert.Equal(t, tt.want, names(search(tt.query, data)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEmptyReturnsEverything(t *testing.T) {
|
||||
assert.Equal(t, soundtracks, search("", searchData(t)))
|
||||
assert.Equal(t, soundtracks, names(search("", searchData(t))))
|
||||
}
|
||||
|
||||
func TestSearchOrdering(t *testing.T) {
|
||||
// "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order.
|
||||
results := search("mg", searchData(t))
|
||||
results := names(search("mg", searchData(t)))
|
||||
require.GreaterOrEqual(t, len(results), 2)
|
||||
assert.Equal(t, "MG", results[0])
|
||||
assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots")
|
||||
}
|
||||
|
||||
func TestSearchAlternativeNames(t *testing.T) {
|
||||
data, err := newCache(soundtracks, map[string][]string{
|
||||
"Final Fantasy VII": {"FF7", "ファイナルファンタジーVII"},
|
||||
"Kingdom Hearts": {"KH"},
|
||||
}).Get(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII", MatchedName: "FF7"}}, search("ff7", data))
|
||||
assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII", MatchedName: "ファイナルファンタジーVII"}}, search("ファイナル", data))
|
||||
assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII"}}, search("fantasy", data),
|
||||
"no matched name when the soundtrack's own name matches")
|
||||
// An exact match on an alternative name comes before a letters-in-order match on a name.
|
||||
results := search("kh", data)
|
||||
require.NotEmpty(t, results)
|
||||
assert.Equal(t, SearchResult{Name: "Kingdom Hearts", MatchedName: "KH"}, results[0])
|
||||
}
|
||||
|
||||
func TestFoundSoundtracksShowsMatchedName(t *testing.T) {
|
||||
var out strings.Builder
|
||||
require.NoError(t, FoundSoundtracks([]SearchResult{
|
||||
{Name: "Final Fantasy VII", MatchedName: "FF7"}, {Name: "Kingdom Hearts"},
|
||||
}).Render(context.Background(), &out))
|
||||
assert.Contains(t, out.String(), "Final Fantasy VII")
|
||||
assert.Contains(t, out.String(), "Also known as FF7")
|
||||
assert.Equal(t, 1, strings.Count(out.String(), "Also known as"))
|
||||
}
|
||||
|
||||
func TestSearchHandler(t *testing.T) {
|
||||
handler := NewSearchHandler(newCache(soundtracks))
|
||||
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode()))
|
||||
@@ -93,7 +136,7 @@ func TestSearchHandler(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchHandlerLoadError(t *testing.T) {
|
||||
cache := backend.NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") })
|
||||
cache := backend.NewSearchCache(func(context.Context) ([]backend.SearchSoundtrack, error) { return nil, errors.New("db down") })
|
||||
rec := httptest.NewRecorder()
|
||||
NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil))
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module music-server
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/MShekow/directory-checksum v1.4.18
|
||||
@@ -8,28 +8,30 @@ require (
|
||||
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/labstack/echo/v5 v5.3.0
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
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/stretchr/testify v1.12.1
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
go.uber.org/zap v1.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1
|
||||
github.com/getkin/kin-openapi v0.149.0
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/sync v0.20.0
|
||||
github.com/prometheus/client_golang v1.25.0
|
||||
github.com/prometheus/client_model v0.6.3
|
||||
golang.org/x/sync v0.23.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
@@ -38,8 +40,6 @@ require (
|
||||
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/cpuguy83/go-md2man/v2 v2.0.5 // 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
|
||||
@@ -50,25 +50,21 @@ require (
|
||||
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/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // 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/klauspost/compress v1.20.1 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.15.4 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // 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
|
||||
@@ -78,21 +74,22 @@ require (
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/natefinch/atomic v1.0.1 // indirect
|
||||
github.com/oasdiff/yaml v0.1.1 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.14 // 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/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/prometheus/common v0.72.0 // indirect
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // 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/urfave/cli/v2 v2.27.5 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // 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
|
||||
@@ -100,20 +97,15 @@ require (
|
||||
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.51.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/net v0.59.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.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
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/a-h/templ/cmd/templ
|
||||
github.com/swaggo/swag/cmd/swag
|
||||
)
|
||||
tool github.com/a-h/templ/cmd/templ
|
||||
@@ -4,8 +4,6 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af
|
||||
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=
|
||||
@@ -14,8 +12,10 @@ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ6
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
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/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
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=
|
||||
@@ -32,17 +32,17 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
||||
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/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g=
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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=
|
||||
@@ -57,6 +57,12 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
|
||||
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -66,33 +72,12 @@ 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-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/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
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.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
@@ -102,6 +87,8 @@ 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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -112,25 +99,30 @@ 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/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
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/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I=
|
||||
github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
|
||||
github.com/labstack/echo/v5 v5.3.0 h1:KT74Mprk053PQEHwSZdeCDIz1BigTZOZhavMD0c9Fjs=
|
||||
github.com/labstack/echo/v5 v5.3.0/go.mod h1:Q3j2+clBRgJr0O3DDONQeXNsM7RHgSwUhcuo47unqm8=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
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/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
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/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
@@ -151,8 +143,14 @@ 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||
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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -160,14 +158,20 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
|
||||
github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
|
||||
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
|
||||
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||
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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
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=
|
||||
@@ -179,28 +183,22 @@ 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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
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/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
|
||||
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
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=
|
||||
@@ -225,41 +223,37 @@ 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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
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=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
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/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/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=
|
||||
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=
|
||||
@@ -28,7 +28,7 @@ func (c *CharacterService) List() ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var characters []string
|
||||
characters := []string{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && isImage(entry.Name()) {
|
||||
characters = append(characters, entry.Name())
|
||||
@@ -37,6 +37,33 @@ func (c *CharacterService) List() ([]string, error) {
|
||||
return characters, nil
|
||||
}
|
||||
|
||||
// CharacterDetails is a character image with the time its file was last changed, so a
|
||||
// client that caches images knows when to download one again.
|
||||
type CharacterDetails struct {
|
||||
Name string `json:"name" doc:"File name of the character image"`
|
||||
Modified int64 `json:"modified" doc:"When the image was last changed, in Unix seconds"`
|
||||
}
|
||||
|
||||
// ListDetails returns all character images with the time each was last changed.
|
||||
func (c *CharacterService) ListDetails() ([]CharacterDetails, error) {
|
||||
entries, err := fs.ReadDir(c.root.FS(), ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
characters := []CharacterDetails{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !isImage(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
characters = append(characters, CharacterDetails{Name: entry.Name(), Modified: info.ModTime().Unix()})
|
||||
}
|
||||
return characters, nil
|
||||
}
|
||||
|
||||
// Open opens the character image with the given file name.
|
||||
func (c *CharacterService) Open(name string) (*os.File, error) {
|
||||
if name == "" || name != filepath.Base(name) || !isImage(name) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -31,6 +32,32 @@ func TestCharacterList(t *testing.T) {
|
||||
assert.Equal(t, []string{"Adol.png", "Aloy.JPG"}, characters)
|
||||
}
|
||||
|
||||
func TestCharacterListDetails(t *testing.T) {
|
||||
c := newCharacterService(t)
|
||||
changed := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, c.root.Chtimes("Adol.png", changed, changed))
|
||||
|
||||
characters, err := c.ListDetails()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, characters, 2)
|
||||
assert.Equal(t, CharacterDetails{Name: "Adol.png", Modified: changed.Unix()}, characters[0])
|
||||
assert.Equal(t, "Aloy.JPG", characters[1].Name)
|
||||
}
|
||||
|
||||
func TestCharacterListDetailsSeesChangedImage(t *testing.T) {
|
||||
c := newCharacterService(t)
|
||||
before, err := c.ListDetails()
|
||||
require.NoError(t, err)
|
||||
|
||||
later := time.Unix(before[0].Modified, 0).Add(time.Hour)
|
||||
require.NoError(t, c.root.Chtimes("Adol.png", later, later))
|
||||
|
||||
after, err := c.ListDetails()
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, after[0].Modified, before[0].Modified)
|
||||
assert.Equal(t, before[1], after[1], "unchanged images keep their time")
|
||||
}
|
||||
|
||||
func TestCharacterOpen(t *testing.T) {
|
||||
c := newCharacterService(t)
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// coverMatch is a cover found by a source.
|
||||
type coverMatch struct {
|
||||
ExternalID string
|
||||
ImageURL string
|
||||
}
|
||||
|
||||
// coverSource finds cover images in an external database.
|
||||
type coverSource interface {
|
||||
// Name is stored as the cover's source, e.g. "steamgriddb".
|
||||
Name() string
|
||||
// Search returns the cover for the first of names that the source knows, or nil when
|
||||
// none matched. An error means the source could not be asked.
|
||||
Search(ctx context.Context, names []string) (*coverMatch, error)
|
||||
// ByID returns the cover of the entry with the given ID, or ErrNotFound.
|
||||
ByID(ctx context.Context, id string) (*coverMatch, error)
|
||||
}
|
||||
|
||||
// getJSON sends req and decodes a JSON response into v. A 404 gives ErrNotFound, other
|
||||
// failures ErrUpstream.
|
||||
func getJSON(client *http.Client, req *http.Request, v any) error {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %s answered %s", ErrUpstream, req.URL.Host, resp.Status)
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(v); err != nil {
|
||||
return fmt.Errorf("%w: invalid response from %s: %v", ErrUpstream, req.URL.Host, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// steamGridDB finds game box art ("grids") on SteamGridDB. It needs an API key.
|
||||
type steamGridDB struct {
|
||||
baseURL string
|
||||
key string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
const steamGridDBURL = "https://www.steamgriddb.com/api/v2"
|
||||
|
||||
func (s *steamGridDB) Name() string { return "steamgriddb" }
|
||||
|
||||
func (s *steamGridDB) get(ctx context.Context, path string, query url.Values, v any) error {
|
||||
u := s.baseURL + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.key)
|
||||
var wrapper struct {
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := getJSON(s.client, req, &wrapper); err != nil {
|
||||
return err
|
||||
}
|
||||
if !wrapper.Success {
|
||||
return fmt.Errorf("%w: SteamGridDB request failed", ErrUpstream)
|
||||
}
|
||||
return json.Unmarshal(wrapper.Data, v)
|
||||
}
|
||||
|
||||
type steamGridDBGame struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *steamGridDB) Search(ctx context.Context, names []string) (*coverMatch, error) {
|
||||
for _, name := range names {
|
||||
var games []steamGridDBGame
|
||||
if err := s.get(ctx, "/search/autocomplete/"+url.PathEscape(name), nil, &games); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(games) == 0 {
|
||||
continue
|
||||
}
|
||||
match, err := s.ByID(ctx, strconv.Itoa(bestGame(name, games).ID))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue // the game has no box art
|
||||
}
|
||||
return match, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *steamGridDB) ByID(ctx context.Context, id string) (*coverMatch, error) {
|
||||
if _, err := strconv.Atoi(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
query := url.Values{
|
||||
"dimensions": {"600x900"}, // portrait box art
|
||||
"types": {"static"},
|
||||
"mimes": {"image/png,image/jpeg"},
|
||||
"nsfw": {"false"},
|
||||
"humor": {"false"},
|
||||
}
|
||||
var grids []struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := s.get(ctx, "/grids/game/"+id, query, &grids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(grids) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &coverMatch{ExternalID: id, ImageURL: grids[0].URL}, nil
|
||||
}
|
||||
|
||||
// bestGame picks the search result for name:
|
||||
// 1. a result with exactly the words of name, in any order ("Lemmings 3D" → "3D Lemmings");
|
||||
// 2. else a result that ends with the words of name, as words added in front are usually a
|
||||
// publisher or series ("Civilization IV" → "Sid Meier's Civilization IV");
|
||||
// 3. else SteamGridDB's own first result. Words added after the name can be a subtitle, a
|
||||
// sequel or an expansion ("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), which only
|
||||
// SteamGridDB's ranking can tell apart.
|
||||
func bestGame(name string, games []steamGridDBGame) steamGridDBGame {
|
||||
want := titleWords(name)
|
||||
for _, game := range games {
|
||||
if sameWords(titleWords(game.Name), want) {
|
||||
return game
|
||||
}
|
||||
}
|
||||
for _, game := range games {
|
||||
if have := titleWords(game.Name); len(have) > len(want) && slices.Equal(have[len(have)-len(want):], want) {
|
||||
return game
|
||||
}
|
||||
}
|
||||
return games[0]
|
||||
}
|
||||
|
||||
// sameWords reports whether a and b have the same words, in any order.
|
||||
func sameWords(a, b []string) bool {
|
||||
return len(a) == len(b) && slices.Equal(slices.Sorted(slices.Values(a)), slices.Sorted(slices.Values(b)))
|
||||
}
|
||||
|
||||
// romanNumerals are read as numbers, so "Octopath Traveler 2" matches "Octopath Traveler II".
|
||||
var romanNumerals = map[string]string{
|
||||
"i": "1", "ii": "2", "iii": "3", "iv": "4", "v": "5", "vi": "6", "vii": "7", "viii": "8", "ix": "9", "x": "10",
|
||||
}
|
||||
|
||||
// titleWords returns the words of a title in lower case, without punctuation.
|
||||
func titleWords(title string) []string {
|
||||
words := strings.FieldsFunc(strings.ToLower(title), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
for i, word := range words {
|
||||
if number, ok := romanNumerals[word]; ok {
|
||||
words[i] = number
|
||||
}
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// aniList finds anime covers on AniList. It needs no key. Its search is loose, so a
|
||||
// result is only used when one of its titles is the searched name.
|
||||
type aniList struct {
|
||||
url string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
const aniListURL = "https://graphql.anilist.co"
|
||||
|
||||
const aniListFields = `id title { romaji english native } coverImage { extraLarge large }`
|
||||
|
||||
type aniListMedia struct {
|
||||
ID int `json:"id"`
|
||||
Title struct {
|
||||
Romaji string `json:"romaji"`
|
||||
English string `json:"english"`
|
||||
Native string `json:"native"`
|
||||
} `json:"title"`
|
||||
CoverImage struct {
|
||||
ExtraLarge string `json:"extraLarge"`
|
||||
Large string `json:"large"`
|
||||
} `json:"coverImage"`
|
||||
}
|
||||
|
||||
func (m aniListMedia) match() *coverMatch {
|
||||
image := m.CoverImage.ExtraLarge
|
||||
if image == "" {
|
||||
image = m.CoverImage.Large
|
||||
}
|
||||
if image == "" {
|
||||
return nil
|
||||
}
|
||||
return &coverMatch{ExternalID: strconv.Itoa(m.ID), ImageURL: image}
|
||||
}
|
||||
|
||||
func (a *aniList) Name() string { return "anilist" }
|
||||
|
||||
func (a *aniList) query(ctx context.Context, query string, variables map[string]any) (*aniListMedia, error) {
|
||||
body, err := json.Marshal(map[string]any{"query": query, "variables": variables})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Media *aniListMedia `json:"Media"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := getJSON(a.client, req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Data.Media == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return resp.Data.Media, nil
|
||||
}
|
||||
|
||||
func (a *aniList) Search(ctx context.Context, names []string) (*coverMatch, error) {
|
||||
for _, name := range names {
|
||||
media, err := a.query(ctx, `query ($search: String) { Media(search: $search, type: ANIME) { `+aniListFields+` } }`,
|
||||
map[string]any{"search": name})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := normalizeTitle(name)
|
||||
for _, title := range []string{media.Title.Romaji, media.Title.English, media.Title.Native} {
|
||||
if title != "" && normalizeTitle(title) == want {
|
||||
if match := media.match(); match != nil {
|
||||
return match, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *aniList) ByID(ctx context.Context, id string) (*coverMatch, error) {
|
||||
n, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
media, err := a.query(ctx, `query ($id: Int) { Media(id: $id) { `+aniListFields+` } }`, map[string]any{"id": n})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match := media.match(); match != nil {
|
||||
return match, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// normalizeTitle keeps only the letters and digits of a title, in lower case, so
|
||||
// "Cowboy Bebop: The Movie" and "cowboy bebop the movie" compare equal.
|
||||
func normalizeTitle(title string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(title) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxCoverBytes is the largest cover image that is stored.
|
||||
MaxCoverBytes = 10 << 20
|
||||
// coverRecheckAfter is how long a soundtrack without a match waits before the job
|
||||
// searches for it again. Changing its alternative names makes it search right away.
|
||||
coverRecheckAfter = 30 * 24 * time.Hour
|
||||
// coverRequestInterval spaces out requests to the external APIs, to stay well inside
|
||||
// their rate limits.
|
||||
coverRequestInterval = time.Second
|
||||
)
|
||||
|
||||
// SourceUpload is the source of a cover that was uploaded.
|
||||
const SourceUpload = "upload"
|
||||
|
||||
// coverTypes are the image types stored as covers, by MIME type, with their file extension.
|
||||
var coverTypes = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
// CoverService stores cover images for soundtracks in a folder and finds them in external
|
||||
// databases in the background.
|
||||
type CoverService struct {
|
||||
q repository.Querier
|
||||
dir string
|
||||
sources []coverSource
|
||||
client *http.Client
|
||||
// interval spaces out external requests; tests set it to 0.
|
||||
interval time.Duration
|
||||
|
||||
trigger chan struct{}
|
||||
// writeMu keeps the job from replacing a cover that is being set by hand.
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
// CoverOptions configures where covers are searched.
|
||||
type CoverOptions struct {
|
||||
// SteamGridDBKey turns on SteamGridDB and the background job.
|
||||
SteamGridDBKey string
|
||||
// SteamGridDBURL and AniListURL replace the real APIs, for tests.
|
||||
SteamGridDBURL string
|
||||
AniListURL string
|
||||
}
|
||||
|
||||
// NewCoverService stores covers in dir. With a SteamGridDB API key the background job
|
||||
// searches SteamGridDB, then AniList; without one there is no background job, but covers
|
||||
// can still be uploaded or picked by ID.
|
||||
func NewCoverService(q repository.Querier, dir string, opts CoverOptions) (*CoverService, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
c := &CoverService{
|
||||
q: q,
|
||||
dir: dir,
|
||||
client: client,
|
||||
interval: coverRequestInterval,
|
||||
trigger: make(chan struct{}, 1),
|
||||
}
|
||||
if opts.SteamGridDBKey != "" {
|
||||
c.sources = append(c.sources, &steamGridDB{
|
||||
baseURL: cmp.Or(opts.SteamGridDBURL, steamGridDBURL), key: opts.SteamGridDBKey, client: client,
|
||||
})
|
||||
}
|
||||
c.sources = append(c.sources, &aniList{url: cmp.Or(opts.AniListURL, aniListURL), client: client})
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SearchesAutomatically reports whether the background job runs.
|
||||
func (c *CoverService) SearchesAutomatically() bool {
|
||||
return c.source("steamgriddb") != nil
|
||||
}
|
||||
|
||||
func (c *CoverService) source(name string) coverSource {
|
||||
for _, s := range c.sources {
|
||||
if s.Name() == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the cover image of an active soundtrack, or ErrNotFound.
|
||||
func (c *CoverService) Path(ctx context.Context, id uuid.UUID) (string, error) {
|
||||
st, err := c.q.GetSoundtrackByUUID(ctx, id)
|
||||
if err != nil {
|
||||
return "", notFound(err)
|
||||
}
|
||||
if st.CoverFile == nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return filepath.Join(c.dir, *st.CoverFile), nil
|
||||
}
|
||||
|
||||
// Upload stores data as the soundtrack's cover. It is never replaced by the background job.
|
||||
func (c *CoverService) Upload(ctx context.Context, id uuid.UUID, data []byte) error {
|
||||
if _, err := c.activeSoundtrack(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
return c.store(ctx, id, data, SourceUpload, nil, true)
|
||||
}
|
||||
|
||||
// FromSource stores the cover of entry externalID in source (steamgriddb or anilist) as
|
||||
// the soundtrack's cover. It is never replaced by the background job.
|
||||
func (c *CoverService) FromSource(ctx context.Context, id uuid.UUID, source, externalID string) error {
|
||||
s := c.source(source)
|
||||
if s == nil {
|
||||
return fmt.Errorf("%w: unknown or disabled cover source %q", ErrInvalidName, source)
|
||||
}
|
||||
if _, err := c.activeSoundtrack(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
match, err := s.ByID(ctx, externalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.download(ctx, match.ImageURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
return c.store(ctx, id, data, source, &match.ExternalID, true)
|
||||
}
|
||||
|
||||
// Remove deletes the soundtrack's cover, so the background job searches for a new one.
|
||||
func (c *CoverService) Remove(ctx context.Context, id uuid.UUID) error {
|
||||
st, err := c.activeSoundtrack(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if err := c.q.ClearCover(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if st.CoverFile != nil {
|
||||
c.removeFile(*st.CoverFile)
|
||||
}
|
||||
c.Trigger()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CoverService) activeSoundtrack(ctx context.Context, id uuid.UUID) (repository.Soundtrack, error) {
|
||||
st, err := c.q.GetSoundtrackByUUID(ctx, id)
|
||||
return st, notFound(err)
|
||||
}
|
||||
|
||||
// store writes data as the soundtrack's cover file and records it. Call with writeMu held.
|
||||
func (c *CoverService) store(ctx context.Context, id uuid.UUID, data []byte, source string, externalID *string, manual bool) error {
|
||||
ext, err := coverExtension(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := id.String() + ext
|
||||
tmp, err := os.CreateTemp(c.dir, ".upload-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), filepath.Join(c.dir, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
// A cover of another image type leaves a file with another extension behind.
|
||||
for _, other := range coverTypes {
|
||||
if other != ext {
|
||||
c.removeFile(id.String() + other)
|
||||
}
|
||||
}
|
||||
return c.q.SetCover(ctx, repository.SetCoverParams{
|
||||
Uuid: id, File: &name, Source: &source, ExternalID: externalID, Manual: manual,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CoverService) removeFile(name string) {
|
||||
if err := os.Remove(filepath.Join(c.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
logging.GetLogger().Warn("Failed to remove cover", zap.String("file", name), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// coverExtension checks that data is a supported image and returns its file extension.
|
||||
func coverExtension(data []byte) (string, error) {
|
||||
if len(data) > MaxCoverBytes {
|
||||
return "", fmt.Errorf("%w: the image is larger than %d MB", ErrInvalidImage, MaxCoverBytes>>20)
|
||||
}
|
||||
ext, ok := coverTypes[http.DetectContentType(data)]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: only JPEG, PNG and WebP images are supported", ErrInvalidImage)
|
||||
}
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// download fetches a cover image.
|
||||
func (c *CoverService) download(ctx context.Context, imageURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid image URL: %v", ErrUpstream, err)
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: image download answered %s", ErrUpstream, resp.Status)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, MaxCoverBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||
}
|
||||
if _, err := coverExtension(data); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Trigger asks the background job to look for missing covers. It never blocks.
|
||||
func (c *CoverService) Trigger() {
|
||||
select {
|
||||
case c.trigger <- struct{}{}:
|
||||
default: // a pass is already waiting
|
||||
}
|
||||
}
|
||||
|
||||
// Run searches for missing covers now and each time Trigger is called, until ctx ends.
|
||||
// It does nothing when automatic search is off.
|
||||
func (c *CoverService) Run(ctx context.Context) {
|
||||
if !c.SearchesAutomatically() {
|
||||
return
|
||||
}
|
||||
c.Trigger()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.trigger:
|
||||
if err := c.searchMissing(ctx); err != nil && ctx.Err() == nil {
|
||||
logging.GetLogger().Warn("Cover search stopped, trying again after the next sync", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// searchMissing finds covers for active soundtracks without one. It stops at the first
|
||||
// error from an external API, so an unreachable API is not asked again for every soundtrack.
|
||||
func (c *CoverService) searchMissing(ctx context.Context) error {
|
||||
c.removeOrphans(ctx)
|
||||
rows, err := c.q.ListSoundtracksNeedingCover(ctx,
|
||||
pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter), Valid: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger := logging.GetLogger()
|
||||
found := 0
|
||||
for _, row := range rows {
|
||||
match, source, err := c.search(ctx, coverSearchNames(row.SoundtrackName, row.AlternativeNames))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if match == nil {
|
||||
if err := c.q.MarkCoverChecked(ctx, row.Uuid); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := c.wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.download(ctx, match.ImageURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored, err := c.storeFound(ctx, row.Uuid, data, source, match.ExternalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored {
|
||||
found++
|
||||
logger.Info("Cover found", zap.String("soundtrack", row.SoundtrackName),
|
||||
zap.String("source", source), zap.String("external_id", match.ExternalID))
|
||||
}
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
logger.Info("Cover search finished", zap.Int("searched", len(rows)), zap.Int("found", found))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeFound stores a cover found by the job, unless one was set by hand meanwhile.
|
||||
func (c *CoverService) storeFound(ctx context.Context, id uuid.UUID, data []byte, source, externalID string) (bool, error) {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
st, err := c.q.GetSoundtrackByUUID(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil // removed meanwhile
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if st.CoverManual || st.CoverFile != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, c.store(ctx, id, data, source, &externalID, false)
|
||||
}
|
||||
|
||||
// search asks the sources in order and returns the first match.
|
||||
func (c *CoverService) search(ctx context.Context, names []string) (*coverMatch, string, error) {
|
||||
for _, s := range c.sources {
|
||||
if err := c.wait(ctx); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
match, err := s.Search(ctx, names)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%s: %w", s.Name(), err)
|
||||
}
|
||||
if match != nil {
|
||||
return match, s.Name(), nil
|
||||
}
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
func (c *CoverService) wait(ctx context.Context) error {
|
||||
if c.interval <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(c.interval):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// removeOrphans deletes cover files that no soundtrack uses any more, for example after
|
||||
// the library was reset.
|
||||
func (c *CoverService) removeOrphans(ctx context.Context) {
|
||||
files, err := c.q.ListCoverFiles(ctx)
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Failed to list cover files", zap.Error(err))
|
||||
return
|
||||
}
|
||||
used := make(map[string]bool, len(files))
|
||||
for _, f := range files {
|
||||
used[f] = true
|
||||
}
|
||||
entries, err := os.ReadDir(c.dir)
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Failed to read the covers folder", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && !used[entry.Name()] {
|
||||
c.removeFile(entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bracketed = regexp.MustCompile(`\s*[(\[][^)\]]*[)\]]`)
|
||||
|
||||
// coverSearchNames returns the names to search for: the soundtrack's name, its alternative
|
||||
// names, and the name without parts in brackets ("Lemmings (SNES + Mega Drive)" →
|
||||
// "Lemmings"), without duplicates.
|
||||
func coverSearchNames(name string, alternatives []string) []string {
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, n := range append(append([]string{name}, alternatives...), bracketed.ReplaceAllString(name, "")) {
|
||||
n = strings.TrimSpace(n)
|
||||
if n != "" && !seen[strings.ToLower(n)] {
|
||||
seen[strings.ToLower(n)] = true
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build integration
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/pgtest"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAlternativeNamesAndCoversWithPostgres checks the alternative name and cover queries
|
||||
// against a real database.
|
||||
func TestAlternativeNamesAndCoversWithPostgres(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
|
||||
s := NewSyncService(q, copyTestMusic(t), nil)
|
||||
require.True(t, s.Start(false))
|
||||
s.Wait()
|
||||
|
||||
library := NewLibraryService(q)
|
||||
soundtracks, err := library.Soundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(soundtracks), 2)
|
||||
first, second := soundtracks[0], soundtracks[1]
|
||||
assert.Equal(t, []string{}, first.AlternativeNames)
|
||||
assert.Nil(t, first.CoverModified)
|
||||
|
||||
// Alternative names, in every soundtrack response.
|
||||
updated, err := library.SetAlternativeNames(ctx, first.Uuid, []string{" Alt ", "alt", first.Name, "Другое"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Alt", "Другое"}, updated.AlternativeNames)
|
||||
stats, err := NewStatisticsService(q).AllSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Alt", "Другое"}, stats[0].AlternativeNames)
|
||||
never, err := NewStatisticsService(q).NeverPlayedSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Alt", "Другое"}, never[0].AlternativeNames)
|
||||
search, err := NewMusicService(q, t.TempDir()).SearchSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Alt", "Другое"}, search[0].AlternativeNames)
|
||||
|
||||
// Covers.
|
||||
c, err := NewCoverService(q, filepath.Join(t.TempDir(), "covers"), CoverOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.Upload(ctx, first.Uuid, pngBytes(t)))
|
||||
withCover, err := library.Soundtrack(ctx, first.Uuid)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, withCover.CoverModified)
|
||||
stats, err = NewStatisticsService(q).AllSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, withCover.CoverModified, stats[0].CoverModified)
|
||||
|
||||
needing, err := q.ListSoundtracksNeedingCover(ctx, nowPlus(0))
|
||||
require.NoError(t, err)
|
||||
for _, row := range needing {
|
||||
assert.NotEqual(t, first.Uuid, row.Uuid, "a soundtrack with a cover is not searched")
|
||||
}
|
||||
require.NoError(t, q.MarkCoverChecked(ctx, second.Uuid))
|
||||
needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60))
|
||||
require.NoError(t, err)
|
||||
for _, row := range needing {
|
||||
assert.NotEqual(t, second.Uuid, row.Uuid, "searched recently")
|
||||
}
|
||||
|
||||
// New names make a soundtrack without a cover searched again.
|
||||
_, err = library.SetAlternativeNames(ctx, second.Uuid, []string{"Other"})
|
||||
require.NoError(t, err)
|
||||
needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60))
|
||||
require.NoError(t, err)
|
||||
var found bool
|
||||
for _, row := range needing {
|
||||
if row.Uuid == second.Uuid {
|
||||
found = true
|
||||
assert.Equal(t, []string{"Other"}, row.AlternativeNames)
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
files, err := q.ListCoverFiles(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{first.Uuid.String() + ".png"}, files)
|
||||
require.NoError(t, c.Remove(ctx, first.Uuid))
|
||||
files, err = q.ListCoverFiles(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, files)
|
||||
}
|
||||
|
||||
func nowPlus(seconds int) pgtype.Timestamptz {
|
||||
return pgtype.Timestamptz{Time: time.Now().Add(time.Duration(seconds) * time.Second), Valid: true}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func pngBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// coverAPIs fakes SteamGridDB, AniList and the image host.
|
||||
type coverAPIs struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
// games maps search terms to SteamGridDB game IDs; 404 has no grids.
|
||||
games map[string]int
|
||||
// anime maps search terms to AniList titles; the ID is always 42.
|
||||
anime map[string]string
|
||||
// failSteamGridDB makes SteamGridDB answer 500.
|
||||
failSteamGridDB bool
|
||||
|
||||
mu sync.Mutex
|
||||
searches []string
|
||||
}
|
||||
|
||||
func newCoverAPIs(t *testing.T) *coverAPIs {
|
||||
a := &coverAPIs{t: t, games: map[string]int{}, anime: map[string]string{}}
|
||||
image := pngBytes(t)
|
||||
mux := http.NewServeMux()
|
||||
reply := func(w http.ResponseWriter, data any) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data})
|
||||
}
|
||||
mux.HandleFunc("GET /sgdb/search/autocomplete/{term}", func(w http.ResponseWriter, r *http.Request) {
|
||||
a.record("sgdb:" + r.PathValue("term"))
|
||||
if a.failSteamGridDB {
|
||||
http.Error(w, "down", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, "Bearer key", r.Header.Get("Authorization"))
|
||||
if id, ok := a.games[r.PathValue("term")]; ok {
|
||||
reply(w, []map[string]any{{"id": id, "name": r.PathValue("term")}})
|
||||
return
|
||||
}
|
||||
reply(w, []any{})
|
||||
})
|
||||
mux.HandleFunc("GET /sgdb/grids/game/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "600x900", r.URL.Query().Get("dimensions"))
|
||||
if r.PathValue("id") == "404" {
|
||||
reply(w, []any{})
|
||||
return
|
||||
}
|
||||
reply(w, []map[string]any{{"id": 1, "url": a.server.URL + "/img/grid-" + r.PathValue("id") + ".png"}})
|
||||
})
|
||||
mux.HandleFunc("POST /anilist", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Variables struct {
|
||||
Search string `json:"search"`
|
||||
ID int `json:"id"`
|
||||
} `json:"variables"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
a.record("anilist:" + req.Variables.Search)
|
||||
title, ok := a.anime[req.Variables.Search]
|
||||
if req.Variables.ID == 42 {
|
||||
title, ok = "By ID", true
|
||||
}
|
||||
if !ok {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": nil}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": map[string]any{
|
||||
"id": 42, "title": map[string]any{"romaji": title},
|
||||
"coverImage": map[string]any{"extraLarge": a.server.URL + "/img/anime.png"},
|
||||
}}})
|
||||
})
|
||||
mux.HandleFunc("GET /img/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PathValue("name") == "text.png" {
|
||||
_, _ = w.Write([]byte("not an image"))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(image)
|
||||
})
|
||||
a.server = httptest.NewServer(mux)
|
||||
t.Cleanup(a.server.Close)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *coverAPIs) record(search string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.searches = append(a.searches, search)
|
||||
}
|
||||
|
||||
func (a *coverAPIs) searched() []string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return append([]string(nil), a.searches...)
|
||||
}
|
||||
|
||||
func (a *coverAPIs) options(withKey bool) CoverOptions {
|
||||
opts := CoverOptions{SteamGridDBURL: a.server.URL + "/sgdb", AniListURL: a.server.URL + "/anilist"}
|
||||
if withKey {
|
||||
opts.SteamGridDBKey = "key"
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func newCovers(t *testing.T, store *fakedb.Store, opts CoverOptions) *CoverService {
|
||||
t.Helper()
|
||||
c, err := NewCoverService(store, filepath.Join(t.TempDir(), "covers"), opts)
|
||||
require.NoError(t, err)
|
||||
c.interval = 0
|
||||
return c
|
||||
}
|
||||
|
||||
func coverOf(t *testing.T, store *fakedb.Store, name string) repository.Soundtrack {
|
||||
t.Helper()
|
||||
st, ok := store.Soundtrack(name)
|
||||
require.True(t, ok)
|
||||
return st
|
||||
}
|
||||
|
||||
func TestCoverSearchFindsCovers(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.games["Chrono Trigger"] = 1
|
||||
apis.games["FF7"] = 7
|
||||
apis.games["Lemmings"] = 3
|
||||
apis.anime["Cowboy Bebop"] = "Cowboy Bebop"
|
||||
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
|
||||
ff := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII", AlternativeNames: []string{"FF7"}})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Lemmings (SNES + Mega Drive)"})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cowboy Bebop"})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Unknown"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
|
||||
chrono := coverOf(t, store, "Chrono Trigger")
|
||||
require.NotNil(t, chrono.CoverFile)
|
||||
assert.Equal(t, chrono.Uuid.String()+".png", *chrono.CoverFile)
|
||||
assert.Equal(t, "steamgriddb", *chrono.CoverSource)
|
||||
assert.Equal(t, "1", *chrono.CoverExternalID)
|
||||
assert.False(t, chrono.CoverManual)
|
||||
assert.True(t, chrono.CoverModified.Valid)
|
||||
assert.FileExists(t, filepath.Join(c.dir, *chrono.CoverFile))
|
||||
|
||||
assert.Equal(t, "7", *coverOf(t, store, "Final Fantasy VII").CoverExternalID, "found by its alternative name")
|
||||
assert.Equal(t, "3", *coverOf(t, store, "Lemmings (SNES + Mega Drive)").CoverExternalID, "found without the part in brackets")
|
||||
bebop := coverOf(t, store, "Cowboy Bebop")
|
||||
assert.Equal(t, "anilist", *bebop.CoverSource, "AniList when SteamGridDB has nothing")
|
||||
|
||||
unknown := coverOf(t, store, "Unknown")
|
||||
assert.Nil(t, unknown.CoverFile)
|
||||
assert.True(t, unknown.CoverChecked.Valid, "marked as searched")
|
||||
assert.NotContains(t, apis.searched(), "anilist:FF7", "SteamGridDB found it first")
|
||||
_ = ff
|
||||
|
||||
// Searched soundtracks are not searched again until the recheck time.
|
||||
before := len(apis.searched())
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
assert.Len(t, apis.searched(), before)
|
||||
}
|
||||
|
||||
func TestCoverSearchAniListNeedsAnExactTitle(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
|
||||
assert.Nil(t, coverOf(t, store, "Final Fantasy VII").CoverFile, "a near match is not used")
|
||||
}
|
||||
|
||||
func TestCoverSearchStopsWhenAnAPIFails(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.failSteamGridDB = true
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
|
||||
err := c.searchMissing(context.Background())
|
||||
assert.ErrorIs(t, err, ErrUpstream)
|
||||
|
||||
assert.Equal(t, []string{"sgdb:A"}, apis.searched(), "the other soundtracks are not tried")
|
||||
assert.False(t, coverOf(t, store, "A").CoverChecked.Valid, "searched again next time")
|
||||
}
|
||||
|
||||
func TestCoverSearchSkipsManualCovers(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.games["Chrono Trigger"] = 1
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
|
||||
require.NoError(t, store.ClearCover(context.Background(), uuid.Nil)) // no-op
|
||||
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
|
||||
assert.Empty(t, apis.searched())
|
||||
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
|
||||
|
||||
// A cover set by hand while the job was searching is kept.
|
||||
stored, err := c.storeFound(context.Background(), st.Uuid, pngBytes(t), "steamgriddb", "1")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored)
|
||||
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
|
||||
}
|
||||
|
||||
func TestCoverSearchAgainAfterRecheckTime(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Old",
|
||||
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter - time.Hour), Valid: true}})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Recent",
|
||||
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
|
||||
assert.Contains(t, apis.searched(), "sgdb:Old")
|
||||
assert.NotContains(t, apis.searched(), "sgdb:Recent")
|
||||
}
|
||||
|
||||
func TestCoverUpload(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
c := newCovers(t, store, CoverOptions{})
|
||||
ctx := context.Background()
|
||||
jpeg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 100)...)
|
||||
|
||||
require.NoError(t, c.Upload(ctx, st.Uuid, jpeg))
|
||||
got := coverOf(t, store, "Game")
|
||||
assert.Equal(t, st.Uuid.String()+".jpg", *got.CoverFile)
|
||||
assert.True(t, got.CoverManual)
|
||||
assert.Nil(t, got.CoverExternalID)
|
||||
path, err := c.Path(ctx, st.Uuid)
|
||||
require.NoError(t, err)
|
||||
data, _ := os.ReadFile(path)
|
||||
assert.Equal(t, jpeg, data)
|
||||
|
||||
// A PNG replaces the JPEG, and its old file is removed.
|
||||
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
|
||||
assert.Equal(t, st.Uuid.String()+".png", *coverOf(t, store, "Game").CoverFile)
|
||||
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".jpg"))
|
||||
|
||||
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, []byte("<svg></svg>")), ErrInvalidImage)
|
||||
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, append(pngBytes(t), make([]byte, MaxCoverBytes)...)), ErrInvalidImage)
|
||||
assert.ErrorIs(t, c.Upload(ctx, uuid.New(), pngBytes(t)), ErrNotFound)
|
||||
entries, _ := os.ReadDir(c.dir)
|
||||
assert.Len(t, entries, 1, "no temporary files are left behind")
|
||||
}
|
||||
|
||||
func TestCoverFromSource(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
ctx := context.Background()
|
||||
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
require.NoError(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "5"))
|
||||
got := coverOf(t, store, "Game")
|
||||
assert.Equal(t, "steamgriddb", *got.CoverSource)
|
||||
assert.Equal(t, "5", *got.CoverExternalID)
|
||||
assert.True(t, got.CoverManual, "a picked cover is never replaced by the job")
|
||||
|
||||
require.NoError(t, c.FromSource(ctx, st.Uuid, "anilist", "42"))
|
||||
assert.Equal(t, "anilist", *coverOf(t, store, "Game").CoverSource)
|
||||
|
||||
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "404"), ErrNotFound, "no grids")
|
||||
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "abc"), ErrNotFound)
|
||||
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "anilist", "7"), ErrNotFound)
|
||||
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "igdb", "1"), ErrInvalidName)
|
||||
assert.ErrorIs(t, c.FromSource(ctx, uuid.New(), "anilist", "42"), ErrNotFound)
|
||||
|
||||
withoutKey := newCovers(t, store, apis.options(false))
|
||||
assert.ErrorIs(t, withoutKey.FromSource(ctx, st.Uuid, "steamgriddb", "5"), ErrInvalidName)
|
||||
require.NoError(t, withoutKey.FromSource(ctx, st.Uuid, "anilist", "42"), "AniList needs no key")
|
||||
}
|
||||
|
||||
func TestCoverDownloadMustBeAnImage(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
c := newCovers(t, fakedb.New(), apis.options(true))
|
||||
_, err := c.download(context.Background(), apis.server.URL+"/img/text.png")
|
||||
assert.ErrorIs(t, err, ErrUpstream)
|
||||
}
|
||||
|
||||
func TestCoverRemove(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
c := newCovers(t, store, CoverOptions{})
|
||||
ctx := context.Background()
|
||||
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
|
||||
|
||||
require.NoError(t, c.Remove(ctx, st.Uuid))
|
||||
|
||||
got := coverOf(t, store, "Game")
|
||||
assert.Nil(t, got.CoverFile)
|
||||
assert.False(t, got.CoverManual)
|
||||
assert.False(t, got.CoverChecked.Valid, "the job searches for it again")
|
||||
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".png"))
|
||||
_, err := c.Path(ctx, st.Uuid)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
assert.Len(t, c.trigger, 1, "the job is asked to search")
|
||||
}
|
||||
|
||||
func TestCoverRemoveOrphans(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
c := newCovers(t, store, CoverOptions{})
|
||||
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
|
||||
writeFile(t, filepath.Join(c.dir, uuid.NewString()+".png"), "left over after a reset")
|
||||
|
||||
c.removeOrphans(context.Background())
|
||||
|
||||
entries, _ := os.ReadDir(c.dir)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, st.Uuid.String()+".png", entries[0].Name())
|
||||
}
|
||||
|
||||
func TestCoverRunSearchesUntilStopped(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.games["Game"] = 1
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(ctx); close(done) }()
|
||||
|
||||
assert.Eventually(t, func() bool { return coverOf(t, store, "Game").CoverFile != nil }, time.Second, 10*time.Millisecond,
|
||||
"searches when started")
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) // same name, new soundtrack
|
||||
c.Trigger()
|
||||
assert.Eventually(t, func() bool { return strings.Count(strings.Join(apis.searched(), ","), "sgdb:Game") == 2 },
|
||||
time.Second, 10*time.Millisecond, "searches again when triggered")
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Run did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverRunWithoutKeyDoesNothing(t *testing.T) {
|
||||
c := newCovers(t, fakedb.New(), CoverOptions{})
|
||||
assert.False(t, c.SearchesAutomatically())
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(context.Background()); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Run should return at once without a SteamGridDB key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverSearchNames(t *testing.T) {
|
||||
assert.Equal(t, []string{"Lemmings (SNES + Mega Drive)", "LEM", "lemmings"},
|
||||
coverSearchNames("Lemmings (SNES + Mega Drive)", []string{"LEM", "lemmings"}))
|
||||
assert.Equal(t, []string{"Chrono Trigger"}, coverSearchNames("Chrono Trigger", nil))
|
||||
assert.Equal(t, []string{"Resident Evil 2 [Remake]", "Resident Evil 2"}, coverSearchNames("Resident Evil 2 [Remake]", nil))
|
||||
}
|
||||
|
||||
func TestNormalizeTitle(t *testing.T) {
|
||||
assert.Equal(t, normalizeTitle("Cowboy Bebop: The Movie"), normalizeTitle("cowboy bebop the movie"))
|
||||
assert.Equal(t, "ファイナルファンタジーvii", normalizeTitle("ファイナルファンタジーVII"))
|
||||
}
|
||||
|
||||
func TestBestGame(t *testing.T) {
|
||||
games := func(names ...string) []steamGridDBGame {
|
||||
result := make([]steamGridDBGame, len(names))
|
||||
for i, name := range names {
|
||||
result[i] = steamGridDBGame{ID: i + 1, Name: name}
|
||||
}
|
||||
return result
|
||||
}
|
||||
tests := []struct {
|
||||
search string
|
||||
games []steamGridDBGame
|
||||
want string
|
||||
}{
|
||||
// Real SteamGridDB results, in their order.
|
||||
{"Civilization IV", games("Sid Meier's Civilization IV: Colonization", "Sid Meier's Civilization IV",
|
||||
"Sid Meier's Civilization IV: Warlords", "Galactic Civilizations IV"), "Sid Meier's Civilization IV"},
|
||||
{"Lemmings 3D", games("3D Lemmings Winterland", "3D Lemmings", "Lemmings"), "3D Lemmings"},
|
||||
{"Octopath Traveler 2", games("Octopath Traveler II", "Octopath Traveler"), "Octopath Traveler II"},
|
||||
{"Joe & Mac", games("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), "Joe & Mac: Caveman Ninja"},
|
||||
{"Ys (Chronicles)", games("Ys I & II Chronicles", "Ys Origin"), "Ys I & II Chronicles"},
|
||||
// No result has every word: SteamGridDB's first result.
|
||||
{"Resident Evil 2 (Remake)", games("Resident Evil 2", "Resident Evil 2: 1-Shot Demo"), "Resident Evil 2"},
|
||||
{"Pokémon HeartGold & SoulSilver", games("Pokémon HeartGold Version", "Pokémon SoulSilver Version"), "Pokémon HeartGold Version"},
|
||||
{"Kingdom Hearts", games("Kingdom Hearts II", "Kingdom Hearts"), "Kingdom Hearts"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, bestGame(tt.search, tt.games).Name, tt.search)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,18 @@ var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrInvalidName is returned when a requested file name is not allowed.
|
||||
ErrInvalidName = errors.New("invalid name")
|
||||
// ErrInvalidImage is returned when an image is not a supported type or too large.
|
||||
ErrInvalidImage = errors.New("invalid image")
|
||||
// ErrCoversDisabled is returned when covers are used but COVERS_PATH is not set.
|
||||
ErrCoversDisabled = errors.New("covers are not enabled on this server")
|
||||
// ErrConflict is returned when something with the same name already exists.
|
||||
ErrConflict = errors.New("already exists")
|
||||
// ErrSyncing is returned when the music folder is being synced.
|
||||
ErrSyncing = errors.New("syncing is in progress")
|
||||
// ErrTooLarge is returned when an upload is over its size limit.
|
||||
ErrTooLarge = errors.New("too large")
|
||||
// ErrUploadsUnavailable is returned when the music folder cannot be written to.
|
||||
ErrUploadsUnavailable = errors.New("uploads are not possible: the music folder cannot be written to")
|
||||
// ErrUpstream is returned when an external service could not be reached.
|
||||
ErrUpstream = errors.New("upstream service unavailable")
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// Soundtrack is a soundtrack as returned by the API.
|
||||
type Soundtrack struct {
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
LastPlayed *time.Time `json:"last_played,omitempty"`
|
||||
Added time.Time `json:"added"`
|
||||
CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."`
|
||||
}
|
||||
|
||||
// MaxNameLength is the longest alternative name that can be stored, in bytes.
|
||||
const MaxNameLength = 200
|
||||
|
||||
// SoundtrackWithSongList is a soundtrack with all its songs.
|
||||
type SoundtrackWithSongList struct {
|
||||
Soundtrack
|
||||
Songs []Song `json:"songs"`
|
||||
}
|
||||
|
||||
// Song is a song as returned by the API.
|
||||
type Song struct {
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
FileName *string `json:"file_name,omitempty"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
}
|
||||
|
||||
// LibraryService looks up soundtracks and songs by UUID.
|
||||
type LibraryService struct {
|
||||
q repository.Querier
|
||||
}
|
||||
|
||||
// NewLibraryService creates a LibraryService.
|
||||
func NewLibraryService(q repository.Querier) *LibraryService {
|
||||
return &LibraryService{q: q}
|
||||
}
|
||||
|
||||
func toSoundtrack(s repository.Soundtrack) Soundtrack {
|
||||
return Soundtrack{
|
||||
Uuid: s.Uuid,
|
||||
Name: s.SoundtrackName,
|
||||
AlternativeNames: nonNil(s.AlternativeNames),
|
||||
TimesPlayed: s.TimesPlayed,
|
||||
LastPlayed: s.LastPlayed,
|
||||
Added: s.Added,
|
||||
CoverModified: unixSeconds(s.CoverModified),
|
||||
}
|
||||
}
|
||||
|
||||
// nonNil returns names, or an empty list for nil, so the JSON is [] and never null.
|
||||
func nonNil(names []string) []string {
|
||||
if names == nil {
|
||||
return []string{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// unixSeconds returns t in Unix seconds, or nil when it is not set.
|
||||
func unixSeconds(t pgtype.Timestamptz) *int64 {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
seconds := t.Time.Unix()
|
||||
return &seconds
|
||||
}
|
||||
|
||||
// SetAlternativeNames replaces the alternative names of an active soundtrack. The names
|
||||
// are trimmed, and empty names, duplicates and names equal to the soundtrack's own name
|
||||
// (ignoring case) are dropped.
|
||||
func (l *LibraryService) SetAlternativeNames(ctx context.Context, id uuid.UUID, names []string) (Soundtrack, error) {
|
||||
row, err := l.q.GetSoundtrackByUUID(ctx, id)
|
||||
if err != nil {
|
||||
return Soundtrack{}, notFound(err)
|
||||
}
|
||||
row, err = l.q.SetAlternativeNames(ctx, repository.SetAlternativeNamesParams{
|
||||
Uuid: id, Names: cleanAlternativeNames(row.SoundtrackName, names),
|
||||
})
|
||||
if err != nil {
|
||||
return Soundtrack{}, notFound(err)
|
||||
}
|
||||
return toSoundtrack(row), nil
|
||||
}
|
||||
|
||||
func cleanAlternativeNames(name string, names []string) []string {
|
||||
cleaned := []string{}
|
||||
seen := map[string]bool{strings.ToLower(strings.TrimSpace(name)): true}
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
key := strings.ToLower(n)
|
||||
if n == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
cleaned = append(cleaned, n)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func notFound(err error) error {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Soundtracks returns all active soundtracks, sorted by name.
|
||||
func (l *LibraryService) Soundtracks(ctx context.Context) ([]Soundtrack, error) {
|
||||
rows, err := l.q.FindAllSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
soundtracks := make([]Soundtrack, len(rows))
|
||||
for i, row := range rows {
|
||||
soundtracks[i] = toSoundtrack(row)
|
||||
}
|
||||
return soundtracks, nil
|
||||
}
|
||||
|
||||
// Soundtrack returns an active soundtrack with its songs.
|
||||
func (l *LibraryService) Soundtrack(ctx context.Context, id uuid.UUID) (SoundtrackWithSongList, error) {
|
||||
row, err := l.q.GetSoundtrackByUUID(ctx, id)
|
||||
if err != nil {
|
||||
return SoundtrackWithSongList{}, notFound(err)
|
||||
}
|
||||
songRows, err := l.q.GetSongsBySoundtrackUUID(ctx, id)
|
||||
if err != nil {
|
||||
return SoundtrackWithSongList{}, err
|
||||
}
|
||||
songs := make([]Song, len(songRows))
|
||||
for i, song := range songRows {
|
||||
songs[i] = toSong(song, row.SoundtrackName)
|
||||
}
|
||||
return SoundtrackWithSongList{Soundtrack: toSoundtrack(row), Songs: songs}, nil
|
||||
}
|
||||
|
||||
func toSong(s repository.Song, soundtrackName string) Song {
|
||||
return Song{
|
||||
Uuid: s.Uuid,
|
||||
Name: s.SongName,
|
||||
FileName: s.FileName,
|
||||
TimesPlayed: s.TimesPlayed,
|
||||
SoundtrackUuid: s.SoundtrackUuid,
|
||||
SoundtrackName: soundtrackName,
|
||||
}
|
||||
}
|
||||
|
||||
// Song returns a song of an active soundtrack.
|
||||
func (l *LibraryService) Song(ctx context.Context, id uuid.UUID) (Song, error) {
|
||||
row, err := l.q.GetActiveSongByUUID(ctx, id)
|
||||
if err != nil {
|
||||
return Song{}, notFound(err)
|
||||
}
|
||||
return toSong(row.Song, row.SoundtrackName), nil
|
||||
}
|
||||
|
||||
// SongPath returns the file path of a song of an active soundtrack, if the file can be played.
|
||||
func (l *LibraryService) SongPath(ctx context.Context, id uuid.UUID) (string, error) {
|
||||
row, err := l.q.GetActiveSongByUUID(ctx, id)
|
||||
if err != nil {
|
||||
return "", notFound(err)
|
||||
}
|
||||
if !songIsPlayable(row.Song, row.SoundtrackPath) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return row.Song.Path, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//go:build integration
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/pgtest"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLibraryAndStatisticsWithPostgres checks the UUID and statistics queries against a real database.
|
||||
func TestLibraryAndStatisticsWithPostgres(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
|
||||
musicDir := copyTestMusic(t)
|
||||
require.NoError(t, os.Mkdir(filepath.Join(musicDir, "Empty Soundtrack"), 0o755))
|
||||
writeFile(t, filepath.Join(musicDir, "Empty Soundtrack", "cover.jpg"), "image")
|
||||
|
||||
s := NewSyncService(q, musicDir, nil)
|
||||
require.True(t, s.Start(false))
|
||||
s.Wait()
|
||||
require.Empty(t, s.Result().CatchedErrors)
|
||||
|
||||
count, err := q.CountLibrary(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(4), count.Soundtracks)
|
||||
require.NoError(t, s.UpdateLibraryMetrics(ctx))
|
||||
|
||||
library := NewLibraryService(q)
|
||||
soundtracks, err := library.Soundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, soundtracks, 4)
|
||||
|
||||
bird, err := library.Soundtrack(ctx, soundtracks[0].Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "A Bird Story", bird.Name)
|
||||
require.Len(t, bird.Songs, 6)
|
||||
song := bird.Songs[0]
|
||||
assert.Equal(t, bird.Uuid, song.SoundtrackUuid)
|
||||
|
||||
info, err := library.Song(ctx, song.Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "A Bird Story", info.SoundtrackName)
|
||||
path, err := library.SongPath(ctx, song.Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.FileExists(t, path)
|
||||
_, err = library.Song(ctx, uuid.New())
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// Play counts by UUID.
|
||||
require.NoError(t, q.AddSongPlayedByUUID(ctx, song.Uuid))
|
||||
require.NoError(t, q.AddSoundtrackPlayedByUUID(ctx, bird.Uuid))
|
||||
info, err = library.Song(ctx, song.Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(1), info.TimesPlayed)
|
||||
|
||||
stats := NewStatisticsService(q)
|
||||
most, err := stats.MostPlayedSoundtracks(ctx, 10)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, most)
|
||||
assert.Equal(t, bird.Uuid, most[0].SoundtrackUuid)
|
||||
assert.Len(t, most[0].Songs, 6)
|
||||
assert.NotEqual(t, uuid.Nil, most[0].Songs[0].SongUuid)
|
||||
|
||||
songs, err := stats.MostPlayedSongs(ctx, 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, song.Uuid, songs[0].SongUuid)
|
||||
assert.Equal(t, bird.Uuid, songs[0].SoundtrackUuid)
|
||||
|
||||
all, err := stats.AllSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, all)
|
||||
for i := 1; i < len(all); i++ {
|
||||
assert.LessOrEqual(t, all[i-1].SoundtrackName, all[i].SoundtrackName, "sorted by name")
|
||||
}
|
||||
for _, st := range all {
|
||||
if st.SoundtrackUuid == bird.Uuid {
|
||||
assert.Equal(t, int32(1), st.SoundtrackPlayed)
|
||||
assert.Len(t, st.Songs, 6)
|
||||
}
|
||||
if st.SoundtrackName == "Empty Soundtrack" {
|
||||
assert.Empty(t, st.Songs, "a soundtrack without songs has an empty song list")
|
||||
}
|
||||
}
|
||||
|
||||
never, err := stats.NeverPlayedSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
for _, st := range never {
|
||||
assert.NotEqual(t, uuid.Nil, st.SoundtrackUuid)
|
||||
if st.SoundtrackName == "Empty Soundtrack" {
|
||||
assert.Empty(t, st.Songs, "a soundtrack without songs has an empty song list")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLibrarySoundtracks(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
b := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B Game", TimesPlayed: 3})
|
||||
a := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A Game"})
|
||||
deleted := time.Now()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Gone", Deleted: &deleted})
|
||||
|
||||
soundtracks, err := NewLibraryService(store).Soundtracks(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, soundtracks, 2)
|
||||
assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game", AlternativeNames: []string{}}, soundtracks[0])
|
||||
assert.Equal(t, b.Uuid, soundtracks[1].Uuid)
|
||||
assert.Equal(t, int32(3), soundtracks[1].TimesPlayed)
|
||||
}
|
||||
|
||||
func TestLibrarySetAlternativeNames(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"})
|
||||
l := NewLibraryService(store)
|
||||
ctx := context.Background()
|
||||
|
||||
soundtrack, err := l.SetAlternativeNames(ctx, st.Uuid,
|
||||
[]string{" FF7 ", "ff7", "", "final fantasy vii", "ファイナルファンタジーVII", " "})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"FF7", "ファイナルファンタジーVII"}, soundtrack.AlternativeNames,
|
||||
"trimmed, without empty names, duplicates or the soundtrack's own name")
|
||||
|
||||
stored, err := l.Soundtrack(ctx, st.Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"FF7", "ファイナルファンタジーVII"}, stored.AlternativeNames)
|
||||
|
||||
soundtrack, err = l.SetAlternativeNames(ctx, st.Uuid, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{}, soundtrack.AlternativeNames, "an empty list removes them")
|
||||
|
||||
_, err = l.SetAlternativeNames(ctx, uuid.New(), []string{"x"})
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestLibrarySetAlternativeNamesSearchesForACoverAgain(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
checked := pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||
without := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "No cover", CoverChecked: checked})
|
||||
file := "cover.jpg"
|
||||
with := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cover", CoverFile: &file, CoverChecked: checked})
|
||||
l := NewLibraryService(store)
|
||||
|
||||
_, err := l.SetAlternativeNames(context.Background(), without.Uuid, []string{"x"})
|
||||
require.NoError(t, err)
|
||||
_, err = l.SetAlternativeNames(context.Background(), with.Uuid, []string{"y"})
|
||||
require.NoError(t, err)
|
||||
|
||||
st, _ := store.Soundtrack("No cover")
|
||||
assert.False(t, st.CoverChecked.Valid, "searched again with the new names")
|
||||
st, _ = store.Soundtrack("Cover")
|
||||
assert.True(t, st.CoverChecked.Valid, "a soundtrack with a cover keeps it")
|
||||
}
|
||||
|
||||
func TestLibrarySoundtrackWithSongs(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A")
|
||||
l := NewLibraryService(store)
|
||||
|
||||
result, err := l.Soundtrack(context.Background(), st.Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Game A", result.Name)
|
||||
require.Len(t, result.Songs, 2)
|
||||
assert.Equal(t, "Song A", result.Songs[0].Name)
|
||||
assert.Equal(t, st.Uuid, result.Songs[0].SoundtrackUuid)
|
||||
assert.Equal(t, "Game A", result.Songs[0].SoundtrackName)
|
||||
|
||||
_, err = l.Soundtrack(context.Background(), uuid.New())
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestLibrarySong(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
|
||||
song := store.SongsOf(st.ID)[0]
|
||||
l := NewLibraryService(store)
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := l.Song(ctx, song.Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Song{Uuid: song.Uuid, Name: "Song", FileName: song.FileName, SoundtrackUuid: st.Uuid, SoundtrackName: "Game A"}, result)
|
||||
|
||||
path, err := l.SongPath(ctx, song.Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, song.Path, path)
|
||||
|
||||
require.NoError(t, os.Remove(song.Path))
|
||||
_, err = l.SongPath(ctx, song.Uuid)
|
||||
assert.ErrorIs(t, err, ErrNotFound, "missing files are not served")
|
||||
|
||||
_, err = l.Song(ctx, uuid.New())
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestLibraryHidesSongsOfDeletedSoundtracks(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
|
||||
song := store.SongsOf(st.ID)[0]
|
||||
require.NoError(t, store.MarkSoundtracksDeletedExcept(context.Background(), nil))
|
||||
|
||||
_, err := NewLibraryService(store).Song(context.Background(), song.Uuid)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
_, err = NewLibraryService(store).Soundtrack(context.Background(), st.Uuid)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/metrics"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func requested(mode PickMode) float64 {
|
||||
return testutil.ToFloat64(metrics.SongsRequested.WithLabelValues(string(mode)))
|
||||
}
|
||||
|
||||
func played(mode PickMode) float64 {
|
||||
return testutil.ToFloat64(metrics.SongsPlayed.WithLabelValues(string(mode)))
|
||||
}
|
||||
|
||||
func syncCount(syncType string) uint64 {
|
||||
m := &dto.Metric{}
|
||||
_ = metrics.SyncDuration.WithLabelValues(syncType).(prometheus.Metric).Write(m)
|
||||
return m.GetHistogram().GetSampleCount()
|
||||
}
|
||||
|
||||
func TestSongMetricsByPickMode(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
ctx := context.Background()
|
||||
q := newQueue(t, m)
|
||||
before := map[PickMode][2]float64{}
|
||||
for _, mode := range []PickMode{PickRandom, PickLow, PickClassic} {
|
||||
before[mode] = [2]float64{requested(mode), played(mode)}
|
||||
}
|
||||
|
||||
pick := map[PickMode]func(context.Context, uuid.UUID) (string, error){
|
||||
PickRandom: func(ctx context.Context, id uuid.UUID) (string, error) { return m.RandomSong(ctx, id) },
|
||||
PickLow: func(ctx context.Context, id uuid.UUID) (string, error) { return m.RandomSongLowChance(ctx, id) },
|
||||
PickClassic: func(ctx context.Context, id uuid.UUID) (string, error) { return m.RandomSongClassic(ctx, id) },
|
||||
}
|
||||
for songNo, mode := range []PickMode{PickRandom, PickLow, PickClassic} {
|
||||
_, err := pick[mode](ctx, q)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, m.AddLatestToQueue(ctx, q))
|
||||
require.NoError(t, m.SetPlayed(ctx, q, songNo))
|
||||
}
|
||||
// Fetched but never added: requested, not played.
|
||||
_, err := m.RandomSong(ctx, q)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, m.AddLatestPlayed(ctx, q), "the current song is the classic one")
|
||||
|
||||
assert.Equal(t, before[PickRandom][0]+2, requested(PickRandom))
|
||||
assert.Equal(t, before[PickLow][0]+1, requested(PickLow))
|
||||
assert.Equal(t, before[PickClassic][0]+1, requested(PickClassic))
|
||||
assert.Equal(t, before[PickRandom][1]+1, played(PickRandom))
|
||||
assert.Equal(t, before[PickLow][1]+1, played(PickLow))
|
||||
assert.Equal(t, before[PickClassic][1]+2, played(PickClassic))
|
||||
}
|
||||
|
||||
func TestSongsPlayedIgnoresSongsOutsideTheQueue(t *testing.T) {
|
||||
m := NewMusicService(fakedb.New(), t.TempDir())
|
||||
before := played(PickRandom)
|
||||
require.NoError(t, m.SetPlayed(context.Background(), newQueue(t, m), 3))
|
||||
assert.Equal(t, before, played(PickRandom))
|
||||
}
|
||||
|
||||
func TestSyncMetrics(t *testing.T) {
|
||||
f := newSyncFixture(t)
|
||||
f.write("Game A/01.mp3", "a1")
|
||||
f.write("Game A/02.mp3", "a2")
|
||||
f.write("Game B/01.mp3", "b1")
|
||||
changesBefore, fullBefore := syncCount("changes"), syncCount("full")
|
||||
metrics.SyncLastSuccess.Set(0)
|
||||
|
||||
f.run(false)
|
||||
|
||||
assert.Equal(t, changesBefore+1, syncCount("changes"))
|
||||
assert.Equal(t, 2.0, testutil.ToFloat64(metrics.LibrarySoundtracks))
|
||||
assert.Equal(t, 3.0, testutil.ToFloat64(metrics.LibrarySongs))
|
||||
assert.InDelta(t, float64(time.Now().Unix()), testutil.ToFloat64(metrics.SyncLastSuccess), 5)
|
||||
|
||||
f.store.AddTestSong(repository.Song{SoundtrackID: 999, SongName: "gone", Path: "/does/not/exist.mp3"})
|
||||
f.run(true)
|
||||
|
||||
assert.Equal(t, fullBefore+1, syncCount("full"))
|
||||
assert.Equal(t, 1.0, testutil.ToFloat64(metrics.BrokenSongs))
|
||||
assert.Equal(t, 3.0, testutil.ToFloat64(metrics.LibrarySongs), "the broken song is removed")
|
||||
}
|
||||
|
||||
func TestSyncWithErrorsIsNotALastSuccess(t *testing.T) {
|
||||
metrics.SyncLastSuccess.Set(0)
|
||||
s := NewSyncService(fakedb.New(), filepath.Join(t.TempDir(), "missing"), nil)
|
||||
require.True(t, s.Start(false))
|
||||
s.Wait()
|
||||
require.NotEmpty(t, s.Result().CatchedErrors)
|
||||
|
||||
assert.Zero(t, testutil.ToFloat64(metrics.SyncLastSuccess))
|
||||
}
|
||||
+201
-86
@@ -12,7 +12,9 @@ import (
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/metrics"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -20,32 +22,48 @@ import (
|
||||
// maxPickAttempts bounds how many broken songs are skipped before giving up.
|
||||
const maxPickAttempts = 100
|
||||
|
||||
// PickMode is how a random song was picked. It is stored with queued songs and used as
|
||||
// the mode label of the song metrics.
|
||||
type PickMode string
|
||||
|
||||
const (
|
||||
PickRandom PickMode = "random"
|
||||
PickLow PickMode = "low"
|
||||
PickClassic PickMode = "classic"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Start every mode at 0, so the series exist before the first song.
|
||||
for _, mode := range []PickMode{PickRandom, PickLow, PickClassic} {
|
||||
metrics.SongsRequested.WithLabelValues(string(mode))
|
||||
metrics.SongsPlayed.WithLabelValues(string(mode))
|
||||
}
|
||||
}
|
||||
|
||||
type SongInfo struct {
|
||||
SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"`
|
||||
Soundtrack string `json:"Soundtrack"`
|
||||
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
|
||||
SongUuid uuid.UUID `json:"SongUuid"`
|
||||
Song string `json:"Song"`
|
||||
SongPlayed int32 `json:"SongPlayed"`
|
||||
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
|
||||
SongNo int `json:"SongNo"`
|
||||
}
|
||||
|
||||
// MusicService picks songs and keeps track of the shared play queue.
|
||||
// MusicService picks songs and keeps track of the play queues. Every client has its own queue,
|
||||
// stored in the database and identified by its UUID.
|
||||
type MusicService struct {
|
||||
q repository.Querier
|
||||
soundCheckPath string
|
||||
|
||||
cacheMu sync.Mutex
|
||||
soundtracks []repository.Soundtrack
|
||||
|
||||
// Queue state is shared by all clients and intentionally not locked.
|
||||
queue []repository.Song
|
||||
currentSong int
|
||||
lastFetched repository.Song
|
||||
}
|
||||
|
||||
// NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs.
|
||||
func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService {
|
||||
return &MusicService{q: q, soundCheckPath: soundCheckPath, currentSong: -1}
|
||||
return &MusicService{q: q, soundCheckPath: soundCheckPath}
|
||||
}
|
||||
|
||||
func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) {
|
||||
@@ -61,10 +79,8 @@ func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtr
|
||||
return m.soundtracks, nil
|
||||
}
|
||||
|
||||
// Reset clears the play queue and reloads the soundtrack list.
|
||||
func (m *MusicService) Reset(ctx context.Context) error {
|
||||
m.queue = nil
|
||||
m.currentSong = -1
|
||||
// ReloadSoundtracks reloads the soundtrack list.
|
||||
func (m *MusicService) ReloadSoundtracks(ctx context.Context) error {
|
||||
m.cacheMu.Lock()
|
||||
m.soundtracks = nil
|
||||
m.cacheMu.Unlock()
|
||||
@@ -72,6 +88,36 @@ func (m *MusicService) Reset(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ResolveQueue returns the queue with the given ID if it is a running queue.
|
||||
// Otherwise, for example when id is empty or the queue has ended, it starts a new queue.
|
||||
func (m *MusicService) ResolveQueue(ctx context.Context, id string) (uuid.UUID, error) {
|
||||
if queueID, err := uuid.Parse(id); err == nil {
|
||||
queue, err := m.q.GetQueue(ctx, queueID)
|
||||
if err == nil && !queue.EndedAt.Valid {
|
||||
return queue.Uuid, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
}
|
||||
queue, err := m.q.CreateQueue(ctx)
|
||||
return queue.Uuid, err
|
||||
}
|
||||
|
||||
// NewQueue ends the given queue and starts a new one.
|
||||
func (m *MusicService) NewQueue(ctx context.Context, queueID uuid.UUID) (uuid.UUID, error) {
|
||||
if err := m.q.EndQueue(ctx, queueID); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
queue, err := m.q.CreateQueue(ctx)
|
||||
return queue.Uuid, err
|
||||
}
|
||||
|
||||
// EndAllQueues ends every running queue. Clients get a new queue on their next request.
|
||||
func (m *MusicService) EndAllQueues(ctx context.Context) error {
|
||||
return m.q.EndActiveQueues(ctx)
|
||||
}
|
||||
|
||||
// SoundCheckSong returns the path of a random sound check song.
|
||||
func (m *MusicService) SoundCheckSong() (string, error) {
|
||||
files, err := os.ReadDir(m.soundCheckPath)
|
||||
@@ -85,44 +131,57 @@ func (m *MusicService) SoundCheckSong() (string, error) {
|
||||
return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil
|
||||
}
|
||||
|
||||
// AddLatestToQueue appends the last fetched random song to the queue.
|
||||
func (m *MusicService) AddLatestToQueue() {
|
||||
if m.lastFetched.Path != "" {
|
||||
m.currentSong = len(m.queue)
|
||||
m.queue = append(m.queue, m.lastFetched)
|
||||
m.lastFetched = repository.Song{}
|
||||
}
|
||||
// AddLatestToQueue appends the last fetched random song to the queue and makes it the current song.
|
||||
func (m *MusicService) AddLatestToQueue(ctx context.Context, queueID uuid.UUID) error {
|
||||
return m.q.AddLastFetchedToQueue(ctx, queueID)
|
||||
}
|
||||
|
||||
// AddLatestPlayed increments the play counters of the current song.
|
||||
func (m *MusicService) AddLatestPlayed(ctx context.Context) error {
|
||||
if !m.validIndex(m.currentSong) {
|
||||
return nil
|
||||
func (m *MusicService) AddLatestPlayed(ctx context.Context, queueID uuid.UUID) error {
|
||||
queue, songs, err := m.loadQueue(ctx, queueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.markPlayed(ctx, m.queue[m.currentSong])
|
||||
return m.markPlayed(ctx, songs, int(queue.CurrentSong))
|
||||
}
|
||||
|
||||
// SetPlayed increments the play counters of the song at the given queue position.
|
||||
func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error {
|
||||
if !m.validIndex(songNumber) {
|
||||
return nil
|
||||
}
|
||||
return m.markPlayed(ctx, m.queue[songNumber])
|
||||
}
|
||||
|
||||
func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error {
|
||||
if err := m.q.AddSoundtrackPlayed(ctx, song.SoundtrackID); err != nil {
|
||||
func (m *MusicService) SetPlayed(ctx context.Context, queueID uuid.UUID, songNumber int) error {
|
||||
_, songs, err := m.loadQueue(ctx, queueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: song.SoundtrackID, SongName: song.SongName})
|
||||
return m.markPlayed(ctx, songs, songNumber)
|
||||
}
|
||||
|
||||
func (m *MusicService) validIndex(i int) bool {
|
||||
return i >= 0 && i < len(m.queue)
|
||||
// markPlayed increments the play counters of songs[songNumber]. Positions outside the queue
|
||||
// and songs that have been removed from the library are ignored.
|
||||
func (m *MusicService) markPlayed(ctx context.Context, songs []repository.ListQueueSongsRow, songNumber int) error {
|
||||
if songNumber < 0 || songNumber >= len(songs) || songs[songNumber].Path == nil {
|
||||
return nil
|
||||
}
|
||||
song := songs[songNumber]
|
||||
if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.q.AddSongPlayedByUUID(ctx, song.SongUuid); err != nil {
|
||||
return err
|
||||
}
|
||||
metrics.SongsPlayed.WithLabelValues(song.Mode).Inc()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) {
|
||||
queue, err := m.q.GetQueue(ctx, queueID)
|
||||
if err != nil {
|
||||
return repository.Queue{}, nil, err
|
||||
}
|
||||
songs, err := m.q.ListQueueSongs(ctx, queueID)
|
||||
return queue, songs, err
|
||||
}
|
||||
|
||||
// RandomSong picks a random song from a random soundtrack.
|
||||
func (m *MusicService) RandomSong(ctx context.Context) (string, error) {
|
||||
func (m *MusicService) RandomSong(ctx context.Context, queueID uuid.UUID) (string, error) {
|
||||
soundtracks, err := m.allSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -131,29 +190,44 @@ func (m *MusicService) RandomSong(ctx context.Context) (string, error) {
|
||||
for i, s := range soundtracks {
|
||||
candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1}
|
||||
}
|
||||
return m.pickAndRemember(ctx, candidates)
|
||||
return m.pickAndRemember(ctx, queueID, candidates, PickRandom)
|
||||
}
|
||||
|
||||
// RandomSongLowChance picks a random song, favouring soundtracks played less than average.
|
||||
func (m *MusicService) RandomSongLowChance(ctx context.Context) (string, error) {
|
||||
func (m *MusicService) RandomSongLowChance(ctx context.Context, queueID uuid.UUID) (string, error) {
|
||||
soundtracks, err := m.allSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.pickAndRemember(ctx, lowChanceWeights(soundtracks))
|
||||
return m.pickAndRemember(ctx, queueID, lowChanceWeights(soundtracks), PickLow)
|
||||
}
|
||||
|
||||
func (m *MusicService) pickAndRemember(ctx context.Context, candidates []weightedSoundtrack) (string, error) {
|
||||
func (m *MusicService) pickAndRemember(
|
||||
ctx context.Context, queueID uuid.UUID, candidates []weightedSoundtrack, mode PickMode,
|
||||
) (string, error) {
|
||||
song, err := m.pickSong(ctx, candidates)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m.lastFetched = song
|
||||
return m.remember(ctx, queueID, song, mode)
|
||||
}
|
||||
|
||||
// remember stores the song and how it was picked as the queue's last fetched song,
|
||||
// for AddLatestToQueue.
|
||||
func (m *MusicService) remember(ctx context.Context, queueID uuid.UUID, song repository.Song, mode PickMode) (string, error) {
|
||||
modeName := string(mode)
|
||||
err := m.q.SetQueueLastFetched(ctx, repository.SetQueueLastFetchedParams{
|
||||
Uuid: queueID, LastFetchedSongUuid: song.Uuid, LastFetchedMode: &modeName,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
metrics.SongsRequested.WithLabelValues(modeName).Inc()
|
||||
return song.Path, nil
|
||||
}
|
||||
|
||||
// RandomSongClassic picks a random song where every song has the same chance.
|
||||
func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) {
|
||||
func (m *MusicService) RandomSongClassic(ctx context.Context, queueID uuid.UUID) (string, error) {
|
||||
for range maxPickAttempts {
|
||||
row, err := m.q.GetRandomActiveSong(ctx)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -168,69 +242,97 @@ func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
m.lastFetched = row.Song
|
||||
return row.Song.Path, nil
|
||||
return m.remember(ctx, queueID, row.Song, PickClassic)
|
||||
}
|
||||
return "", ErrNoSongs
|
||||
}
|
||||
|
||||
// SongInfo returns information about the current song in the queue.
|
||||
func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
|
||||
if !m.validIndex(m.currentSong) {
|
||||
return SongInfo{}
|
||||
func (m *MusicService) SongInfo(ctx context.Context, queueID uuid.UUID) (SongInfo, error) {
|
||||
queue, songs, err := m.loadQueue(ctx, queueID)
|
||||
if err != nil {
|
||||
return SongInfo{}, err
|
||||
}
|
||||
song := m.queue[m.currentSong]
|
||||
soundtrack := m.soundtrackFor(ctx, song)
|
||||
return SongInfo{
|
||||
Soundtrack: soundtrack.SoundtrackName,
|
||||
SoundtrackPlayed: soundtrack.TimesPlayed,
|
||||
Song: song.SongName,
|
||||
SongPlayed: song.TimesPlayed,
|
||||
CurrentlyPlaying: true,
|
||||
SongNo: m.currentSong,
|
||||
current := int(queue.CurrentSong)
|
||||
if current < 0 || current >= len(songs) {
|
||||
return SongInfo{}, nil
|
||||
}
|
||||
return m.songInfo(ctx, songs[current], true), nil
|
||||
}
|
||||
|
||||
// PlayedSongs returns all songs in the queue.
|
||||
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo {
|
||||
var songList []SongInfo
|
||||
for i, song := range m.queue {
|
||||
soundtrack := m.soundtrackFor(ctx, song)
|
||||
songList = append(songList, SongInfo{
|
||||
Soundtrack: soundtrack.SoundtrackName,
|
||||
SoundtrackPlayed: soundtrack.TimesPlayed,
|
||||
Song: song.SongName,
|
||||
SongPlayed: song.TimesPlayed,
|
||||
CurrentlyPlaying: i == m.currentSong,
|
||||
SongNo: i,
|
||||
})
|
||||
func (m *MusicService) PlayedSongs(ctx context.Context, queueID uuid.UUID) ([]SongInfo, error) {
|
||||
queue, songs, err := m.loadQueue(ctx, queueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return songList
|
||||
songList := []SongInfo{}
|
||||
for _, song := range songs {
|
||||
songList = append(songList, m.songInfo(ctx, song, song.SongNo == queue.CurrentSong))
|
||||
}
|
||||
return songList, nil
|
||||
}
|
||||
|
||||
// songInfo describes a queued song. Songs removed from the library keep their stored names.
|
||||
func (m *MusicService) songInfo(ctx context.Context, song repository.ListQueueSongsRow, current bool) SongInfo {
|
||||
info := SongInfo{
|
||||
SoundtrackUuid: song.SoundtrackUuid,
|
||||
Soundtrack: song.SoundtrackName,
|
||||
SongUuid: song.SongUuid,
|
||||
Song: song.SongName,
|
||||
CurrentlyPlaying: current,
|
||||
SongNo: int(song.SongNo),
|
||||
}
|
||||
if song.TimesPlayed != nil {
|
||||
info.SongPlayed = *song.TimesPlayed
|
||||
}
|
||||
if soundtrack, ok := m.soundtrackFor(ctx, song.SoundtrackUuid); ok {
|
||||
info.Soundtrack = soundtrack.SoundtrackName
|
||||
info.SoundtrackPlayed = soundtrack.TimesPlayed
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// Song jumps to the given queue position (clamped to the queue) and returns its path.
|
||||
func (m *MusicService) Song(songNumber int) (string, error) {
|
||||
if len(m.queue) == 0 {
|
||||
return "", ErrQueueEmpty
|
||||
func (m *MusicService) Song(ctx context.Context, queueID uuid.UUID, songNumber int) (string, error) {
|
||||
_, songs, err := m.loadQueue(ctx, queueID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m.currentSong = max(0, min(songNumber, len(m.queue)-1))
|
||||
return m.queue[m.currentSong].Path, nil
|
||||
return m.jump(ctx, queueID, songs, songNumber)
|
||||
}
|
||||
|
||||
// NextSong moves to the next song in the queue, staying on the last one at the end.
|
||||
func (m *MusicService) NextSong() (string, error) {
|
||||
if len(m.queue) == 0 {
|
||||
return "", ErrQueueEmpty
|
||||
func (m *MusicService) NextSong(ctx context.Context, queueID uuid.UUID) (string, error) {
|
||||
queue, songs, err := m.loadQueue(ctx, queueID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.Song(m.currentSong + 1)
|
||||
return m.jump(ctx, queueID, songs, int(queue.CurrentSong)+1)
|
||||
}
|
||||
|
||||
// PreviousSong moves to the previous song in the queue, staying on the first one at the start.
|
||||
func (m *MusicService) PreviousSong() (string, error) {
|
||||
if len(m.queue) == 0 {
|
||||
func (m *MusicService) PreviousSong(ctx context.Context, queueID uuid.UUID) (string, error) {
|
||||
queue, songs, err := m.loadQueue(ctx, queueID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.jump(ctx, queueID, songs, int(queue.CurrentSong)-1)
|
||||
}
|
||||
|
||||
func (m *MusicService) jump(ctx context.Context, queueID uuid.UUID, songs []repository.ListQueueSongsRow, songNumber int) (string, error) {
|
||||
if len(songs) == 0 {
|
||||
return "", ErrQueueEmpty
|
||||
}
|
||||
return m.Song(m.currentSong - 1)
|
||||
songNumber = max(0, min(songNumber, len(songs)-1))
|
||||
err := m.q.SetQueueCurrentSong(ctx, repository.SetQueueCurrentSongParams{Uuid: queueID, CurrentSong: int32(songNumber)})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if songs[songNumber].Path == nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return *songs[songNumber].Path, nil
|
||||
}
|
||||
|
||||
// SoundtrackNames returns all soundtrack names in alphabetical order.
|
||||
@@ -246,6 +348,19 @@ func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// SearchSoundtracks returns every soundtrack's name and alternative names, for search.
|
||||
func (m *MusicService) SearchSoundtracks(ctx context.Context) ([]SearchSoundtrack, error) {
|
||||
soundtracks, err := m.allSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]SearchSoundtrack, len(soundtracks))
|
||||
for i, s := range soundtracks {
|
||||
result[i] = SearchSoundtrack{Name: s.SoundtrackName, AlternativeNames: s.AlternativeNames}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SoundtrackNamesRandom returns all soundtrack names in random order.
|
||||
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
|
||||
names, err := m.SoundtrackNames(ctx)
|
||||
@@ -331,7 +446,7 @@ func (m *MusicService) removeBrokenSong(ctx context.Context, song repository.Son
|
||||
logging.GetLogger().Warn("Song not found, removing from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("path", song.Path))
|
||||
return m.q.RemoveBrokenSong(ctx, repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
|
||||
return m.q.RemoveBrokenSongs(ctx, []uuid.UUID{song.Uuid})
|
||||
}
|
||||
|
||||
// songIsPlayable reports whether the song file exists and its stored path is consistent.
|
||||
@@ -345,16 +460,16 @@ func songIsPlayable(song repository.Song, soundtrackPath string) bool {
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func (m *MusicService) soundtrackFor(ctx context.Context, song repository.Song) repository.Soundtrack {
|
||||
func (m *MusicService) soundtrackFor(ctx context.Context, soundtrackUUID uuid.UUID) (repository.Soundtrack, bool) {
|
||||
soundtracks, err := m.allSoundtracks(ctx)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err))
|
||||
return repository.Soundtrack{}
|
||||
return repository.Soundtrack{}, false
|
||||
}
|
||||
for _, soundtrack := range soundtracks {
|
||||
if soundtrack.ID == song.SoundtrackID {
|
||||
return soundtrack
|
||||
if soundtrack.Uuid == soundtrackUUID {
|
||||
return soundtrack, true
|
||||
}
|
||||
}
|
||||
return repository.Soundtrack{}
|
||||
return repository.Soundtrack{}, false
|
||||
}
|
||||
+206
-37
@@ -9,6 +9,7 @@ import (
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -32,30 +33,55 @@ func addSoundtrackWithSongs(t *testing.T, store *fakedb.Store, musicDir, name st
|
||||
return st
|
||||
}
|
||||
|
||||
// newQueue starts a queue for the tests.
|
||||
func newQueue(t *testing.T, m *MusicService) uuid.UUID {
|
||||
t.Helper()
|
||||
id, err := m.ResolveQueue(context.Background(), "")
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
// queueSong fetches a random song and adds it to the queue.
|
||||
func queueSong(t *testing.T, m *MusicService, queueID uuid.UUID) string {
|
||||
t.Helper()
|
||||
path, err := m.RandomSong(context.Background(), queueID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, m.AddLatestToQueue(context.Background(), queueID))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestMusicServiceEmptyQueue(t *testing.T) {
|
||||
m := NewMusicService(fakedb.New(), t.TempDir())
|
||||
ctx := context.Background()
|
||||
q := newQueue(t, m)
|
||||
|
||||
_, err := m.NextSong()
|
||||
_, err := m.NextSong(ctx, q)
|
||||
assert.ErrorIs(t, err, ErrQueueEmpty)
|
||||
_, err = m.PreviousSong()
|
||||
_, err = m.PreviousSong(ctx, q)
|
||||
assert.ErrorIs(t, err, ErrQueueEmpty)
|
||||
_, err = m.Song(0)
|
||||
_, err = m.Song(ctx, q, 0)
|
||||
assert.ErrorIs(t, err, ErrQueueEmpty)
|
||||
assert.Equal(t, SongInfo{}, m.SongInfo(context.Background()))
|
||||
assert.Empty(t, m.PlayedSongs(context.Background()))
|
||||
assert.NoError(t, m.AddLatestPlayed(context.Background()))
|
||||
assert.NoError(t, m.SetPlayed(context.Background(), -1))
|
||||
info, err := m.SongInfo(ctx, q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, SongInfo{}, info)
|
||||
played, err := m.PlayedSongs(ctx, q)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, played)
|
||||
assert.NotNil(t, played, "an empty queue is [] in JSON")
|
||||
assert.NoError(t, m.AddLatestPlayed(ctx, q))
|
||||
assert.NoError(t, m.SetPlayed(ctx, q, -1))
|
||||
}
|
||||
|
||||
func TestMusicServiceNoSoundtracks(t *testing.T) {
|
||||
m := NewMusicService(fakedb.New(), t.TempDir())
|
||||
ctx := context.Background()
|
||||
q := newQueue(t, m)
|
||||
|
||||
_, err := m.RandomSong(ctx)
|
||||
_, err := m.RandomSong(ctx, q)
|
||||
assert.ErrorIs(t, err, ErrNoSongs)
|
||||
_, err = m.RandomSongLowChance(ctx)
|
||||
_, err = m.RandomSongLowChance(ctx, q)
|
||||
assert.ErrorIs(t, err, ErrNoSongs)
|
||||
_, err = m.RandomSongClassic(ctx)
|
||||
_, err = m.RandomSongClassic(ctx, q)
|
||||
assert.ErrorIs(t, err, ErrNoSongs)
|
||||
}
|
||||
|
||||
@@ -64,7 +90,7 @@ func TestMusicServiceSoundtracksWithoutSongs(t *testing.T) {
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"})
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
|
||||
_, err := m.RandomSong(context.Background())
|
||||
_, err := m.RandomSong(context.Background(), newQueue(t, m))
|
||||
assert.ErrorIs(t, err, ErrNoSongs)
|
||||
}
|
||||
|
||||
@@ -74,67 +100,182 @@ func TestMusicServiceQueue(t *testing.T) {
|
||||
addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2")
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
ctx := context.Background()
|
||||
q := newQueue(t, m)
|
||||
|
||||
first, err := m.RandomSong(ctx)
|
||||
require.NoError(t, err)
|
||||
m.AddLatestToQueue()
|
||||
second, err := m.RandomSong(ctx)
|
||||
require.NoError(t, err)
|
||||
m.AddLatestToQueue()
|
||||
first := queueSong(t, m, q)
|
||||
second := queueSong(t, m, q)
|
||||
|
||||
info := m.SongInfo(ctx)
|
||||
info, err := m.SongInfo(ctx, q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Game A", info.Soundtrack)
|
||||
soundtrack, _ := store.Soundtrack("Game A")
|
||||
assert.Equal(t, soundtrack.Uuid, info.SoundtrackUuid)
|
||||
assert.Equal(t, second, songPathByUUID(store, info.SongUuid))
|
||||
assert.Equal(t, 1, info.SongNo)
|
||||
assert.True(t, info.CurrentlyPlaying)
|
||||
|
||||
path, err := m.PreviousSong()
|
||||
path, err := m.PreviousSong(ctx, q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first, path)
|
||||
path, err = m.PreviousSong()
|
||||
path, err = m.PreviousSong(ctx, q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first, path, "previous stays on the first song")
|
||||
|
||||
path, err = m.NextSong()
|
||||
path, err = m.NextSong(ctx, q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second, path)
|
||||
path, err = m.NextSong()
|
||||
path, err = m.NextSong(ctx, q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second, path, "next stays on the last song")
|
||||
|
||||
path, err = m.Song(99)
|
||||
path, err = m.Song(ctx, q, 99)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second, path, "song number is clamped")
|
||||
|
||||
played := m.PlayedSongs(ctx)
|
||||
played, err := m.PlayedSongs(ctx, q)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, played, 2)
|
||||
assert.False(t, played[0].CurrentlyPlaying)
|
||||
assert.True(t, played[1].CurrentlyPlaying)
|
||||
}
|
||||
|
||||
require.NoError(t, m.Reset(ctx))
|
||||
_, err = m.NextSong()
|
||||
func TestMusicServiceQueuesAreSeparate(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
ctx := context.Background()
|
||||
q1, q2 := newQueue(t, m), newQueue(t, m)
|
||||
require.NotEqual(t, q1, q2)
|
||||
|
||||
queueSong(t, m, q1)
|
||||
queueSong(t, m, q1)
|
||||
queueSong(t, m, q2)
|
||||
|
||||
played, err := m.PlayedSongs(ctx, q1)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, played, 2)
|
||||
played, err = m.PlayedSongs(ctx, q2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, played, 1)
|
||||
}
|
||||
|
||||
func TestMusicServiceQueueSurvivesRestart(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
q := newQueue(t, m)
|
||||
path := queueSong(t, m, q)
|
||||
|
||||
restarted := NewMusicService(store, t.TempDir())
|
||||
id, err := restarted.ResolveQueue(context.Background(), q.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, q, id)
|
||||
current, err := restarted.Song(context.Background(), q, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, path, current)
|
||||
}
|
||||
|
||||
func TestMusicServiceNewQueue(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
ctx := context.Background()
|
||||
old := newQueue(t, m)
|
||||
queueSong(t, m, old)
|
||||
|
||||
q, err := m.NewQueue(ctx, old)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, old, q)
|
||||
_, err = m.NextSong(ctx, q)
|
||||
assert.ErrorIs(t, err, ErrQueueEmpty)
|
||||
|
||||
resolved, err := m.ResolveQueue(ctx, old.String())
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, old, resolved, "an ended queue is not used again")
|
||||
played, err := m.PlayedSongs(ctx, old)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, played, 1, "ended queues are kept")
|
||||
}
|
||||
|
||||
func TestMusicServiceEndAllQueues(t *testing.T) {
|
||||
m := NewMusicService(fakedb.New(), t.TempDir())
|
||||
ctx := context.Background()
|
||||
q1, q2 := newQueue(t, m), newQueue(t, m)
|
||||
|
||||
require.NoError(t, m.EndAllQueues(ctx))
|
||||
for _, q := range []uuid.UUID{q1, q2} {
|
||||
id, err := m.ResolveQueue(ctx, q.String())
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, q, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMusicServiceResolveQueue(t *testing.T) {
|
||||
m := NewMusicService(fakedb.New(), t.TempDir())
|
||||
ctx := context.Background()
|
||||
q := newQueue(t, m)
|
||||
|
||||
id, err := m.ResolveQueue(ctx, q.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, q, id)
|
||||
for _, header := range []string{"", "not-a-uuid", uuid.NewString()} {
|
||||
id, err := m.ResolveQueue(ctx, header)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, uuid.Nil, id, header)
|
||||
assert.NotEqual(t, q, id, header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMusicServiceRemovedSongInQueue(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
ctx := context.Background()
|
||||
q := newQueue(t, m)
|
||||
queueSong(t, m, q)
|
||||
require.NoError(t, store.ClearSongs(ctx))
|
||||
|
||||
_, err := m.Song(ctx, q, 0)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
info, err := m.SongInfo(ctx, q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Game A", info.Soundtrack, "the stored names are kept")
|
||||
assert.Equal(t, "Song 1", info.Song)
|
||||
assert.NoError(t, m.AddLatestPlayed(ctx, q), "removed songs are not counted")
|
||||
}
|
||||
|
||||
func TestMusicServiceAddLatestToQueueWithoutFetch(t *testing.T) {
|
||||
m := NewMusicService(fakedb.New(), t.TempDir())
|
||||
m.AddLatestToQueue()
|
||||
_, err := m.NextSong()
|
||||
q := newQueue(t, m)
|
||||
require.NoError(t, m.AddLatestToQueue(context.Background(), q))
|
||||
_, err := m.NextSong(context.Background(), q)
|
||||
assert.ErrorIs(t, err, ErrQueueEmpty)
|
||||
}
|
||||
|
||||
func TestMusicServiceAddLatestToQueueOnlyOnce(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
q := newQueue(t, m)
|
||||
queueSong(t, m, q)
|
||||
require.NoError(t, m.AddLatestToQueue(context.Background(), q))
|
||||
|
||||
played, err := m.PlayedSongs(context.Background(), q)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, played, 1)
|
||||
}
|
||||
|
||||
func TestMusicServiceMarkPlayed(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
ctx := context.Background()
|
||||
q := newQueue(t, m)
|
||||
queueSong(t, m, q)
|
||||
|
||||
_, err := m.RandomSong(ctx)
|
||||
require.NoError(t, err)
|
||||
m.AddLatestToQueue()
|
||||
|
||||
require.NoError(t, m.AddLatestPlayed(ctx))
|
||||
require.NoError(t, m.SetPlayed(ctx, 0))
|
||||
require.NoError(t, m.SetPlayed(ctx, 5), "out of range is ignored")
|
||||
require.NoError(t, m.AddLatestPlayed(ctx, q))
|
||||
require.NoError(t, m.SetPlayed(ctx, q, 0))
|
||||
require.NoError(t, m.SetPlayed(ctx, q, 5), "out of range is ignored")
|
||||
|
||||
updated, _ := store.Soundtrack("Game A")
|
||||
assert.Equal(t, int32(2), updated.TimesPlayed)
|
||||
@@ -151,9 +292,10 @@ func TestMusicServiceRemovesBrokenSongs(t *testing.T) {
|
||||
writeFile(t, st.Path+wav, "wav")
|
||||
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav})
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
q := newQueue(t, m)
|
||||
|
||||
for range 20 {
|
||||
path, err := m.RandomSong(context.Background())
|
||||
path, err := m.RandomSong(context.Background(), q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, st.Path+"Good.mp3", path)
|
||||
}
|
||||
@@ -168,9 +310,10 @@ func TestMusicServiceRandomSongClassic(t *testing.T) {
|
||||
missing := "Missing.mp3"
|
||||
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing})
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
q := newQueue(t, m)
|
||||
|
||||
for range 20 {
|
||||
path, err := m.RandomSongClassic(context.Background())
|
||||
path, err := m.RandomSongClassic(context.Background(), q)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, st.Path+"Good.mp3", path)
|
||||
}
|
||||
@@ -222,3 +365,29 @@ func TestSoundCheckSong(t *testing.T) {
|
||||
_, err = NewMusicService(fakedb.New(), filepath.Join(dir, "missing")).SoundCheckSong()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func songPathByUUID(store *fakedb.Store, id uuid.UUID) string {
|
||||
for _, song := range store.Songs {
|
||||
if song.Uuid == id {
|
||||
return song.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestMarkPlayedUsesSongUUID(t *testing.T) {
|
||||
// Two songs with the same name in one soundtrack: only the played one is counted.
|
||||
store := fakedb.New()
|
||||
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
|
||||
fileName := "Song (copy).mp3"
|
||||
writeFile(t, st.Path+fileName, "copy")
|
||||
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Song", Path: st.Path + fileName, FileName: &fileName})
|
||||
m := NewMusicService(store, t.TempDir())
|
||||
q := newQueue(t, m)
|
||||
queueSong(t, m, q)
|
||||
require.NoError(t, m.AddLatestPlayed(context.Background(), q))
|
||||
|
||||
songs := store.SongsOf(st.ID)
|
||||
require.Len(t, songs, 2)
|
||||
assert.ElementsMatch(t, []int32{0, 1}, []int32{songs[0].TimesPlayed, songs[1].TimesPlayed})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build integration
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/pgtest"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestQueueWithPostgres checks the queue queries against a real database.
|
||||
func TestQueueWithPostgres(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := pgtest.NewMigratedDatabase(t)
|
||||
q := repository.New(database.Pool)
|
||||
s := NewSyncService(q, copyTestMusic(t), nil)
|
||||
require.True(t, s.Start(false))
|
||||
s.Wait()
|
||||
|
||||
m := NewMusicService(q, t.TempDir())
|
||||
queueID, err := m.ResolveQueue(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, m.AddLatestToQueue(ctx, queueID), "nothing fetched yet")
|
||||
var paths []string
|
||||
for range 3 {
|
||||
path, err := m.RandomSong(ctx, queueID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, m.AddLatestToQueue(ctx, queueID))
|
||||
paths = append(paths, path)
|
||||
}
|
||||
require.NoError(t, m.AddLatestToQueue(ctx, queueID), "a fetched song is only added once")
|
||||
|
||||
played, err := m.PlayedSongs(ctx, queueID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, played, 3)
|
||||
for i, song := range played {
|
||||
assert.Equal(t, i, song.SongNo)
|
||||
assert.Equal(t, i == 2, song.CurrentlyPlaying, "the last added song is current")
|
||||
assert.NotEmpty(t, song.Soundtrack)
|
||||
assert.NotEqual(t, uuid.Nil, song.SoundtrackUuid)
|
||||
}
|
||||
|
||||
var modes []string
|
||||
rows, err := database.Pool.Query(ctx, "SELECT mode FROM queue_song WHERE queue_uuid = $1 ORDER BY song_no", queueID)
|
||||
require.NoError(t, err)
|
||||
for rows.Next() {
|
||||
var mode string
|
||||
require.NoError(t, rows.Scan(&mode))
|
||||
modes = append(modes, mode)
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
assert.Equal(t, []string{"random", "random", "random"}, modes)
|
||||
|
||||
_, err = m.RandomSongClassic(ctx, queueID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, m.AddLatestToQueue(ctx, queueID))
|
||||
var classic string
|
||||
require.NoError(t, database.Pool.QueryRow(ctx, "SELECT mode FROM queue_song WHERE queue_uuid = $1 AND song_no = 3", queueID).Scan(&classic))
|
||||
assert.Equal(t, "classic", classic)
|
||||
played, err = m.PlayedSongs(ctx, queueID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, played, 4)
|
||||
|
||||
path, err := m.Song(ctx, queueID, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, paths[2], path)
|
||||
path, err = m.PreviousSong(ctx, queueID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, paths[1], path)
|
||||
require.NoError(t, m.AddLatestPlayed(ctx, queueID))
|
||||
info, err := m.SongInfo(ctx, queueID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, info.SongNo)
|
||||
assert.Equal(t, int32(1), info.SongPlayed)
|
||||
|
||||
// A new service, as after a restart, sees the same queue.
|
||||
restarted := NewMusicService(q, t.TempDir())
|
||||
same, err := restarted.ResolveQueue(ctx, queueID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, queueID, same)
|
||||
|
||||
newID, err := m.NewQueue(ctx, queueID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, queueID, newID)
|
||||
resolved, err := m.ResolveQueue(ctx, queueID.String())
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, queueID, resolved, "an ended queue is not used again")
|
||||
|
||||
other, err := m.ResolveQueue(ctx, "")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, m.EndAllQueues(ctx))
|
||||
var active int
|
||||
require.NoError(t, database.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM queue WHERE ended_at IS NULL").Scan(&active))
|
||||
assert.Zero(t, active)
|
||||
_, err = m.ResolveQueue(ctx, other.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ended queues keep their songs, also after the songs are removed from the library.
|
||||
require.NoError(t, q.ClearSongs(ctx))
|
||||
played, err = m.PlayedSongs(ctx, queueID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, played, 4)
|
||||
assert.NotEmpty(t, played[0].Song)
|
||||
_, err = m.Song(ctx, queueID, 0)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
@@ -52,6 +52,26 @@ func TestReleaseService(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestReleaseServiceLinuxBinaryAndPackage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(giteaRelease{Id: 1, Name: "v1.9.0", Assets: []giteaAsset{
|
||||
{Name: "MusicPlayer_1.9.0.exe", DownloadUrl: "https://example.com/exe"},
|
||||
{Name: "MusicPlayer_1.9.0_linux.tar.gz", DownloadUrl: "https://example.com/package"},
|
||||
{Name: "MusicPlayer_1.9.0.x86_64", DownloadUrl: "https://example.com/binary"},
|
||||
}})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
r := NewReleaseService(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
binary, err := r.AssetURL(ctx, ".x86_64")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com/binary", binary)
|
||||
pkg, err := r.AssetURL(ctx, "_linux.tar.gz")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com/package", pkg)
|
||||
}
|
||||
|
||||
func TestReleaseServiceCachesRelease(t *testing.T) {
|
||||
server, calls := newGitea(t, http.StatusOK)
|
||||
r := NewReleaseService(server.URL)
|
||||
|
||||
@@ -6,23 +6,40 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SoundtrackData holds precomputed search data for a soundtrack
|
||||
type SoundtrackData struct {
|
||||
// SearchSoundtrack is a soundtrack's name and alternative names, as loaded for search.
|
||||
type SearchSoundtrack struct {
|
||||
Name string
|
||||
AlternativeNames []string
|
||||
}
|
||||
|
||||
// SearchName holds a name prepared for matching.
|
||||
type SearchName struct {
|
||||
Original string
|
||||
Lowercase string
|
||||
Cleaned string
|
||||
}
|
||||
|
||||
// SearchCache holds precomputed search data for all soundtrack names.
|
||||
func newSearchName(name string) SearchName {
|
||||
return SearchName{Original: name, Lowercase: strings.ToLower(name), Cleaned: CleanSearchTerm(name)}
|
||||
}
|
||||
|
||||
// SoundtrackData holds precomputed search data for a soundtrack: its name and its
|
||||
// alternative names.
|
||||
type SoundtrackData struct {
|
||||
SearchName
|
||||
Alternatives []SearchName
|
||||
}
|
||||
|
||||
// SearchCache holds precomputed search data for all soundtracks.
|
||||
type SearchCache struct {
|
||||
load func(context.Context) ([]string, error)
|
||||
load func(context.Context) ([]SearchSoundtrack, error)
|
||||
|
||||
mu sync.Mutex
|
||||
cache []SoundtrackData
|
||||
}
|
||||
|
||||
// NewSearchCache creates a SearchCache that gets the soundtrack names from load.
|
||||
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache {
|
||||
// NewSearchCache creates a SearchCache that gets the soundtracks from load.
|
||||
func NewSearchCache(load func(context.Context) ([]SearchSoundtrack, error)) *SearchCache {
|
||||
return &SearchCache{load: load}
|
||||
}
|
||||
|
||||
@@ -52,10 +69,9 @@ func (s *SearchCache) reload(ctx context.Context) error {
|
||||
}
|
||||
cache := make([]SoundtrackData, len(soundtracks))
|
||||
for i, soundtrack := range soundtracks {
|
||||
cache[i] = SoundtrackData{
|
||||
Original: soundtrack,
|
||||
Lowercase: strings.ToLower(soundtrack),
|
||||
Cleaned: CleanSearchTerm(soundtrack),
|
||||
cache[i] = SoundtrackData{SearchName: newSearchName(soundtrack.Name)}
|
||||
for _, name := range soundtrack.AlternativeNames {
|
||||
cache[i].Alternatives = append(cache[i].Alternatives, newSearchName(name))
|
||||
}
|
||||
}
|
||||
s.cache = cache
|
||||
|
||||
@@ -40,28 +40,31 @@ func TestMatchesSubsequence(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchCache(t *testing.T) {
|
||||
names := []string{"Final Fantasy VII"}
|
||||
soundtracks := []SearchSoundtrack{{Name: "Final Fantasy VII", AlternativeNames: []string{"FF 7"}}}
|
||||
calls := 0
|
||||
cache := NewSearchCache(func(context.Context) ([]string, error) {
|
||||
cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) {
|
||||
calls++
|
||||
return names, nil
|
||||
return soundtracks, nil
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
data, err := cache.Get(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []SoundtrackData{{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"}}, data)
|
||||
assert.Equal(t, []SoundtrackData{{
|
||||
SearchName: SearchName{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"},
|
||||
Alternatives: []SearchName{{Original: "FF 7", Lowercase: "ff 7", Cleaned: "ff7"}},
|
||||
}}, data)
|
||||
_, _ = cache.Get(ctx)
|
||||
assert.Equal(t, 1, calls, "data is cached")
|
||||
|
||||
names = []string{"Chrono Trigger"}
|
||||
soundtracks = []SearchSoundtrack{{Name: "Chrono Trigger"}}
|
||||
require.NoError(t, cache.Reload(ctx))
|
||||
data, _ = cache.Get(ctx)
|
||||
assert.Equal(t, "Chrono Trigger", data[0].Original)
|
||||
}
|
||||
|
||||
func TestSearchCacheLoadError(t *testing.T) {
|
||||
cache := NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") })
|
||||
cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) { return nil, errors.New("db down") })
|
||||
_, err := cache.Get(context.Background())
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -6,20 +6,28 @@ import (
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
|
||||
type SoundtrackWithSongs struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
|
||||
CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."`
|
||||
Songs []SongInfoForStats `json:"songs"`
|
||||
}
|
||||
|
||||
// SongInfoForStats represents a song with soundtrack info for statistics
|
||||
// SongInfoForStats represents a song with soundtrack info for statistics.
|
||||
// The field order matches the generated query rows so they can be converted directly.
|
||||
type SongInfoForStats struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SongUuid uuid.UUID `json:"song_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
@@ -53,21 +61,25 @@ type soundtrackRow interface {
|
||||
repository.GetMostPlayedSoundtracksWithSongsRow |
|
||||
repository.GetLeastPlayedSoundtracksWithSongsRow |
|
||||
repository.GetLastPlayedSoundtracksRow |
|
||||
repository.GetOldestPlayedSoundtracksRow
|
||||
repository.GetOldestPlayedSoundtracksRow |
|
||||
repository.GetAllSoundtracksWithSongsRow
|
||||
}
|
||||
|
||||
func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []SoundtrackWithSongs
|
||||
result := []SoundtrackWithSongs{}
|
||||
for _, r := range rows {
|
||||
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackUuid: row.SoundtrackUuid,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
AlternativeNames: nonNil(row.AlternativeNames),
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
CoverModified: unixSeconds(row.CoverModified),
|
||||
Songs: parseSongs(row.Songs),
|
||||
})
|
||||
}
|
||||
@@ -82,7 +94,7 @@ func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []SongInfoForStats
|
||||
result := []SongInfoForStats{}
|
||||
for _, r := range rows {
|
||||
result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r)))
|
||||
}
|
||||
@@ -99,6 +111,11 @@ func parseSongs(data []byte) []SongInfoForStats {
|
||||
return songs
|
||||
}
|
||||
|
||||
// AllSoundtracks returns every soundtrack with its songs, sorted by name
|
||||
func (s *StatisticsService) AllSoundtracks(ctx context.Context) ([]SoundtrackWithSongs, error) {
|
||||
return toSoundtracks(s.q.GetAllSoundtracksWithSongs(ctx))
|
||||
}
|
||||
|
||||
// MostPlayedSoundtracks returns the top N most played soundtracks with their songs
|
||||
func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
|
||||
return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit))
|
||||
@@ -125,12 +142,15 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []SoundtrackWithSongs
|
||||
result := []SoundtrackWithSongs{}
|
||||
for _, row := range rows {
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackUuid: row.SoundtrackUuid,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
AlternativeNames: nonNil(row.AlternativeNames),
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
CoverModified: unixSeconds(row.CoverModified),
|
||||
Songs: parseSongs(row.Songs),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/metrics"
|
||||
|
||||
"github.com/MShekow/directory-checksum/directory_checksum"
|
||||
"github.com/google/uuid"
|
||||
@@ -34,8 +35,15 @@ const (
|
||||
brokenWorkers = 50
|
||||
)
|
||||
|
||||
// foldersToSkip are directories in the music path that are never synced.
|
||||
var foldersToSkip = map[string]struct{}{".sync": {}, "characters": {}, "dist": {}, "old": {}}
|
||||
// foldersToSkip are directories in the music path that are never synced, besides hidden
|
||||
// ones like .sync and .uploads.
|
||||
var foldersToSkip = map[string]struct{}{"characters": {}, "dist": {}, "old": {}}
|
||||
|
||||
// skipFolder reports whether a directory in the music path is not a soundtrack.
|
||||
func skipFolder(name string) bool {
|
||||
_, skip := foldersToSkip[name]
|
||||
return skip || strings.HasPrefix(name, ".")
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
SoundtracksAdded []string `json:"soundtracks_added"`
|
||||
@@ -116,6 +124,37 @@ func (s *SyncService) Start(full bool) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// SyncFolder runs prepare and then syncs the soundtrack folder name, while no other sync
|
||||
// can run. It returns ErrSyncing when a sync is running. prepare can put the folder in
|
||||
// place; when it fails, nothing is synced. onComplete is not called, as only one
|
||||
// soundtrack changes.
|
||||
func (s *SyncService) SyncFolder(ctx context.Context, name string, prepare func() error) (repository.Soundtrack, error) {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
return repository.Soundtrack{}, ErrSyncing
|
||||
}
|
||||
defer s.running.Store(false)
|
||||
|
||||
existing, err := s.q.GetAllSoundtracksIncludingDeleted(ctx)
|
||||
if err != nil {
|
||||
return repository.Soundtrack{}, err
|
||||
}
|
||||
if err := prepare(); err != nil {
|
||||
return repository.Soundtrack{}, err
|
||||
}
|
||||
run := &syncRun{q: s.q, musicPath: s.musicPath, existing: existing, progress: func() {}}
|
||||
run.syncSoundtrack(ctx, name)
|
||||
if len(run.errs) > 0 {
|
||||
return repository.Soundtrack{}, fmt.Errorf("syncing %q: %s", name, strings.Join(run.errs, "; "))
|
||||
}
|
||||
if len(run.foundIDs) != 1 {
|
||||
return repository.Soundtrack{}, fmt.Errorf("syncing %q found %d soundtracks", name, len(run.foundIDs))
|
||||
}
|
||||
if err := s.UpdateLibraryMetrics(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to count the library", zap.Error(err))
|
||||
}
|
||||
return s.q.GetSoundtrackById(ctx, run.foundIDs[0])
|
||||
}
|
||||
|
||||
// Wait blocks until the running sync, if any, has finished.
|
||||
func (s *SyncService) Wait() {
|
||||
s.wg.Wait()
|
||||
@@ -136,11 +175,23 @@ func (s *SyncService) Progress() ProgressResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// Result returns the result of the last finished sync.
|
||||
// Result returns the result of the last finished sync. Lists are empty, not nil, before the first sync.
|
||||
func (s *SyncService) Result() SyncResponse {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.result
|
||||
return s.result.withEmptyLists()
|
||||
}
|
||||
|
||||
func (r SyncResponse) withEmptyLists() SyncResponse {
|
||||
for _, list := range []*[]string{&r.SoundtracksAdded, &r.SoundtracksReAdded, &r.SoundtracksChangedContent, &r.SoundtracksRemoved, &r.CatchedErrors} {
|
||||
if *list == nil {
|
||||
*list = []string{}
|
||||
}
|
||||
}
|
||||
if r.SoundtracksChangedTitle == nil {
|
||||
r.SoundtracksChangedTitle = map[string]string{}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ResetDB deletes all songs and soundtracks.
|
||||
@@ -187,6 +238,7 @@ func (s *SyncService) run(ctx context.Context, full bool) {
|
||||
run := &syncRun{q: s.q, musicPath: s.musicPath, full: full, progress: func() { s.foldersSynced.Add(1) }}
|
||||
result := run.execute(ctx, func(total int) { s.foldersTotal.Store(int64(total)) })
|
||||
result.TotalTime = formatDuration(time.Since(start))
|
||||
s.recordMetrics(ctx, full, time.Since(start), len(result.CatchedErrors) == 0)
|
||||
|
||||
if s.onComplete != nil {
|
||||
s.onComplete(ctx)
|
||||
@@ -206,6 +258,42 @@ func (s *SyncService) run(ctx context.Context, full bool) {
|
||||
zap.Int("errors", len(result.CatchedErrors)))
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Start both sync types at 0, so the series exist before the first sync.
|
||||
metrics.SyncDuration.WithLabelValues(syncType(false))
|
||||
metrics.SyncDuration.WithLabelValues(syncType(true))
|
||||
}
|
||||
|
||||
// syncType is the type label of the sync metrics.
|
||||
func syncType(full bool) string {
|
||||
if full {
|
||||
return "full"
|
||||
}
|
||||
return "changes"
|
||||
}
|
||||
|
||||
// recordMetrics updates the sync and library metrics after a sync.
|
||||
func (s *SyncService) recordMetrics(ctx context.Context, full bool, took time.Duration, success bool) {
|
||||
metrics.SyncDuration.WithLabelValues(syncType(full)).Observe(took.Seconds())
|
||||
if success {
|
||||
metrics.SyncLastSuccess.SetToCurrentTime()
|
||||
}
|
||||
if err := s.UpdateLibraryMetrics(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to count the library", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLibraryMetrics sets the library size metrics from the database.
|
||||
func (s *SyncService) UpdateLibraryMetrics(ctx context.Context) error {
|
||||
count, err := s.q.CountLibrary(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metrics.LibrarySoundtracks.Set(float64(count.Soundtracks))
|
||||
metrics.LibrarySongs.Set(float64(count.Songs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *syncRun) execute(ctx context.Context, setTotal func(int)) SyncResponse {
|
||||
var err error
|
||||
r.existing, err = r.q.GetAllSoundtracksIncludingDeleted(ctx)
|
||||
@@ -226,7 +314,7 @@ func (r *syncRun) execute(ctx context.Context, setTotal func(int)) SyncResponse
|
||||
for _, dir := range directories {
|
||||
g.Go(func() error {
|
||||
defer r.progress()
|
||||
if _, skip := foldersToSkip[dir.Name()]; dir.IsDir() && !skip {
|
||||
if dir.IsDir() && !skipFolder(dir.Name()) {
|
||||
r.syncSoundtrack(ctx, dir.Name())
|
||||
}
|
||||
return nil
|
||||
@@ -611,6 +699,7 @@ func (r *syncRun) removeBrokenSongs(ctx context.Context) {
|
||||
}
|
||||
_ = g.Wait()
|
||||
|
||||
metrics.BrokenSongs.Set(float64(len(broken)))
|
||||
if len(broken) > 0 {
|
||||
if err := r.q.RemoveBrokenSongs(ctx, broken); err != nil {
|
||||
r.addError("RemoveBrokenSongs", err, "")
|
||||
|
||||
@@ -57,7 +57,11 @@ func TestSyncWithPostgres(t *testing.T) {
|
||||
|
||||
// Play a song, then rename one of its files and, in a later sync, the soundtrack folder.
|
||||
// (Renaming both at once changes the content hash, so the folder would count as new.)
|
||||
require.NoError(t, q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: bird.ID, SongName: "2. Yesterday"}))
|
||||
for _, song := range songs {
|
||||
if song.SongName == "2. Yesterday" {
|
||||
require.NoError(t, q.AddSongPlayedByUUID(ctx, song.Uuid))
|
||||
}
|
||||
}
|
||||
birdDir := filepath.Join(musicDir, "A Bird Story")
|
||||
require.NoError(t, os.Rename(filepath.Join(birdDir, "2. Yesterday.mp3"), filepath.Join(birdDir, "02 Yesterday.mp3")))
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ func TestSyncDetectsRenamedSoundtrack(t *testing.T) {
|
||||
f.write("Game A/01.mp3", "a1")
|
||||
f.run(false)
|
||||
a := f.soundtrack("Game A")
|
||||
require.NoError(t, f.store.AddSongPlayed(context.Background(), repository.AddSongPlayedParams{SoundtrackID: a.ID, SongName: "01"}))
|
||||
require.NoError(t, f.store.AddSongPlayedByUUID(context.Background(), f.store.SongsOf(a.ID)[0].Uuid))
|
||||
|
||||
require.NoError(t, os.Rename(filepath.Join(f.dir, "Game A"), filepath.Join(f.dir, "Game A Remastered")))
|
||||
result := f.run(false)
|
||||
@@ -371,7 +371,14 @@ func TestSyncStartOnlyOnce(t *testing.T) {
|
||||
func TestSyncProgressBeforeFirstSync(t *testing.T) {
|
||||
s := NewSyncService(fakedb.New(), t.TempDir(), nil)
|
||||
assert.Equal(t, "0", s.Progress().Progress)
|
||||
assert.Equal(t, SyncResponse{}, s.Result())
|
||||
assert.Equal(t, SyncResponse{
|
||||
SoundtracksAdded: []string{},
|
||||
SoundtracksReAdded: []string{},
|
||||
SoundtracksChangedTitle: map[string]string{},
|
||||
SoundtracksChangedContent: []string{},
|
||||
SoundtracksRemoved: []string{},
|
||||
CatchedErrors: []string{},
|
||||
}, s.Result(), "lists are empty, not null, so the JSON matches the API schema")
|
||||
}
|
||||
|
||||
func TestSyncCallsOnCompleteBeforeFinishing(t *testing.T) {
|
||||
@@ -396,3 +403,34 @@ func TestResetDB(t *testing.T) {
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second))
|
||||
}
|
||||
|
||||
func TestSyncKeepsAlternativeNamesAndCover(t *testing.T) {
|
||||
f := newSyncFixture(t)
|
||||
f.write("Final Fantasy 7/01.mp3", "a1")
|
||||
f.run(false)
|
||||
st := f.soundtrack("Final Fantasy 7")
|
||||
_, err := f.store.SetAlternativeNames(context.Background(),
|
||||
repository.SetAlternativeNamesParams{Uuid: st.Uuid, Names: []string{"FF7"}})
|
||||
require.NoError(t, err)
|
||||
file, source := "cover.jpg", "upload"
|
||||
require.NoError(t, f.store.SetCover(context.Background(),
|
||||
repository.SetCoverParams{Uuid: st.Uuid, File: &file, Source: &source, Manual: true}))
|
||||
|
||||
// Renamed: the same row is kept.
|
||||
require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy 7"), filepath.Join(f.dir, "Final Fantasy VII")))
|
||||
f.run(false)
|
||||
renamed := f.soundtrack("Final Fantasy VII")
|
||||
assert.Equal(t, st.Uuid, renamed.Uuid)
|
||||
assert.Equal(t, []string{"FF7"}, renamed.AlternativeNames)
|
||||
assert.Equal(t, &file, renamed.CoverFile)
|
||||
|
||||
// Removed and added again.
|
||||
require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy VII"), filepath.Join(t.TempDir(), "away")))
|
||||
f.run(false)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(f.dir, "Final Fantasy VII"), 0o755))
|
||||
f.write("Final Fantasy VII/01.mp3", "a1")
|
||||
f.run(false)
|
||||
readded := f.soundtrack("Final Fantasy VII")
|
||||
assert.Nil(t, readded.Deleted)
|
||||
assert.Equal(t, []string{"FF7"}, readded.AlternativeNames)
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// uploadsFolder is where unfinished uploads are kept, inside the music folder. The
|
||||
// sync skips it, like every hidden folder.
|
||||
uploadsFolder = ".uploads"
|
||||
// uploadMaxAge is how long an unfinished upload is kept.
|
||||
uploadMaxAge = 24 * time.Hour
|
||||
// maxUploadNameLength is the longest soundtrack or file name, in bytes.
|
||||
maxUploadNameLength = 200
|
||||
)
|
||||
|
||||
// Upload is an unfinished soundtrack upload.
|
||||
type Upload struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name" doc:"Name of the new soundtrack, also its folder name"`
|
||||
Started time.Time `json:"started"`
|
||||
Files []UploadFile `json:"files" doc:"The songs received so far, sorted by name"`
|
||||
TotalSize int64 `json:"total_size" doc:"Size of all songs received so far, in bytes"`
|
||||
}
|
||||
|
||||
// UploadFile is a song received for an upload.
|
||||
type UploadFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// uploadInfo is stored next to an upload's folder, as <id>.json.
|
||||
type uploadInfo struct {
|
||||
Name string `json:"name"`
|
||||
Started time.Time `json:"started"`
|
||||
}
|
||||
|
||||
// UploadService adds new soundtracks from uploaded songs. An upload is started with a name,
|
||||
// gets one song per request, and is finished by moving its folder into the music folder
|
||||
// and syncing it.
|
||||
type UploadService struct {
|
||||
q repository.Querier
|
||||
musicPath string
|
||||
sync *SyncService
|
||||
library *LibraryService
|
||||
// MaxFileSize and MaxTotalSize limit a song and a whole upload, in bytes.
|
||||
MaxFileSize int64
|
||||
MaxTotalSize int64
|
||||
now func() time.Time
|
||||
|
||||
// mu keeps requests for the same upload, and the name checks, from interleaving.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewUploadService creates an UploadService for the music folder.
|
||||
func NewUploadService(q repository.Querier, musicPath string, syncService *SyncService, library *LibraryService,
|
||||
maxFileSize, maxTotalSize int64) *UploadService {
|
||||
return &UploadService{
|
||||
q: q, musicPath: musicPath, sync: syncService, library: library,
|
||||
MaxFileSize: maxFileSize, MaxTotalSize: maxTotalSize, now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UploadService) uploadsDir() string {
|
||||
return filepath.Join(u.musicPath, uploadsFolder)
|
||||
}
|
||||
|
||||
func (u *UploadService) dir(id uuid.UUID) string {
|
||||
return filepath.Join(u.uploadsDir(), id.String())
|
||||
}
|
||||
|
||||
func (u *UploadService) infoPath(id uuid.UUID) string {
|
||||
return filepath.Join(u.uploadsDir(), id.String()+".json")
|
||||
}
|
||||
|
||||
// Start begins an upload of a new soundtrack called name. The name must be usable as a
|
||||
// folder name, and no soundtrack, folder or other upload may have it.
|
||||
func (u *UploadService) Start(ctx context.Context, name string) (Upload, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if err := checkUploadName(name, "soundtrack"); err != nil {
|
||||
return Upload{}, err
|
||||
}
|
||||
if skipFolder(name) {
|
||||
return Upload{}, fmt.Errorf("%w: %q is used for other things in the music folder", ErrInvalidName, name)
|
||||
}
|
||||
if u.sync.IsSyncing() {
|
||||
return Upload{}, ErrSyncing
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.removeOld()
|
||||
if err := os.MkdirAll(u.uploadsDir(), 0o755); err != nil {
|
||||
logging.GetLogger().Error("Failed to create the uploads folder", zap.Error(err))
|
||||
return Upload{}, ErrUploadsUnavailable
|
||||
}
|
||||
if err := u.checkNameFree(ctx, name, uuid.Nil); err != nil {
|
||||
return Upload{}, err
|
||||
}
|
||||
|
||||
upload := Upload{ID: uuid.New(), Name: name, Started: u.now(), Files: []UploadFile{}}
|
||||
if err := os.Mkdir(u.dir(upload.ID), 0o755); err != nil {
|
||||
return Upload{}, err
|
||||
}
|
||||
info, err := json.Marshal(uploadInfo{Name: name, Started: upload.Started})
|
||||
if err == nil {
|
||||
err = os.WriteFile(u.infoPath(upload.ID), info, 0o644)
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(u.dir(upload.ID))
|
||||
return Upload{}, err
|
||||
}
|
||||
logging.GetLogger().Info("Soundtrack upload started", zap.String("name", name), zap.Stringer("id", upload.ID))
|
||||
return upload, nil
|
||||
}
|
||||
|
||||
// checkNameFree returns ErrConflict when an active soundtrack, a folder in the music folder
|
||||
// or an upload other than except already has name, ignoring case. Call with mu held.
|
||||
func (u *UploadService) checkNameFree(ctx context.Context, name string, except uuid.UUID) error {
|
||||
soundtracks, err := u.q.FindAllSoundtracks(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range soundtracks {
|
||||
if strings.EqualFold(s.SoundtrackName, name) {
|
||||
return fmt.Errorf("%w: a soundtrack called %q", ErrConflict, s.SoundtrackName)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(u.musicPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.EqualFold(entry.Name(), name) {
|
||||
return fmt.Errorf("%w: a folder called %q in the music folder", ErrConflict, entry.Name())
|
||||
}
|
||||
}
|
||||
uploads, err := u.list()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, other := range uploads {
|
||||
if other.id != except && strings.EqualFold(other.info.Name, name) {
|
||||
return fmt.Errorf("%w: an unfinished upload of %q", ErrConflict, other.info.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUploadName checks that name can be used as a file or folder name on every system.
|
||||
func checkUploadName(name, what string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return fmt.Errorf("%w: the %s name is empty", ErrInvalidName, what)
|
||||
case len(name) > maxUploadNameLength:
|
||||
return fmt.Errorf("%w: the %s name is longer than %d bytes", ErrInvalidName, what, maxUploadNameLength)
|
||||
case !utf8.ValidString(name):
|
||||
return fmt.Errorf("%w: the %s name is not valid UTF-8", ErrInvalidName, what)
|
||||
case strings.HasPrefix(name, "."):
|
||||
return fmt.Errorf("%w: the %s name cannot start with a dot", ErrInvalidName, what)
|
||||
case strings.TrimRight(name, ". ") != name:
|
||||
return fmt.Errorf("%w: the %s name cannot end with a dot or a space", ErrInvalidName, what)
|
||||
case strings.ContainsAny(name, `/\:*?"<>|`):
|
||||
return fmt.Errorf(`%w: the %s name cannot contain / \ : * ? " < > |`, ErrInvalidName, what)
|
||||
case strings.ContainsFunc(name, unicode.IsControl):
|
||||
return fmt.Errorf("%w: the %s name cannot contain control characters", ErrInvalidName, what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type storedUpload struct {
|
||||
id uuid.UUID
|
||||
info uploadInfo
|
||||
}
|
||||
|
||||
// list returns all unfinished uploads. Call with mu held.
|
||||
func (u *UploadService) list() ([]storedUpload, error) {
|
||||
entries, err := os.ReadDir(u.uploadsDir())
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var uploads []storedUpload
|
||||
for _, entry := range entries {
|
||||
idText, ok := strings.CutSuffix(entry.Name(), ".json")
|
||||
id, err := uuid.Parse(idText)
|
||||
if !ok || err != nil {
|
||||
continue
|
||||
}
|
||||
info, err := u.readInfo(id)
|
||||
if err == nil {
|
||||
uploads = append(uploads, storedUpload{id: id, info: info})
|
||||
}
|
||||
}
|
||||
return uploads, nil
|
||||
}
|
||||
|
||||
func (u *UploadService) readInfo(id uuid.UUID) (uploadInfo, error) {
|
||||
data, err := os.ReadFile(u.infoPath(id))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return uploadInfo{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return uploadInfo{}, err
|
||||
}
|
||||
var info uploadInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return uploadInfo{}, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Get returns an unfinished upload with the songs received so far.
|
||||
func (u *UploadService) Get(id uuid.UUID) (Upload, error) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return u.get(id)
|
||||
}
|
||||
|
||||
func (u *UploadService) get(id uuid.UUID) (Upload, error) {
|
||||
info, err := u.readInfo(id)
|
||||
if err != nil {
|
||||
return Upload{}, err
|
||||
}
|
||||
upload := Upload{ID: id, Name: info.Name, Started: info.Started, Files: []UploadFile{}}
|
||||
entries, err := os.ReadDir(u.dir(id))
|
||||
if err != nil {
|
||||
return Upload{}, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !isSong(entry) {
|
||||
continue
|
||||
}
|
||||
fileInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
return Upload{}, err
|
||||
}
|
||||
upload.Files = append(upload.Files, UploadFile{Name: entry.Name(), Size: fileInfo.Size()})
|
||||
upload.TotalSize += fileInfo.Size()
|
||||
}
|
||||
return upload, nil
|
||||
}
|
||||
|
||||
// PutFile stores data as the song fileName of an upload, replacing a song of the same name.
|
||||
// Only MP3 files are accepted, like the sync.
|
||||
func (u *UploadService) PutFile(id uuid.UUID, fileName string, data []byte) (UploadFile, error) {
|
||||
if err := checkUploadName(fileName, "file"); err != nil {
|
||||
return UploadFile{}, err
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(fileName), ".mp3") {
|
||||
return UploadFile{}, fmt.Errorf("%w: only .mp3 files can be uploaded", ErrInvalidName)
|
||||
}
|
||||
if int64(len(data)) > u.MaxFileSize {
|
||||
return UploadFile{}, fmt.Errorf("%w: a song can be at most %d MB", ErrTooLarge, u.MaxFileSize>>20)
|
||||
}
|
||||
if !isMP3(data) {
|
||||
return UploadFile{}, fmt.Errorf("%w: %q is not an MP3 file", ErrInvalidName, fileName)
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
upload, err := u.get(id)
|
||||
if err != nil {
|
||||
return UploadFile{}, err
|
||||
}
|
||||
total := upload.TotalSize + int64(len(data))
|
||||
for _, f := range upload.Files {
|
||||
if f.Name == fileName {
|
||||
total -= f.Size // replaced
|
||||
}
|
||||
}
|
||||
if total > u.MaxTotalSize {
|
||||
return UploadFile{}, fmt.Errorf("%w: an upload can be at most %d MB", ErrTooLarge, u.MaxTotalSize>>20)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(u.dir(id), ".part-*")
|
||||
if err != nil {
|
||||
return UploadFile{}, err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return UploadFile{}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return UploadFile{}, err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), filepath.Join(u.dir(id), fileName)); err != nil {
|
||||
return UploadFile{}, err
|
||||
}
|
||||
return UploadFile{Name: fileName, Size: int64(len(data))}, nil
|
||||
}
|
||||
|
||||
// isMP3 reports whether data starts like an MP3 file: an ID3 tag or an MPEG audio frame.
|
||||
func isMP3(data []byte) bool {
|
||||
if bytes.HasPrefix(data, []byte("ID3")) {
|
||||
return true
|
||||
}
|
||||
return len(data) >= 2 && data[0] == 0xFF && data[1]&0xE0 == 0xE0
|
||||
}
|
||||
|
||||
// Finish moves the upload's folder into the music folder, adds the soundtrack to the
|
||||
// library and returns it with its songs.
|
||||
func (u *UploadService) Finish(ctx context.Context, id uuid.UUID) (SoundtrackWithSongList, error) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
upload, err := u.get(id)
|
||||
if err != nil {
|
||||
return SoundtrackWithSongList{}, err
|
||||
}
|
||||
if len(upload.Files) == 0 {
|
||||
return SoundtrackWithSongList{}, fmt.Errorf("%w: the upload has no songs", ErrInvalidName)
|
||||
}
|
||||
|
||||
target := filepath.Join(u.musicPath, upload.Name)
|
||||
soundtrack, err := u.sync.SyncFolder(ctx, upload.Name, func() error {
|
||||
if err := u.checkNameFree(ctx, upload.Name, upload.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.checkNotDuplicate(ctx, upload); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(u.dir(id), target)
|
||||
})
|
||||
if err != nil {
|
||||
return SoundtrackWithSongList{}, err
|
||||
}
|
||||
if err := os.Remove(u.infoPath(id)); err != nil {
|
||||
logging.GetLogger().Warn("Failed to remove upload info", zap.Error(err))
|
||||
}
|
||||
logging.GetLogger().Info("Soundtrack uploaded", zap.String("name", upload.Name),
|
||||
zap.Int("songs", len(upload.Files)), zap.Int64("bytes", upload.TotalSize))
|
||||
return u.library.Soundtrack(ctx, soundtrack.Uuid)
|
||||
}
|
||||
|
||||
// checkNotDuplicate refuses an upload with the same songs as an active soundtrack. The sync
|
||||
// would otherwise take it for that soundtrack renamed.
|
||||
func (u *UploadService) checkNotDuplicate(ctx context.Context, upload Upload) error {
|
||||
existing, err := u.q.GetAllSoundtracksIncludingDeleted(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := dirHash(u.dir(upload.ID) + "/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status, match := classify(existing, upload.Name, hash); status == TitleChanged && match.Deleted == nil {
|
||||
return fmt.Errorf("%w: soundtrack %q has the same songs", ErrConflict, match.SoundtrackName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel deletes an unfinished upload and its songs.
|
||||
func (u *UploadService) Cancel(id uuid.UUID) error {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if _, err := u.readInfo(id); err != nil {
|
||||
return err
|
||||
}
|
||||
return u.remove(id)
|
||||
}
|
||||
|
||||
func (u *UploadService) remove(id uuid.UUID) error {
|
||||
if err := os.RemoveAll(u.dir(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(u.infoPath(id))
|
||||
}
|
||||
|
||||
// RemoveOld deletes uploads that were started more than a day ago and never finished.
|
||||
func (u *UploadService) RemoveOld() {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.removeOld()
|
||||
}
|
||||
|
||||
func (u *UploadService) removeOld() {
|
||||
uploads, err := u.list()
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Failed to list uploads", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, upload := range uploads {
|
||||
if u.now().Sub(upload.info.Started) > uploadMaxAge {
|
||||
if err := u.remove(upload.id); err != nil {
|
||||
logging.GetLogger().Warn("Failed to remove an old upload", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
logging.GetLogger().Info("Removed unfinished upload", zap.String("name", upload.info.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//go:build integration
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/pgtest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestUploadWithPostgres uploads songs and checks the soundtrack in a real database.
|
||||
func TestUploadWithPostgres(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
|
||||
musicDir := t.TempDir()
|
||||
writeFile(t, filepath.Join(musicDir, "Original", "01 Song.mp3"), string(mp3("original one")))
|
||||
writeFile(t, filepath.Join(musicDir, "Original", "02 Song.mp3"), string(mp3("original two")))
|
||||
s := NewSyncService(q, musicDir, nil)
|
||||
require.True(t, s.Start(false))
|
||||
s.Wait()
|
||||
require.Empty(t, s.Result().CatchedErrors)
|
||||
library := NewLibraryService(q)
|
||||
uploads := NewUploadService(q, musicDir, s, library, 50<<20, 500<<20)
|
||||
|
||||
upload := func(name string, songs map[string]string) Upload {
|
||||
u, err := uploads.Start(ctx, name)
|
||||
require.NoError(t, err)
|
||||
for file, content := range songs {
|
||||
_, err := uploads.PutFile(u.ID, file, mp3(content))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// The songs of an existing soundtrack under a new name are refused.
|
||||
duplicate := upload("Copy", map[string]string{"01 Song.mp3": "original one", "02 Song.mp3": "original two"})
|
||||
_, err := uploads.Finish(ctx, duplicate.ID)
|
||||
assert.ErrorIs(t, err, ErrConflict)
|
||||
require.NoError(t, uploads.Cancel(duplicate.ID))
|
||||
|
||||
// The name of an existing soundtrack is refused.
|
||||
_, err = uploads.Start(ctx, "original")
|
||||
assert.ErrorIs(t, err, ErrConflict)
|
||||
|
||||
added := upload("Uploaded Soundtrack", map[string]string{"1-01 First.mp3": "first", "1-02 Second.mp3": "second"})
|
||||
soundtrack, err := uploads.Finish(ctx, added.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Uploaded Soundtrack", soundtrack.Name)
|
||||
require.Len(t, soundtrack.Songs, 2)
|
||||
assert.Equal(t, "1-01 First", soundtrack.Songs[0].Name)
|
||||
path, err := library.SongPath(ctx, soundtrack.Songs[0].Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.FileExists(t, path)
|
||||
|
||||
count, err := q.CountLibrary(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), count.Soundtracks)
|
||||
assert.Equal(t, int64(4), count.Songs)
|
||||
|
||||
// A sync afterwards changes nothing.
|
||||
require.True(t, s.Start(false))
|
||||
s.Wait()
|
||||
result := s.Result()
|
||||
assert.Empty(t, result.CatchedErrors)
|
||||
assert.Empty(t, result.SoundtracksAdded)
|
||||
assert.Empty(t, result.SoundtracksChangedTitle)
|
||||
assert.Empty(t, result.SoundtracksRemoved)
|
||||
again, err := library.Soundtrack(ctx, soundtrack.Uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, again.Songs, 2)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mp3 returns data that starts like an MP3 file, made unique by content.
|
||||
func mp3(content string) []byte {
|
||||
return append([]byte("ID3"), content...)
|
||||
}
|
||||
|
||||
type uploadFixture struct {
|
||||
t *testing.T
|
||||
musicDir string
|
||||
store *fakedb.Store
|
||||
sync *SyncService
|
||||
uploads *UploadService
|
||||
}
|
||||
|
||||
func newUploadFixture(t *testing.T) *uploadFixture {
|
||||
musicDir := t.TempDir()
|
||||
store := fakedb.New()
|
||||
syncService := NewSyncService(store, musicDir, nil)
|
||||
return &uploadFixture{
|
||||
t: t, musicDir: musicDir, store: store, sync: syncService,
|
||||
uploads: NewUploadService(store, musicDir, syncService, NewLibraryService(store), 1<<20, 3<<20),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *uploadFixture) start(name string) Upload {
|
||||
f.t.Helper()
|
||||
upload, err := f.uploads.Start(context.Background(), name)
|
||||
require.NoError(f.t, err)
|
||||
return upload
|
||||
}
|
||||
|
||||
func (f *uploadFixture) put(id uuid.UUID, fileName, content string) {
|
||||
f.t.Helper()
|
||||
_, err := f.uploads.PutFile(id, fileName, mp3(content))
|
||||
require.NoError(f.t, err)
|
||||
}
|
||||
|
||||
func TestUploadSoundtrack(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
upload := f.start(" Chrono Trigger ")
|
||||
assert.Equal(t, "Chrono Trigger", upload.Name, "the name is trimmed")
|
||||
f.put(upload.ID, "02 Corridors of Time.mp3", "b")
|
||||
f.put(upload.ID, "01 Wind Scene.MP3", "a")
|
||||
f.put(upload.ID, "02 Corridors of Time.mp3", "b2") // sent again
|
||||
|
||||
got, err := f.uploads.Get(upload.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []UploadFile{{Name: "01 Wind Scene.MP3", Size: 4}, {Name: "02 Corridors of Time.mp3", Size: 5}}, got.Files)
|
||||
assert.Equal(t, int64(9), got.TotalSize)
|
||||
_, err = f.store.GetSoundtrackById(ctx, 1)
|
||||
assert.Error(t, err, "nothing is in the library before finishing")
|
||||
|
||||
soundtrack, err := f.uploads.Finish(ctx, upload.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Chrono Trigger", soundtrack.Name)
|
||||
require.Len(t, soundtrack.Songs, 2)
|
||||
assert.Equal(t, "01 Wind Scene", soundtrack.Songs[0].Name)
|
||||
assert.FileExists(t, filepath.Join(f.musicDir, "Chrono Trigger", "01 Wind Scene.MP3"))
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(f.musicDir, uploadsFolder))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries, "nothing is left of the upload")
|
||||
_, err = f.uploads.Get(upload.ID)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// The next sync sees nothing new.
|
||||
require.True(t, f.sync.Start(false))
|
||||
f.sync.Wait()
|
||||
result := f.sync.Result()
|
||||
assert.Empty(t, result.SoundtracksAdded)
|
||||
assert.Empty(t, result.CatchedErrors)
|
||||
}
|
||||
|
||||
func TestUploadNames(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
ctx := context.Background()
|
||||
f.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Kingdom Hearts"})
|
||||
writeFile(t, filepath.Join(f.musicDir, "Folder Only", "x.txt"), "x")
|
||||
f.start("Pending")
|
||||
|
||||
for name, want := range map[string]error{
|
||||
"": ErrInvalidName,
|
||||
" ": ErrInvalidName,
|
||||
"a/b": ErrInvalidName,
|
||||
`a\b`: ErrInvalidName,
|
||||
"..": ErrInvalidName,
|
||||
".hidden": ErrInvalidName,
|
||||
"ends with dot.": ErrInvalidName,
|
||||
"what?": ErrInvalidName,
|
||||
"tab\tname": ErrInvalidName,
|
||||
"characters": ErrInvalidName,
|
||||
strings.Repeat("a", 201): ErrInvalidName,
|
||||
"kingdom hearts": ErrConflict,
|
||||
"FOLDER ONLY": ErrConflict,
|
||||
"pending": ErrConflict,
|
||||
} {
|
||||
_, err := f.uploads.Start(ctx, name)
|
||||
assert.ErrorIs(t, err, want, "%q", name)
|
||||
}
|
||||
_, err := f.uploads.Start(ctx, "Ys: Origin")
|
||||
assert.ErrorIs(t, err, ErrInvalidName, "':' is not allowed on Windows")
|
||||
_, err = f.uploads.Start(ctx, "Pokémon HeartGold & SoulSilver (DS)")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestUploadFileChecks(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
upload := f.start("Game")
|
||||
|
||||
for fileName, want := range map[string]error{
|
||||
"song.wav": ErrInvalidName,
|
||||
"song": ErrInvalidName,
|
||||
"Disc 1/01.mp3": ErrInvalidName,
|
||||
"../escape.mp3": ErrInvalidName,
|
||||
".hidden.mp3": ErrInvalidName,
|
||||
} {
|
||||
_, err := f.uploads.PutFile(upload.ID, fileName, mp3("x"))
|
||||
assert.ErrorIs(t, err, want, fileName)
|
||||
}
|
||||
_, err := f.uploads.PutFile(upload.ID, "text.mp3", []byte("just text"))
|
||||
assert.ErrorIs(t, err, ErrInvalidName, "not an MP3 file")
|
||||
_, err = f.uploads.PutFile(upload.ID, "frame.mp3", []byte{0xFF, 0xFB, 0x90, 0x00})
|
||||
assert.NoError(t, err, "an MP3 without an ID3 tag starts with a frame")
|
||||
_, err = f.uploads.PutFile(uuid.New(), "song.mp3", mp3("x"))
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
_, err = f.uploads.PutFile(upload.ID, "big.mp3", mp3(strings.Repeat("x", 1<<20)))
|
||||
assert.ErrorIs(t, err, ErrTooLarge, "over the size of one song")
|
||||
for i := range 3 {
|
||||
f.put(upload.ID, string(rune('a'+i))+".mp3", strings.Repeat("x", 1<<20-10))
|
||||
}
|
||||
_, err = f.uploads.PutFile(upload.ID, "d.mp3", mp3(strings.Repeat("x", 1000)))
|
||||
assert.ErrorIs(t, err, ErrTooLarge, "over the size of the whole upload")
|
||||
_, err = f.uploads.PutFile(upload.ID, "a.mp3", mp3("small"))
|
||||
assert.NoError(t, err, "replacing a song counts its new size only")
|
||||
}
|
||||
|
||||
func TestUploadFinishChecks(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
empty := f.start("Empty")
|
||||
_, err := f.uploads.Finish(ctx, empty.ID)
|
||||
assert.ErrorIs(t, err, ErrInvalidName, "an upload without songs")
|
||||
_, err = f.uploads.Finish(ctx, uuid.New())
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// A folder of the same name appeared meanwhile, e.g. from ResilioSync.
|
||||
clash := f.start("Clash")
|
||||
f.put(clash.ID, "01.mp3", "c")
|
||||
writeFile(t, filepath.Join(f.musicDir, "Clash", "01.mp3"), "other")
|
||||
_, err = f.uploads.Finish(ctx, clash.ID)
|
||||
assert.ErrorIs(t, err, ErrConflict)
|
||||
assert.FileExists(t, filepath.Join(f.musicDir, uploadsFolder, clash.ID.String(), "01.mp3"), "the upload is kept")
|
||||
|
||||
// The same songs as a soundtrack that is already in the library.
|
||||
original := f.start("Original")
|
||||
f.put(original.ID, "01.mp3", "same")
|
||||
_, err = f.uploads.Finish(ctx, original.ID)
|
||||
require.NoError(t, err)
|
||||
duplicate := f.start("Copy")
|
||||
f.put(duplicate.ID, "01.mp3", "same")
|
||||
_, err = f.uploads.Finish(ctx, duplicate.ID)
|
||||
assert.ErrorIs(t, err, ErrConflict)
|
||||
assert.Contains(t, err.Error(), "Original")
|
||||
assert.NoDirExists(t, filepath.Join(f.musicDir, "Copy"))
|
||||
}
|
||||
|
||||
func TestUploadWhileSyncing(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
release := make(chan struct{})
|
||||
f.sync = NewSyncService(f.store, f.musicDir, func(context.Context) { <-release })
|
||||
f.uploads.sync = f.sync
|
||||
upload := f.start("Game")
|
||||
f.put(upload.ID, "01.mp3", "a")
|
||||
require.True(t, f.sync.Start(false))
|
||||
|
||||
_, err := f.uploads.Start(context.Background(), "Other")
|
||||
assert.ErrorIs(t, err, ErrSyncing)
|
||||
_, err = f.uploads.Finish(context.Background(), upload.ID)
|
||||
assert.ErrorIs(t, err, ErrSyncing)
|
||||
assert.NoDirExists(t, filepath.Join(f.musicDir, "Game"), "nothing was moved")
|
||||
|
||||
close(release)
|
||||
f.sync.Wait()
|
||||
_, err = f.uploads.Finish(context.Background(), upload.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestUploadFinishBlocksASync(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
upload := f.start("Game")
|
||||
f.put(upload.ID, "01.mp3", "a")
|
||||
started := make(chan bool, 1)
|
||||
|
||||
_, err := f.sync.SyncFolder(context.Background(), "Game", func() error {
|
||||
started <- f.sync.Start(false)
|
||||
return os.Rename(f.uploads.dir(upload.ID), filepath.Join(f.musicDir, "Game"))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, <-started, "a sync cannot start while a folder is being added")
|
||||
}
|
||||
|
||||
func TestUploadCancelAndCleanup(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
cancelled := f.start("Cancelled")
|
||||
f.put(cancelled.ID, "01.mp3", "a")
|
||||
require.NoError(t, f.uploads.Cancel(cancelled.ID))
|
||||
assert.NoDirExists(t, f.uploads.dir(cancelled.ID))
|
||||
assert.ErrorIs(t, f.uploads.Cancel(cancelled.ID), ErrNotFound)
|
||||
|
||||
old := f.start("Old")
|
||||
f.put(old.ID, "01.mp3", "a")
|
||||
recent := f.start("Recent")
|
||||
f.uploads.now = func() time.Time { return time.Now().Add(uploadMaxAge - time.Minute) }
|
||||
f.uploads.RemoveOld()
|
||||
_, err := f.uploads.Get(old.ID)
|
||||
assert.NoError(t, err, "not a day old yet")
|
||||
|
||||
f.uploads.now = func() time.Time { return time.Now().Add(uploadMaxAge + time.Minute) }
|
||||
f.uploads.RemoveOld()
|
||||
_, err = f.uploads.Get(old.ID)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
_, err = f.uploads.Get(recent.ID)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// A new upload of the same name works after the old one is gone.
|
||||
f.uploads.now = time.Now
|
||||
f.start("Old")
|
||||
}
|
||||
|
||||
func TestSyncSkipsUploadsAndHiddenFolders(t *testing.T) {
|
||||
f := newSyncFixture(t)
|
||||
f.write("Game A/01.mp3", "a")
|
||||
f.write(".uploads/123/01.mp3", "b")
|
||||
f.write(".sync/x/01.mp3", "c")
|
||||
result := f.run(false)
|
||||
assert.Equal(t, []string{"Game A"}, result.SoundtracksAdded)
|
||||
}
|
||||
|
||||
func TestUploadsUnavailableWhenMusicFolderIsReadOnly(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("root can write to read-only folders")
|
||||
}
|
||||
f := newUploadFixture(t)
|
||||
require.NoError(t, os.Chmod(f.musicDir, 0o555))
|
||||
t.Cleanup(func() { os.Chmod(f.musicDir, 0o755) })
|
||||
|
||||
_, err := f.uploads.Start(context.Background(), "Game")
|
||||
assert.ErrorIs(t, err, ErrUploadsUnavailable)
|
||||
}
|
||||
@@ -19,8 +19,11 @@ var data = []VersionData{
|
||||
"#21 - Refactor handlers into domain-specific files",
|
||||
"#22 - Change VersionData Changelog from string to string array",
|
||||
"#23 - Update all dependencies to latest versions",
|
||||
"#29 - UUIDs in API responses, new soundtrack and song endpoints by UUID",
|
||||
"Added search cache backend",
|
||||
"Backend sync improvements",
|
||||
"Faster sync that skips unchanged soundtracks",
|
||||
"Security and stability fixes",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -16,6 +17,9 @@ const DefaultReleasesURL = "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicP
|
||||
// Config holds all settings read from the environment.
|
||||
type Config struct {
|
||||
Port int
|
||||
// MetricsPort serves Prometheus metrics on a port of its own, so /metrics is never
|
||||
// reachable through the public port. 0 means metrics are not served.
|
||||
MetricsPort int
|
||||
|
||||
DBHost string
|
||||
DBPort string
|
||||
@@ -27,6 +31,19 @@ type Config struct {
|
||||
CharactersPath string
|
||||
SoundCheckPath string
|
||||
FrontendPath string
|
||||
// UploadMaxFileSize and UploadMaxTotalSize limit uploaded songs, in bytes: one song and
|
||||
// all songs of one uploaded soundtrack.
|
||||
UploadMaxFileSize int64
|
||||
UploadMaxTotalSize int64
|
||||
|
||||
// CoversPath is the folder for soundtrack covers. Empty turns covers off.
|
||||
CoversPath string
|
||||
|
||||
// SteamGridDBAPIKey turns on the background search for covers.
|
||||
SteamGridDBAPIKey string
|
||||
// SteamGridDBURL and AniListURL replace the real cover APIs; only tests set them.
|
||||
SteamGridDBURL string
|
||||
AniListURL string
|
||||
|
||||
LogLevel string
|
||||
LogJSON bool
|
||||
@@ -38,6 +55,10 @@ type Config struct {
|
||||
// TrustedProxies are CIDR ranges whose X-Forwarded-For header is trusted when resolving client IPs.
|
||||
TrustedProxies []*net.IPNet
|
||||
|
||||
// CORSAllowedOrigins are the browser origins, like https://music.example.com, that may call the API
|
||||
// from another site. Empty means only same-origin requests, which is enough for the bundled frontend.
|
||||
CORSAllowedOrigins []string
|
||||
|
||||
ReleasesURL string
|
||||
}
|
||||
|
||||
@@ -57,6 +78,8 @@ func load(getenv func(string) string) (Config, error) {
|
||||
CharactersPath: getenv("CHARACTERS_PATH"),
|
||||
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
|
||||
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
|
||||
CoversPath: getenv("COVERS_PATH"),
|
||||
SteamGridDBAPIKey: getenv("STEAMGRIDDB_API_KEY"),
|
||||
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
|
||||
LogJSON: getenv("LOG_JSON") == "true",
|
||||
APIPassword: getenv("API_PASSWORD"),
|
||||
@@ -72,6 +95,20 @@ func load(getenv func(string) string) (Config, error) {
|
||||
}
|
||||
cfg.Port = port
|
||||
|
||||
cfg.UploadMaxFileSize, err = parseMegabytes(getenv("UPLOAD_MAX_FILE_MB"), DefaultUploadMaxFileMB)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("UPLOAD_MAX_FILE_MB: %w", err))
|
||||
}
|
||||
cfg.UploadMaxTotalSize, err = parseMegabytes(getenv("UPLOAD_MAX_TOTAL_MB"), DefaultUploadMaxTotalMB)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("UPLOAD_MAX_TOTAL_MB: %w", err))
|
||||
}
|
||||
|
||||
cfg.MetricsPort, err = parseMetricsPort(getenv("METRICS_PORT"))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("METRICS_PORT: %w", err))
|
||||
}
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"DB_HOST": cfg.DBHost,
|
||||
"DB_PORT": cfg.DBPort,
|
||||
@@ -91,6 +128,11 @@ func load(getenv func(string) string) (Config, error) {
|
||||
errs = append(errs, fmt.Errorf("TRUSTED_PROXIES: %w", err))
|
||||
}
|
||||
|
||||
cfg.CORSAllowedOrigins, err = parseOrigins(getenv("CORS_ALLOWED_ORIGINS"))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("CORS_ALLOWED_ORIGINS: %w", err))
|
||||
}
|
||||
|
||||
return cfg, errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -110,6 +152,63 @@ func parseCIDRs(value string) ([]*net.IPNet, error) {
|
||||
return nets, nil
|
||||
}
|
||||
|
||||
// Upload size limits in MB, used when UPLOAD_MAX_FILE_MB and UPLOAD_MAX_TOTAL_MB are not set.
|
||||
const (
|
||||
DefaultUploadMaxFileMB = 50
|
||||
DefaultUploadMaxTotalMB = 2048
|
||||
)
|
||||
|
||||
// parseMegabytes reads a positive number of megabytes and returns it in bytes.
|
||||
func parseMegabytes(value string, defaultMB int64) (int64, error) {
|
||||
if value == "" {
|
||||
return defaultMB << 20, nil
|
||||
}
|
||||
mb, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || mb < 1 || mb > 1<<20 {
|
||||
return 0, fmt.Errorf("%q is not a number of megabytes", value)
|
||||
}
|
||||
return mb << 20, nil
|
||||
}
|
||||
|
||||
// DefaultMetricsPort is used when METRICS_PORT is not set.
|
||||
const DefaultMetricsPort = 2112
|
||||
|
||||
// parseMetricsPort reads METRICS_PORT: empty for the default, "off" for no metrics, or a port.
|
||||
func parseMetricsPort(value string) (int, error) {
|
||||
switch value {
|
||||
case "":
|
||||
return DefaultMetricsPort, nil
|
||||
case "off":
|
||||
return 0, nil
|
||||
}
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return 0, fmt.Errorf("%q is not a port or \"off\"", value)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// parseOrigins parses a comma-separated list of origins (scheme://host[:port], nothing else).
|
||||
func parseOrigins(value string) ([]string, error) {
|
||||
var origins []string
|
||||
for part := range strings.SplitSeq(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path != "" || u.RawQuery != "" ||
|
||||
u.Fragment != "" || u.User != nil || strings.Contains(u.Host, "*") {
|
||||
return nil, fmt.Errorf("%q is not an origin like https://music.example.com", part)
|
||||
}
|
||||
origins = append(origins, u.Scheme+"://"+u.Host)
|
||||
}
|
||||
return origins, nil
|
||||
}
|
||||
|
||||
func withDefault(value, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
|
||||
@@ -31,6 +31,60 @@ func TestLoadDefaults(t *testing.T) {
|
||||
assert.Equal(t, "songs", cfg.SoundCheckPath)
|
||||
assert.Equal(t, DefaultReleasesURL, cfg.ReleasesURL)
|
||||
assert.Empty(t, cfg.TrustedProxies)
|
||||
assert.Empty(t, cfg.CORSAllowedOrigins)
|
||||
assert.Equal(t, DefaultMetricsPort, cfg.MetricsPort)
|
||||
assert.Empty(t, cfg.CoversPath, "covers are off by default")
|
||||
assert.Empty(t, cfg.SteamGridDBAPIKey)
|
||||
}
|
||||
|
||||
func TestLoadUploadLimits(t *testing.T) {
|
||||
cfg, err := load(env(validEnv()))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(DefaultUploadMaxFileMB<<20), cfg.UploadMaxFileSize)
|
||||
assert.Equal(t, int64(DefaultUploadMaxTotalMB<<20), cfg.UploadMaxTotalSize)
|
||||
|
||||
values := validEnv()
|
||||
values["UPLOAD_MAX_FILE_MB"] = "100"
|
||||
values["UPLOAD_MAX_TOTAL_MB"] = "500"
|
||||
cfg, err = load(env(values))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(100<<20), cfg.UploadMaxFileSize)
|
||||
assert.Equal(t, int64(500<<20), cfg.UploadMaxTotalSize)
|
||||
|
||||
for _, invalid := range []string{"0", "-5", "lots", "1.5"} {
|
||||
values["UPLOAD_MAX_FILE_MB"] = invalid
|
||||
_, err = load(env(values))
|
||||
assert.ErrorContains(t, err, "UPLOAD_MAX_FILE_MB", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCovers(t *testing.T) {
|
||||
values := validEnv()
|
||||
values["COVERS_PATH"] = "/covers"
|
||||
values["STEAMGRIDDB_API_KEY"] = "key"
|
||||
cfg, err := load(env(values))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/covers", cfg.CoversPath)
|
||||
assert.Equal(t, "key", cfg.SteamGridDBAPIKey)
|
||||
}
|
||||
|
||||
func TestLoadMetricsPort(t *testing.T) {
|
||||
values := validEnv()
|
||||
values["METRICS_PORT"] = "9100"
|
||||
cfg, err := load(env(values))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 9100, cfg.MetricsPort)
|
||||
|
||||
values["METRICS_PORT"] = "off"
|
||||
cfg, err = load(env(values))
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, cfg.MetricsPort)
|
||||
|
||||
for _, invalid := range []string{"abc", "0", "70000", "-1", "OFF"} {
|
||||
values["METRICS_PORT"] = invalid
|
||||
_, err = load(env(values))
|
||||
assert.ErrorContains(t, err, "METRICS_PORT", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingRequired(t *testing.T) {
|
||||
@@ -56,6 +110,21 @@ func TestLoadTrustedProxies(t *testing.T) {
|
||||
assert.ErrorContains(t, err, "TRUSTED_PROXIES")
|
||||
}
|
||||
|
||||
func TestLoadCORSAllowedOrigins(t *testing.T) {
|
||||
values := validEnv()
|
||||
values["CORS_ALLOWED_ORIGINS"] = "https://music.example.com, http://localhost:5173,"
|
||||
cfg, err := load(env(values))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"https://music.example.com", "http://localhost:5173"}, cfg.CORSAllowedOrigins)
|
||||
|
||||
for _, invalid := range []string{"music.example.com", "ftp://example.com", "https://example.com/app",
|
||||
"https://*.example.com", "https://*", "*", "https://example.com?x=1", "https://user@example.com"} {
|
||||
values["CORS_ALLOWED_ORIGINS"] = invalid
|
||||
_, err = load(env(values))
|
||||
assert.ErrorContains(t, err, "CORS_ALLOWED_ORIGINS", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidPort(t *testing.T) {
|
||||
values := validEnv()
|
||||
values["PORT"] = "abc"
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestMigrationsKeepData(t *testing.T) {
|
||||
assert.Error(t, err, "game table is renamed")
|
||||
}
|
||||
|
||||
func TestFingerprintMigrationDown(t *testing.T) {
|
||||
func TestFingerprintAndQueueMigrationDown(t *testing.T) {
|
||||
database := openDatabase(t, pgtest.NewDatabase(t))
|
||||
m, closeFn, err := database.NewMigrator()
|
||||
require.NoError(t, err)
|
||||
@@ -75,6 +75,8 @@ func TestFingerprintMigrationDown(t *testing.T) {
|
||||
require.NoError(t, m.Steps(-1))
|
||||
assert.Equal(t, 0, count(t, database,
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'soundtrack' AND column_name = 'fingerprint'"))
|
||||
assert.Equal(t, 0, count(t, database,
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN ('queue', 'queue_song')"))
|
||||
require.NoError(t, m.Up())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE soundtrack
|
||||
DROP COLUMN alternative_names,
|
||||
DROP COLUMN cover_file,
|
||||
DROP COLUMN cover_source,
|
||||
DROP COLUMN cover_external_id,
|
||||
DROP COLUMN cover_manual,
|
||||
DROP COLUMN cover_modified,
|
||||
DROP COLUMN cover_checked;
|
||||
DROP TABLE queue_song;
|
||||
DROP TABLE queue;
|
||||
ALTER TABLE soundtrack DROP COLUMN fingerprint;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Cheap fingerprint (file names, sizes and modification times) used to skip
|
||||
-- content hashing of soundtracks that have not changed since the last sync.
|
||||
ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Play queues. Every client has its own queue, identified by the queue's UUID.
|
||||
-- Ended queues are kept as history.
|
||||
CREATE TABLE queue (
|
||||
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
current_song INT4 NOT NULL DEFAULT -1,
|
||||
-- The last random song, added to the queue by /music/addQue, and how it was picked.
|
||||
last_fetched_song_uuid UUID NULL,
|
||||
last_fetched_mode VARCHAR(10) NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE INDEX idx_queue_active ON queue(uuid) WHERE ended_at IS NULL;
|
||||
|
||||
-- song_uuid has no foreign key: a sync can delete songs. The names are kept for the history.
|
||||
CREATE TABLE queue_song (
|
||||
queue_uuid UUID NOT NULL REFERENCES queue(uuid) ON DELETE CASCADE,
|
||||
song_no INT4 NOT NULL,
|
||||
song_uuid UUID NOT NULL,
|
||||
soundtrack_name VARCHAR NOT NULL,
|
||||
song_name VARCHAR NOT NULL,
|
||||
-- How the song was picked: random, low or classic.
|
||||
mode VARCHAR(10) NOT NULL,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (queue_uuid, song_no)
|
||||
);
|
||||
|
||||
-- Other names a soundtrack is known by, for search and the cover search.
|
||||
ALTER TABLE soundtrack ADD COLUMN alternative_names TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Cover art. cover_file is the image's file name in COVERS_PATH, NULL when there is no
|
||||
-- cover. Manual covers (uploaded or picked) are never replaced by the background job.
|
||||
-- cover_checked is when the job last searched, so soundtracks without a match are not
|
||||
-- searched again for a while.
|
||||
ALTER TABLE soundtrack ADD COLUMN cover_file TEXT NULL;
|
||||
ALTER TABLE soundtrack ADD COLUMN cover_source VARCHAR(20) NULL;
|
||||
ALTER TABLE soundtrack ADD COLUMN cover_external_id TEXT NULL;
|
||||
ALTER TABLE soundtrack ADD COLUMN cover_manual BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE soundtrack ADD COLUMN cover_modified TIMESTAMPTZ NULL;
|
||||
ALTER TABLE soundtrack ADD COLUMN cover_checked TIMESTAMPTZ NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE soundtrack DROP COLUMN fingerprint;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Cheap fingerprint (file names, sizes and modification times) used to skip
|
||||
-- content hashing of soundtracks that have not changed since the last sync.
|
||||
ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,51 @@
|
||||
-- name: CreateQueue :one
|
||||
INSERT INTO queue DEFAULT VALUES
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetQueue :one
|
||||
SELECT *
|
||||
FROM queue
|
||||
WHERE uuid = $1;
|
||||
|
||||
-- name: EndQueue :exec
|
||||
UPDATE queue SET ended_at = NOW()
|
||||
WHERE uuid = $1 AND ended_at IS NULL;
|
||||
|
||||
-- name: EndActiveQueues :exec
|
||||
UPDATE queue SET ended_at = NOW()
|
||||
WHERE ended_at IS NULL;
|
||||
|
||||
-- name: SetQueueLastFetched :exec
|
||||
UPDATE queue SET last_fetched_song_uuid = $2, last_fetched_mode = $3
|
||||
WHERE uuid = $1;
|
||||
|
||||
-- name: SetQueueCurrentSong :exec
|
||||
UPDATE queue SET current_song = $2
|
||||
WHERE uuid = $1;
|
||||
|
||||
-- name: AddLastFetchedToQueue :exec
|
||||
-- Appends the queue's last fetched song and makes it the current song.
|
||||
-- Does nothing when no song has been fetched since the last call.
|
||||
WITH added AS (
|
||||
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name, mode)
|
||||
SELECT q.uuid,
|
||||
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid),
|
||||
s.uuid, st.soundtrack_name, s.song_name, COALESCE(q.last_fetched_mode, 'unknown')
|
||||
FROM queue q
|
||||
JOIN song s ON s.uuid = q.last_fetched_song_uuid
|
||||
JOIN soundtrack st ON st.id = s.soundtrack_id
|
||||
WHERE q.uuid = $1
|
||||
RETURNING queue_uuid, song_no
|
||||
)
|
||||
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL, last_fetched_mode = NULL
|
||||
FROM added
|
||||
WHERE queue.uuid = added.queue_uuid;
|
||||
|
||||
-- name: ListQueueSongs :many
|
||||
-- The song columns are NULL when the song has been removed from the library since.
|
||||
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, qs.mode,
|
||||
s.path, s.times_played, s.soundtrack_uuid
|
||||
FROM queue_song qs
|
||||
LEFT JOIN song s ON s.uuid = qs.song_uuid
|
||||
WHERE qs.queue_uuid = $1
|
||||
ORDER BY qs.song_no;
|
||||
@@ -46,7 +46,8 @@ DELETE FROM song WHERE uuid = ANY(sqlc.arg(uuids)::uuid[]);
|
||||
-- name: GetSongsBySoundtrackUUID :many
|
||||
SELECT *
|
||||
FROM song
|
||||
WHERE soundtrack_uuid = $1;
|
||||
WHERE soundtrack_uuid = $1
|
||||
ORDER BY song_name;
|
||||
|
||||
-- name: GetSongUUIDsBySoundtrackId :many
|
||||
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
|
||||
@@ -62,3 +63,12 @@ JOIN soundtrack ON soundtrack.id = song.soundtrack_id
|
||||
WHERE soundtrack.deleted IS NULL
|
||||
ORDER BY random()
|
||||
LIMIT 1;
|
||||
|
||||
-- name: AddSongPlayedByUUID :exec
|
||||
UPDATE song SET times_played = times_played + 1 WHERE uuid = $1;
|
||||
|
||||
-- name: GetActiveSongByUUID :one
|
||||
SELECT sqlc.embed(song), soundtrack.soundtrack_name, soundtrack.path AS soundtrack_path
|
||||
FROM song
|
||||
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
|
||||
WHERE song.uuid = $1 AND soundtrack.deleted IS NULL;
|
||||
@@ -62,3 +62,47 @@ AND deleted IS NULL;
|
||||
|
||||
-- name: GetSoundtrackUUIDById :one
|
||||
SELECT uuid FROM soundtrack WHERE id = $1;
|
||||
|
||||
-- name: AddSoundtrackPlayedByUUID :exec
|
||||
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1;
|
||||
|
||||
-- name: SetAlternativeNames :one
|
||||
-- Replaces the alternative names. A soundtrack without a cover is searched again with them.
|
||||
UPDATE soundtrack
|
||||
SET alternative_names = sqlc.arg(names)::text[],
|
||||
cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END
|
||||
WHERE uuid = sqlc.arg(uuid) AND deleted IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListSoundtracksNeedingCover :many
|
||||
-- Active soundtracks without a cover that the job has not searched since checked_before.
|
||||
SELECT uuid, soundtrack_name, alternative_names
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
AND cover_file IS NULL
|
||||
AND NOT cover_manual
|
||||
AND (cover_checked IS NULL OR cover_checked < sqlc.arg(checked_before))
|
||||
ORDER BY soundtrack_name;
|
||||
|
||||
-- name: SetCover :exec
|
||||
UPDATE soundtrack
|
||||
SET cover_file = sqlc.arg(file),
|
||||
cover_source = sqlc.arg(source),
|
||||
cover_external_id = sqlc.arg(external_id),
|
||||
cover_manual = sqlc.arg(manual),
|
||||
cover_modified = now(),
|
||||
cover_checked = now()
|
||||
WHERE uuid = sqlc.arg(uuid);
|
||||
|
||||
-- name: MarkCoverChecked :exec
|
||||
UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1;
|
||||
|
||||
-- name: ClearCover :exec
|
||||
-- Removes the cover, so the job searches for one again.
|
||||
UPDATE soundtrack
|
||||
SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false,
|
||||
cover_modified = NULL, cover_checked = NULL
|
||||
WHERE uuid = $1;
|
||||
|
||||
-- name: ListCoverFiles :many
|
||||
SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL;
|
||||
@@ -2,17 +2,25 @@
|
||||
-- name: GetMostPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
@@ -24,17 +32,25 @@ LIMIT $1;
|
||||
-- name: GetLeastPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
@@ -46,6 +62,8 @@ LIMIT $1;
|
||||
-- name: GetMostPlayedSongsWithSoundtrack :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
s.uuid as song_uuid,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
@@ -61,6 +79,8 @@ LIMIT $1;
|
||||
-- name: GetLeastPlayedSongsWithSoundtrack :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
s.uuid as song_uuid,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
@@ -76,16 +96,24 @@ LIMIT $1;
|
||||
-- name: GetNeverPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.added,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json 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
|
||||
@@ -96,16 +124,24 @@ ORDER BY g.soundtrack_name;
|
||||
-- name: GetLastPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json 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
|
||||
@@ -117,16 +153,24 @@ LIMIT $1;
|
||||
-- name: GetOldestPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json 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
|
||||
@@ -146,3 +190,38 @@ SELECT
|
||||
COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL;
|
||||
|
||||
-- Every soundtrack with its songs, by name
|
||||
-- name: GetAllSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json 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.soundtrack_name;
|
||||
|
||||
-- name: CountLibrary :one
|
||||
-- Soundtracks and songs in the library, without deleted soundtracks.
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM soundtrack WHERE deleted IS NULL) AS soundtracks,
|
||||
(SELECT COUNT(*) FROM song s JOIN soundtrack st ON st.id = s.soundtrack_id WHERE st.deleted IS NULL) AS songs;
|
||||
@@ -11,6 +11,25 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Queue struct {
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
CurrentSong int32 `json:"current_song"`
|
||||
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"`
|
||||
LastFetchedMode *string `json:"last_fetched_mode"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type QueueSong struct {
|
||||
QueueUuid uuid.UUID `json:"queue_uuid"`
|
||||
SongNo int32 `json:"song_no"`
|
||||
SongUuid uuid.UUID `json:"song_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SongName string `json:"song_name"`
|
||||
Mode string `json:"mode"`
|
||||
AddedAt pgtype.Timestamptz `json:"added_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
@@ -52,6 +71,13 @@ type Soundtrack struct {
|
||||
Hash string `json:"hash"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
AlternativeNames []string `json:"alternative_names"`
|
||||
CoverFile *string `json:"cover_file"`
|
||||
CoverSource *string `json:"cover_source"`
|
||||
CoverExternalID *string `json:"cover_external_id"`
|
||||
CoverManual bool `json:"cover_manual"`
|
||||
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||
CoverChecked pgtype.Timestamptz `json:"cover_checked"`
|
||||
}
|
||||
|
||||
type Vgmq struct {
|
||||
|
||||
@@ -9,25 +9,41 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
AddHashToSong(ctx context.Context, arg AddHashToSongParams) error
|
||||
// Appends the queue's last fetched song and makes it the current song.
|
||||
// Does nothing when no song has been fetched since the last call.
|
||||
AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID) error
|
||||
AddSong(ctx context.Context, arg AddSongParams) error
|
||||
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
|
||||
AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
|
||||
AddSoundtrackPlayed(ctx context.Context, id int32) error
|
||||
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
|
||||
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
|
||||
CheckSongWithHash(ctx context.Context, hash string) (int64, error)
|
||||
// Removes the cover, so the job searches for one again.
|
||||
ClearCover(ctx context.Context, argUuid uuid.UUID) error
|
||||
ClearSongs(ctx context.Context) error
|
||||
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
|
||||
ClearSoundtracks(ctx context.Context) error
|
||||
// Soundtracks and songs in the library, without deleted soundtracks.
|
||||
CountLibrary(ctx context.Context) (CountLibraryRow, error)
|
||||
CreateQueue(ctx context.Context) (Queue, error)
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||
DeleteExpiredSessions(ctx context.Context) error
|
||||
DeleteSession(ctx context.Context, token string) error
|
||||
EndActiveQueues(ctx context.Context) error
|
||||
EndQueue(ctx context.Context, argUuid uuid.UUID) error
|
||||
FetchAllSongs(ctx context.Context) ([]Song, error)
|
||||
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
||||
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
|
||||
GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error)
|
||||
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error)
|
||||
// Every soundtrack with its songs, by name
|
||||
GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSoundtracksWithSongsRow, error)
|
||||
GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
|
||||
// Last played soundtracks (most recently played)
|
||||
GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error)
|
||||
@@ -43,6 +59,7 @@ type Querier interface {
|
||||
GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error)
|
||||
// Oldest played soundtracks (least recently played, but has been played at least once)
|
||||
GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error)
|
||||
GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error)
|
||||
GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error)
|
||||
GetSession(ctx context.Context, token string) (Session, error)
|
||||
GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error)
|
||||
@@ -59,12 +76,23 @@ type Querier interface {
|
||||
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
|
||||
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
|
||||
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error
|
||||
ListCoverFiles(ctx context.Context) ([]string, error)
|
||||
// The song columns are NULL when the song has been removed from the library since.
|
||||
ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error)
|
||||
ListSessions(ctx context.Context) ([]Session, error)
|
||||
// Active soundtracks without a cover that the job has not searched since checked_before.
|
||||
ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error)
|
||||
MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error
|
||||
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
|
||||
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
|
||||
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
|
||||
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
|
||||
ResetSoundtrackIdSeq(ctx context.Context) (int64, error)
|
||||
// Replaces the alternative names. A soundtrack without a cover is searched again with them.
|
||||
SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error)
|
||||
SetCover(ctx context.Context, arg SetCoverParams) error
|
||||
SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error
|
||||
SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error
|
||||
SetSoundtrackDeletionDate(ctx context.Context) error
|
||||
SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error
|
||||
UpdateSong(ctx context.Context, arg UpdateSongParams) error
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: queue.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const addLastFetchedToQueue = `-- name: AddLastFetchedToQueue :exec
|
||||
WITH added AS (
|
||||
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name, mode)
|
||||
SELECT q.uuid,
|
||||
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid),
|
||||
s.uuid, st.soundtrack_name, s.song_name, COALESCE(q.last_fetched_mode, 'unknown')
|
||||
FROM queue q
|
||||
JOIN song s ON s.uuid = q.last_fetched_song_uuid
|
||||
JOIN soundtrack st ON st.id = s.soundtrack_id
|
||||
WHERE q.uuid = $1
|
||||
RETURNING queue_uuid, song_no
|
||||
)
|
||||
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL, last_fetched_mode = NULL
|
||||
FROM added
|
||||
WHERE queue.uuid = added.queue_uuid
|
||||
`
|
||||
|
||||
// Appends the queue's last fetched song and makes it the current song.
|
||||
// Does nothing when no song has been fetched since the last call.
|
||||
func (q *Queries) AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, addLastFetchedToQueue, argUuid)
|
||||
return err
|
||||
}
|
||||
|
||||
const createQueue = `-- name: CreateQueue :one
|
||||
INSERT INTO queue DEFAULT VALUES
|
||||
RETURNING uuid, current_song, last_fetched_song_uuid, last_fetched_mode, started_at, ended_at
|
||||
`
|
||||
|
||||
func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) {
|
||||
row := q.db.QueryRow(ctx, createQueue)
|
||||
var i Queue
|
||||
err := row.Scan(
|
||||
&i.Uuid,
|
||||
&i.CurrentSong,
|
||||
&i.LastFetchedSongUuid,
|
||||
&i.LastFetchedMode,
|
||||
&i.StartedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const endActiveQueues = `-- name: EndActiveQueues :exec
|
||||
UPDATE queue SET ended_at = NOW()
|
||||
WHERE ended_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) EndActiveQueues(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, endActiveQueues)
|
||||
return err
|
||||
}
|
||||
|
||||
const endQueue = `-- name: EndQueue :exec
|
||||
UPDATE queue SET ended_at = NOW()
|
||||
WHERE uuid = $1 AND ended_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) EndQueue(ctx context.Context, argUuid uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, endQueue, argUuid)
|
||||
return err
|
||||
}
|
||||
|
||||
const getQueue = `-- name: GetQueue :one
|
||||
SELECT uuid, current_song, last_fetched_song_uuid, last_fetched_mode, started_at, ended_at
|
||||
FROM queue
|
||||
WHERE uuid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error) {
|
||||
row := q.db.QueryRow(ctx, getQueue, argUuid)
|
||||
var i Queue
|
||||
err := row.Scan(
|
||||
&i.Uuid,
|
||||
&i.CurrentSong,
|
||||
&i.LastFetchedSongUuid,
|
||||
&i.LastFetchedMode,
|
||||
&i.StartedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listQueueSongs = `-- name: ListQueueSongs :many
|
||||
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, qs.mode,
|
||||
s.path, s.times_played, s.soundtrack_uuid
|
||||
FROM queue_song qs
|
||||
LEFT JOIN song s ON s.uuid = qs.song_uuid
|
||||
WHERE qs.queue_uuid = $1
|
||||
ORDER BY qs.song_no
|
||||
`
|
||||
|
||||
type ListQueueSongsRow struct {
|
||||
SongNo int32 `json:"song_no"`
|
||||
SongUuid uuid.UUID `json:"song_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SongName string `json:"song_name"`
|
||||
Mode string `json:"mode"`
|
||||
Path *string `json:"path"`
|
||||
TimesPlayed *int32 `json:"times_played"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
}
|
||||
|
||||
// The song columns are NULL when the song has been removed from the library since.
|
||||
func (q *Queries) ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listQueueSongs, queueUuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListQueueSongsRow
|
||||
for rows.Next() {
|
||||
var i ListQueueSongsRow
|
||||
if err := rows.Scan(
|
||||
&i.SongNo,
|
||||
&i.SongUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SongName,
|
||||
&i.Mode,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.SoundtrackUuid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setQueueCurrentSong = `-- name: SetQueueCurrentSong :exec
|
||||
UPDATE queue SET current_song = $2
|
||||
WHERE uuid = $1
|
||||
`
|
||||
|
||||
type SetQueueCurrentSongParams struct {
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
CurrentSong int32 `json:"current_song"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error {
|
||||
_, err := q.db.Exec(ctx, setQueueCurrentSong, arg.Uuid, arg.CurrentSong)
|
||||
return err
|
||||
}
|
||||
|
||||
const setQueueLastFetched = `-- name: SetQueueLastFetched :exec
|
||||
UPDATE queue SET last_fetched_song_uuid = $2, last_fetched_mode = $3
|
||||
WHERE uuid = $1
|
||||
`
|
||||
|
||||
type SetQueueLastFetchedParams struct {
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"`
|
||||
LastFetchedMode *string `json:"last_fetched_mode"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error {
|
||||
_, err := q.db.Exec(ctx, setQueueLastFetched, arg.Uuid, arg.LastFetchedSongUuid, arg.LastFetchedMode)
|
||||
return err
|
||||
}
|
||||
@@ -68,6 +68,15 @@ func (q *Queries) AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) er
|
||||
return err
|
||||
}
|
||||
|
||||
const addSongPlayedByUUID = `-- name: AddSongPlayedByUUID :exec
|
||||
UPDATE song SET times_played = times_played + 1 WHERE uuid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, addSongPlayedByUUID, argUuid)
|
||||
return err
|
||||
}
|
||||
|
||||
const checkSong = `-- name: CheckSong :one
|
||||
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2
|
||||
`
|
||||
@@ -181,6 +190,37 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getActiveSongByUUID = `-- name: GetActiveSongByUUID :one
|
||||
SELECT song.soundtrack_id, song.song_name, song.path, song.times_played, song.hash, song.file_name, song.uuid, song.soundtrack_uuid, soundtrack.soundtrack_name, soundtrack.path AS soundtrack_path
|
||||
FROM song
|
||||
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
|
||||
WHERE song.uuid = $1 AND soundtrack.deleted IS NULL
|
||||
`
|
||||
|
||||
type GetActiveSongByUUIDRow struct {
|
||||
Song Song `json:"song"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPath string `json:"soundtrack_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, getActiveSongByUUID, argUuid)
|
||||
var i GetActiveSongByUUIDRow
|
||||
err := row.Scan(
|
||||
&i.Song.SoundtrackID,
|
||||
&i.Song.SongName,
|
||||
&i.Song.Path,
|
||||
&i.Song.TimesPlayed,
|
||||
&i.Song.Hash,
|
||||
&i.Song.FileName,
|
||||
&i.Song.Uuid,
|
||||
&i.Song.SoundtrackUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPath,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRandomActiveSong = `-- name: GetRandomActiveSong :one
|
||||
SELECT song.soundtrack_id, song.song_name, song.path, song.times_played, song.hash, song.file_name, song.uuid, song.soundtrack_uuid, soundtrack.path AS soundtrack_path
|
||||
FROM song
|
||||
@@ -285,6 +325,7 @@ const getSongsBySoundtrackUUID = `-- name: GetSongsBySoundtrackUUID :many
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
|
||||
FROM song
|
||||
WHERE soundtrack_uuid = $1
|
||||
ORDER BY song_name
|
||||
`
|
||||
|
||||
func (q *Queries) GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
|
||||
@@ -20,6 +21,28 @@ func (q *Queries) AddSoundtrackPlayed(ctx context.Context, id int32) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const addSoundtrackPlayedByUUID = `-- name: AddSoundtrackPlayedByUUID :exec
|
||||
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, addSoundtrackPlayedByUUID, argUuid)
|
||||
return err
|
||||
}
|
||||
|
||||
const clearCover = `-- name: ClearCover :exec
|
||||
UPDATE soundtrack
|
||||
SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false,
|
||||
cover_modified = NULL, cover_checked = NULL
|
||||
WHERE uuid = $1
|
||||
`
|
||||
|
||||
// Removes the cover, so the job searches for one again.
|
||||
func (q *Queries) ClearCover(ctx context.Context, argUuid uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, clearCover, argUuid)
|
||||
return err
|
||||
}
|
||||
|
||||
const clearSoundtracks = `-- name: ClearSoundtracks :exec
|
||||
DELETE FROM soundtrack
|
||||
`
|
||||
@@ -30,7 +53,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
|
||||
}
|
||||
|
||||
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY soundtrack_name
|
||||
@@ -58,6 +81,13 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverFile,
|
||||
&i.CoverSource,
|
||||
&i.CoverExternalID,
|
||||
&i.CoverManual,
|
||||
&i.CoverModified,
|
||||
&i.CoverChecked,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,7 +100,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
||||
}
|
||||
|
||||
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||
FROM soundtrack
|
||||
ORDER BY soundtrack_name
|
||||
`
|
||||
@@ -97,6 +127,13 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverFile,
|
||||
&i.CoverSource,
|
||||
&i.CoverExternalID,
|
||||
&i.CoverManual,
|
||||
&i.CoverModified,
|
||||
&i.CoverChecked,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,7 +157,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
|
||||
}
|
||||
|
||||
const getSoundtrackById = `-- name: GetSoundtrackById :one
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||
FROM soundtrack
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL
|
||||
@@ -142,12 +179,19 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverFile,
|
||||
&i.CoverSource,
|
||||
&i.CoverExternalID,
|
||||
&i.CoverManual,
|
||||
&i.CoverModified,
|
||||
&i.CoverChecked,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||
FROM soundtrack
|
||||
WHERE uuid = $1
|
||||
AND deleted IS NULL
|
||||
@@ -169,6 +213,13 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverFile,
|
||||
&i.CoverSource,
|
||||
&i.CoverExternalID,
|
||||
&i.CoverManual,
|
||||
&i.CoverModified,
|
||||
&i.CoverChecked,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -244,6 +295,76 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
|
||||
return err
|
||||
}
|
||||
|
||||
const listCoverFiles = `-- name: ListCoverFiles :many
|
||||
SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL
|
||||
`
|
||||
|
||||
func (q *Queries) ListCoverFiles(ctx context.Context) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, listCoverFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var cover_file string
|
||||
if err := rows.Scan(&cover_file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, cover_file)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSoundtracksNeedingCover = `-- name: ListSoundtracksNeedingCover :many
|
||||
SELECT uuid, soundtrack_name, alternative_names
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
AND cover_file IS NULL
|
||||
AND NOT cover_manual
|
||||
AND (cover_checked IS NULL OR cover_checked < $1)
|
||||
ORDER BY soundtrack_name
|
||||
`
|
||||
|
||||
type ListSoundtracksNeedingCoverRow struct {
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
AlternativeNames []string `json:"alternative_names"`
|
||||
}
|
||||
|
||||
// Active soundtracks without a cover that the job has not searched since checked_before.
|
||||
func (q *Queries) ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSoundtracksNeedingCover, checkedBefore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSoundtracksNeedingCoverRow
|
||||
for rows.Next() {
|
||||
var i ListSoundtracksNeedingCoverRow
|
||||
if err := rows.Scan(&i.Uuid, &i.SoundtrackName, &i.AlternativeNames); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markCoverChecked = `-- name: MarkCoverChecked :exec
|
||||
UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, markCoverChecked, argUuid)
|
||||
return err
|
||||
}
|
||||
|
||||
const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
|
||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
|
||||
`
|
||||
@@ -273,6 +394,77 @@ func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) {
|
||||
return setval, err
|
||||
}
|
||||
|
||||
const setAlternativeNames = `-- name: SetAlternativeNames :one
|
||||
UPDATE soundtrack
|
||||
SET alternative_names = $1::text[],
|
||||
cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END
|
||||
WHERE uuid = $2 AND deleted IS NULL
|
||||
RETURNING id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
|
||||
`
|
||||
|
||||
type SetAlternativeNamesParams struct {
|
||||
Names []string `json:"names"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
}
|
||||
|
||||
// Replaces the alternative names. A soundtrack without a cover is searched again with them.
|
||||
func (q *Queries) SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error) {
|
||||
row := q.db.QueryRow(ctx, setAlternativeNames, arg.Names, arg.Uuid)
|
||||
var i Soundtrack
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SoundtrackName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
&i.Fingerprint,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverFile,
|
||||
&i.CoverSource,
|
||||
&i.CoverExternalID,
|
||||
&i.CoverManual,
|
||||
&i.CoverModified,
|
||||
&i.CoverChecked,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const setCover = `-- name: SetCover :exec
|
||||
UPDATE soundtrack
|
||||
SET cover_file = $1,
|
||||
cover_source = $2,
|
||||
cover_external_id = $3,
|
||||
cover_manual = $4,
|
||||
cover_modified = now(),
|
||||
cover_checked = now()
|
||||
WHERE uuid = $5
|
||||
`
|
||||
|
||||
type SetCoverParams struct {
|
||||
File *string `json:"file"`
|
||||
Source *string `json:"source"`
|
||||
ExternalID *string `json:"external_id"`
|
||||
Manual bool `json:"manual"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetCover(ctx context.Context, arg SetCoverParams) error {
|
||||
_, err := q.db.Exec(ctx, setCover,
|
||||
arg.File,
|
||||
arg.Source,
|
||||
arg.ExternalID,
|
||||
arg.Manual,
|
||||
arg.Uuid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
|
||||
`
|
||||
|
||||
@@ -8,21 +8,121 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countLibrary = `-- name: CountLibrary :one
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM soundtrack WHERE deleted IS NULL) AS soundtracks,
|
||||
(SELECT COUNT(*) FROM song s JOIN soundtrack st ON st.id = s.soundtrack_id WHERE st.deleted IS NULL) AS songs
|
||||
`
|
||||
|
||||
type CountLibraryRow struct {
|
||||
Soundtracks int64 `json:"soundtracks"`
|
||||
Songs int64 `json:"songs"`
|
||||
}
|
||||
|
||||
// Soundtracks and songs in the library, without deleted soundtracks.
|
||||
func (q *Queries) CountLibrary(ctx context.Context) (CountLibraryRow, error) {
|
||||
row := q.db.QueryRow(ctx, countLibrary)
|
||||
var i CountLibraryRow
|
||||
err := row.Scan(&i.Soundtracks, &i.Songs)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAllSoundtracksWithSongs = `-- name: GetAllSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json 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.soundtrack_name
|
||||
`
|
||||
|
||||
type GetAllSoundtracksWithSongsRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
AlternativeNames []string `json:"alternative_names"`
|
||||
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
|
||||
// Every soundtrack with its songs, by name
|
||||
func (q *Queries) GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSoundtracksWithSongsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getAllSoundtracksWithSongs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAllSoundtracksWithSongsRow
|
||||
for rows.Next() {
|
||||
var i GetAllSoundtracksWithSongsRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverModified,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json 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
|
||||
@@ -33,8 +133,11 @@ LIMIT $1
|
||||
|
||||
type GetLastPlayedSoundtracksRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
AlternativeNames []string `json:"alternative_names"`
|
||||
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
@@ -51,8 +154,11 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
|
||||
var i GetLastPlayedSoundtracksRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverModified,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
@@ -69,6 +175,8 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
|
||||
const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
s.uuid as song_uuid,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
@@ -83,6 +191,8 @@ LIMIT $1
|
||||
|
||||
type GetLeastPlayedSongsWithSoundtrackRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SongUuid uuid.UUID `json:"song_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
@@ -102,6 +212,8 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i
|
||||
var i GetLeastPlayedSongsWithSoundtrackRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackUuid,
|
||||
&i.SongUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
@@ -121,17 +233,25 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i
|
||||
const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
@@ -142,8 +262,11 @@ LIMIT $1
|
||||
|
||||
type GetLeastPlayedSoundtracksWithSongsRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
AlternativeNames []string `json:"alternative_names"`
|
||||
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
@@ -160,8 +283,11 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
|
||||
var i GetLeastPlayedSoundtracksWithSongsRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverModified,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
@@ -178,6 +304,8 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
|
||||
const getMostPlayedSongsWithSoundtrack = `-- name: GetMostPlayedSongsWithSoundtrack :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
s.uuid as song_uuid,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
@@ -192,6 +320,8 @@ LIMIT $1
|
||||
|
||||
type GetMostPlayedSongsWithSoundtrackRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SongUuid uuid.UUID `json:"song_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
@@ -211,6 +341,8 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in
|
||||
var i GetMostPlayedSongsWithSoundtrackRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackUuid,
|
||||
&i.SongUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
@@ -230,17 +362,25 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in
|
||||
const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
@@ -251,8 +391,11 @@ LIMIT $1
|
||||
|
||||
type GetMostPlayedSoundtracksWithSongsRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
AlternativeNames []string `json:"alternative_names"`
|
||||
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
@@ -269,8 +412,11 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
|
||||
var i GetMostPlayedSoundtracksWithSongsRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverModified,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
@@ -287,16 +433,24 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
|
||||
const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.added,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json 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
|
||||
@@ -306,8 +460,11 @@ ORDER BY g.soundtrack_name
|
||||
|
||||
type GetNeverPlayedSoundtracksRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
AlternativeNames []string `json:"alternative_names"`
|
||||
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||
Added time.Time `json:"added"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
@@ -324,8 +481,11 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
|
||||
var i GetNeverPlayedSoundtracksRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverModified,
|
||||
&i.Added,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
@@ -342,16 +502,24 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
|
||||
const getOldestPlayedSoundtracks = `-- name: GetOldestPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.uuid as soundtrack_uuid,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.alternative_names,
|
||||
g.cover_modified,
|
||||
g.last_played as soundtrack_last_played,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_uuid', s.uuid,
|
||||
'soundtrack_uuid', g.uuid,
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
) FILTER (WHERE s.uuid IS NOT NULL),
|
||||
'[]'
|
||||
)::json 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
|
||||
@@ -362,8 +530,11 @@ LIMIT $1
|
||||
|
||||
type GetOldestPlayedSoundtracksRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
AlternativeNames []string `json:"alternative_names"`
|
||||
CoverModified pgtype.Timestamptz `json:"cover_modified"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
@@ -380,8 +551,11 @@ func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) (
|
||||
var i GetOldestPlayedSoundtracksRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackUuid,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.AlternativeNames,
|
||||
&i.CoverModified,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// MusicServer's own metrics. Labels only take values from small fixed sets: never song
|
||||
// names, IDs, tokens or IPs, since every value creates a new time series.
|
||||
var (
|
||||
// SongsRequested counts random songs handed out, by how they were picked.
|
||||
SongsRequested = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "musicserver_songs_requested_total",
|
||||
Help: "Random songs handed out, by how they were picked (random, low, classic).",
|
||||
}, []string{"mode"})
|
||||
|
||||
// SongsPlayed counts songs marked as played, by how they were picked.
|
||||
SongsPlayed = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "musicserver_songs_played_total",
|
||||
Help: "Songs marked as played, by how they were picked (random, low, classic).",
|
||||
}, []string{"mode"})
|
||||
|
||||
// LibrarySoundtracks is the number of soundtracks in the library.
|
||||
LibrarySoundtracks = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "musicserver_library_soundtracks",
|
||||
Help: "Soundtracks in the library, counted at startup and after each sync.",
|
||||
})
|
||||
|
||||
// LibrarySongs is the number of songs in the library.
|
||||
LibrarySongs = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "musicserver_library_songs",
|
||||
Help: "Songs in the library, counted at startup and after each sync.",
|
||||
})
|
||||
|
||||
// SyncDuration is how long syncs take, by type.
|
||||
SyncDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "musicserver_sync_duration_seconds",
|
||||
Help: "How long a sync took, by type (full, changes).",
|
||||
Buckets: []float64{1, 5, 10, 30, 60, 120, 300, 600, 1200, 1800, 3600},
|
||||
}, []string{"type"})
|
||||
|
||||
// SyncLastSuccess is when the last sync without errors finished.
|
||||
SyncLastSuccess = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "musicserver_sync_last_success_timestamp_seconds",
|
||||
Help: "Unix time when the last sync without errors finished.",
|
||||
})
|
||||
|
||||
// BrokenSongs is the number of songs the last full sync found missing and removed.
|
||||
BrokenSongs = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "musicserver_broken_songs",
|
||||
Help: "Songs whose file was missing in the last full sync. They are removed from the library.",
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "http_requests_total",
|
||||
Help: "HTTP requests by method, route and status code.",
|
||||
}, []string{"method", "route", "code"})
|
||||
|
||||
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "http_request_duration_seconds",
|
||||
Help: "HTTP request latency by method and route. Song downloads include the transfer.",
|
||||
// Up to 30 s, since songs can take long to send to slow clients.
|
||||
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30},
|
||||
}, []string{"method", "route"})
|
||||
)
|
||||
|
||||
// unmatchedRoute labels requests that matched no route, so raw URLs never become labels.
|
||||
const unmatchedRoute = "unmatched"
|
||||
|
||||
// Middleware records the count and latency of requests per route. The route label is the
|
||||
// registered pattern, like /api/v1/songs/:uuid, never the URL with its IDs and query.
|
||||
// Register it outside Recover, so requests that panic are counted as 500.
|
||||
func Middleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
start := time.Now()
|
||||
err := next(c)
|
||||
|
||||
route := c.Path()
|
||||
if route == "" {
|
||||
route = unmatchedRoute
|
||||
}
|
||||
_, status := echo.ResolveResponseStatus(c.Response(), err)
|
||||
method := c.Request().Method
|
||||
httpRequests.WithLabelValues(method, route, strconv.Itoa(status)).Inc()
|
||||
httpDuration.WithLabelValues(method, route).Observe(time.Since(start).Seconds())
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newEcho() *echo.Echo {
|
||||
e := echo.New()
|
||||
e.Use(Middleware())
|
||||
e.Use(echoMiddleware.Recover())
|
||||
e.GET("/items/:id", func(c *echo.Context) error { return c.String(http.StatusOK, "item") })
|
||||
e.GET("/fail", func(c *echo.Context) error { return errors.New("broken") })
|
||||
e.GET("/panic", func(c *echo.Context) error { panic("boom") })
|
||||
return e
|
||||
}
|
||||
|
||||
func serve(e *echo.Echo, method, target string) {
|
||||
e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(method, target, nil))
|
||||
}
|
||||
|
||||
func requests(method, route, code string) float64 {
|
||||
return testutil.ToFloat64(httpRequests.WithLabelValues(method, route, code))
|
||||
}
|
||||
|
||||
func TestMiddlewareCountsRequestsByRoutePattern(t *testing.T) {
|
||||
e := newEcho()
|
||||
before := requests("GET", "/items/:id", "200")
|
||||
|
||||
serve(e, http.MethodGet, "/items/1?x=secret")
|
||||
serve(e, http.MethodGet, "/items/2")
|
||||
|
||||
assert.Equal(t, before+2, requests("GET", "/items/:id", "200"))
|
||||
assert.Equal(t, 0.0, requests("GET", "/items/1?x=secret", "200"), "the URL is never a label")
|
||||
assert.Equal(t, 0.0, requests("GET", "/items/1", "200"))
|
||||
}
|
||||
|
||||
func TestMiddlewareRecordsErrorStatus(t *testing.T) {
|
||||
e := newEcho()
|
||||
failBefore := requests("GET", "/fail", "500")
|
||||
panicBefore := requests("GET", "/panic", "500")
|
||||
missingBefore := requests("GET", unmatchedRoute, "404")
|
||||
|
||||
serve(e, http.MethodGet, "/fail")
|
||||
serve(e, http.MethodGet, "/panic")
|
||||
serve(e, http.MethodGet, "/no/such/page?q=1")
|
||||
|
||||
assert.Equal(t, failBefore+1, requests("GET", "/fail", "500"))
|
||||
assert.Equal(t, panicBefore+1, requests("GET", "/panic", "500"), "Recover turns the panic into a 500")
|
||||
assert.Equal(t, missingBefore+1, requests("GET", unmatchedRoute, "404"))
|
||||
}
|
||||
|
||||
func observations(method, route string) uint64 {
|
||||
m := &dto.Metric{}
|
||||
_ = httpDuration.WithLabelValues(method, route).(prometheus.Metric).Write(m)
|
||||
return m.GetHistogram().GetSampleCount()
|
||||
}
|
||||
|
||||
func TestMiddlewareRecordsLatency(t *testing.T) {
|
||||
e := newEcho()
|
||||
before := observations("GET", "/items/:id")
|
||||
|
||||
serve(e, http.MethodGet, "/items/1")
|
||||
|
||||
assert.Equal(t, before+1, observations("GET", "/items/:id"))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// poolCollector reports the database connection pool's statistics on every scrape.
|
||||
type poolCollector struct {
|
||||
pool *pgxpool.Pool
|
||||
|
||||
acquiredConns *prometheus.Desc
|
||||
idleConns *prometheus.Desc
|
||||
totalConns *prometheus.Desc
|
||||
maxConns *prometheus.Desc
|
||||
acquires *prometheus.Desc
|
||||
acquireWait *prometheus.Desc
|
||||
emptyAcquires *prometheus.Desc
|
||||
canceledAcquires *prometheus.Desc
|
||||
newConns *prometheus.Desc
|
||||
maxLifetimeDestroyed *prometheus.Desc
|
||||
maxIdleDestroyed *prometheus.Desc
|
||||
}
|
||||
|
||||
// NewPoolCollector returns a collector for the musicserver_db_pool_* metrics of pool.
|
||||
// Register it once, for example with prometheus.MustRegister.
|
||||
func NewPoolCollector(pool *pgxpool.Pool) prometheus.Collector {
|
||||
desc := func(name, help string) *prometheus.Desc {
|
||||
return prometheus.NewDesc("musicserver_db_pool_"+name, help, nil, nil)
|
||||
}
|
||||
return &poolCollector{
|
||||
pool: pool,
|
||||
acquiredConns: desc("acquired_conns", "Connections in use."),
|
||||
idleConns: desc("idle_conns", "Idle connections."),
|
||||
totalConns: desc("total_conns", "Open connections, in use, idle and being opened."),
|
||||
maxConns: desc("max_conns", "Most connections the pool opens."),
|
||||
acquires: desc("acquires_total", "Connections handed out."),
|
||||
acquireWait: desc("acquire_wait_seconds_total", "Total time spent waiting for a connection."),
|
||||
emptyAcquires: desc("empty_acquires_total", "Acquires that had to wait because no connection was idle."),
|
||||
canceledAcquires: desc("canceled_acquires_total", "Acquires canceled while waiting, for example by a timeout."),
|
||||
newConns: desc("new_conns_total", "Connections opened."),
|
||||
maxLifetimeDestroyed: desc("max_lifetime_destroyed_total", "Connections closed for reaching their maximum lifetime."),
|
||||
maxIdleDestroyed: desc("max_idle_destroyed_total", "Connections closed for being idle too long."),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *poolCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
prometheus.DescribeByCollect(c, ch)
|
||||
}
|
||||
|
||||
func (c *poolCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
s := c.pool.Stat()
|
||||
gauge := func(d *prometheus.Desc, v float64) {
|
||||
ch <- prometheus.MustNewConstMetric(d, prometheus.GaugeValue, v)
|
||||
}
|
||||
counter := func(d *prometheus.Desc, v float64) {
|
||||
ch <- prometheus.MustNewConstMetric(d, prometheus.CounterValue, v)
|
||||
}
|
||||
gauge(c.acquiredConns, float64(s.AcquiredConns()))
|
||||
gauge(c.idleConns, float64(s.IdleConns()))
|
||||
gauge(c.totalConns, float64(s.TotalConns()))
|
||||
gauge(c.maxConns, float64(s.MaxConns()))
|
||||
counter(c.acquires, float64(s.AcquireCount()))
|
||||
counter(c.acquireWait, s.AcquireDuration().Seconds())
|
||||
counter(c.emptyAcquires, float64(s.EmptyAcquireCount()))
|
||||
counter(c.canceledAcquires, float64(s.CanceledAcquireCount()))
|
||||
counter(c.newConns, float64(s.NewConnsCount()))
|
||||
counter(c.maxLifetimeDestroyed, float64(s.MaxLifetimeDestroyCount()))
|
||||
counter(c.maxIdleDestroyed, float64(s.MaxIdleDestroyCount()))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPoolCollector(t *testing.T) {
|
||||
// The pool connects lazily, so no database is needed to read its statistics.
|
||||
pool, err := pgxpool.New(context.Background(), "postgres://user:pass@127.0.0.1:1/music?pool_max_conns=7")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
registry.MustRegister(NewPoolCollector(pool))
|
||||
|
||||
assert.Equal(t, 11, testutil.CollectAndCount(registry))
|
||||
require.NoError(t, testutil.GatherAndCompare(registry, strings.NewReader(`
|
||||
# HELP musicserver_db_pool_max_conns Most connections the pool opens.
|
||||
# TYPE musicserver_db_pool_max_conns gauge
|
||||
musicserver_db_pool_max_conns 7
|
||||
# HELP musicserver_db_pool_acquired_conns Connections in use.
|
||||
# TYPE musicserver_db_pool_acquired_conns gauge
|
||||
musicserver_db_pool_acquired_conns 0
|
||||
`), "musicserver_db_pool_max_conns", "musicserver_db_pool_acquired_conns"))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package metrics exposes Prometheus metrics: the Go runtime and process metrics that the
|
||||
// default registry collects, HTTP request metrics and MusicServer's own metrics.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Server serves /metrics on a port of its own, so it is never reachable through the
|
||||
// public port.
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
// NewServer creates a metrics server for the given port.
|
||||
func NewServer(port int) *Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /metrics", promhttp.Handler())
|
||||
return &Server{httpServer: &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}}
|
||||
}
|
||||
|
||||
// Handler returns the handler that serves /metrics.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.httpServer.Handler
|
||||
}
|
||||
|
||||
// Start listens on the port and serves metrics in the background. It returns an error
|
||||
// when the port cannot be opened.
|
||||
func (s *Server) Start() error {
|
||||
ln, err := net.Listen("tcp", s.httpServer.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logging.GetLogger().Info("Metrics server starting", zap.String("address", s.httpServer.Addr))
|
||||
go func() {
|
||||
if err := s.httpServer.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
logging.GetLogger().Error("Metrics server error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown stops the metrics server.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logging.Init("fatal", false)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestServerServesRuntimeMetrics(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
NewServer(0).Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "go_goroutines")
|
||||
assert.Contains(t, rec.Body.String(), "process_start_time_seconds")
|
||||
}
|
||||
|
||||
func TestServerServesOnlyMetrics(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
NewServer(0).Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
func TestServerStartAndShutdown(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, ln.Close())
|
||||
|
||||
s := NewServer(port)
|
||||
require.NoError(t, s.Start())
|
||||
resp, err := http.Get("http://" + ln.Addr().String() + "/metrics")
|
||||
require.NoError(t, err)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
assert.Contains(t, string(body), "go_goroutines")
|
||||
|
||||
require.NoError(t, s.Shutdown(context.Background()))
|
||||
_, err = http.Get("http://" + ln.Addr().String() + "/metrics")
|
||||
assert.Error(t, err, "the port is closed after shutdown")
|
||||
}
|
||||
|
||||
func TestServerStartFailsWhenPortIsTaken(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", ":0")
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
|
||||
assert.Error(t, NewServer(ln.Addr().(*net.TCPAddr).Port).Start())
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
apiPrefix = "/api/v1"
|
||||
bearerAuthName = "bearer"
|
||||
)
|
||||
|
||||
var bearerAuth = []map[string][]string{{bearerAuthName: {}}}
|
||||
|
||||
// APIError is the body of every error response.
|
||||
type APIError struct {
|
||||
status int
|
||||
Message string `json:"error" example:"Not Found" doc:"What went wrong"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return e.Message }
|
||||
func (e *APIError) GetStatus() int { return e.status }
|
||||
|
||||
func init() {
|
||||
// The API always returns [] for empty lists, never null.
|
||||
huma.DefaultArrayNullable = false
|
||||
|
||||
// Huma creates all its errors (validation, 404, ...) through NewError.
|
||||
huma.NewError = func(status int, msg string, errs ...error) huma.StatusError {
|
||||
var details []string
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
details = append(details, err.Error())
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
msg += ": " + strings.Join(details, "; ")
|
||||
}
|
||||
return &APIError{status: status, Message: msg}
|
||||
}
|
||||
}
|
||||
|
||||
func newAPI(e *echo.Echo) huma.API {
|
||||
config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version)
|
||||
// No $schema fields or Link headers in responses.
|
||||
config.CreateHooks = nil
|
||||
config.DocsRenderer = huma.DocsRendererScalar
|
||||
config.Info.Description = "API for the MusicServer application.\n\n" +
|
||||
"Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" +
|
||||
"Every client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; " +
|
||||
"send it back in the same header to keep using that queue. Without it, or when the queue has ended " +
|
||||
"(after a reset or a sync), a new queue is started."
|
||||
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||||
bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"},
|
||||
}
|
||||
return humaecho.New(e, config)
|
||||
}
|
||||
|
||||
// route describes an endpoint registered on /api/v1.
|
||||
type route struct {
|
||||
op huma.Operation
|
||||
// public endpoints need no token.
|
||||
public bool
|
||||
// locked endpoints answer 423 while a sync is running.
|
||||
locked bool
|
||||
// queue endpoints work on the client's play queue, see middleware.Queue.
|
||||
queue bool
|
||||
}
|
||||
|
||||
var queueHeaderParam = &huma.Param{
|
||||
Name: middleware.QueueHeader,
|
||||
In: "header",
|
||||
Description: "ID of the client's play queue, from an earlier response. Omit it to start a new queue.",
|
||||
Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"},
|
||||
}
|
||||
|
||||
// register adds the route's operation to the API.
|
||||
func register[I, O any](s *Server, a huma.API, r route, handler func(context.Context, *I) (*O, error)) {
|
||||
op := r.op
|
||||
op.Path = apiPrefix + r.op.Path
|
||||
if !r.public {
|
||||
op.Security = bearerAuth
|
||||
op.Errors = append(op.Errors, http.StatusUnauthorized)
|
||||
}
|
||||
if r.locked {
|
||||
op.Middlewares = append(op.Middlewares, s.syncLock(a))
|
||||
op.Errors = append(op.Errors, http.StatusLocked)
|
||||
}
|
||||
if r.queue {
|
||||
op.Middlewares = append(op.Middlewares, middleware.Queue(a, s.music.ResolveQueue))
|
||||
op.Parameters = append(op.Parameters, queueHeaderParam)
|
||||
}
|
||||
huma.Register(a, op, handler)
|
||||
if r.queue {
|
||||
documentQueueHeader(a, op)
|
||||
}
|
||||
}
|
||||
|
||||
// documentQueueHeader adds the X-Queue-Id header to the operation's success responses.
|
||||
func documentQueueHeader(a huma.API, op huma.Operation) {
|
||||
path := a.OpenAPI().Paths[op.Path]
|
||||
registered := map[string]*huma.Operation{
|
||||
http.MethodGet: path.Get, http.MethodPut: path.Put, http.MethodPost: path.Post, http.MethodDelete: path.Delete,
|
||||
}[op.Method]
|
||||
for status, resp := range registered.Responses {
|
||||
if !strings.HasPrefix(status, "2") {
|
||||
continue
|
||||
}
|
||||
if resp.Headers == nil {
|
||||
resp.Headers = map[string]*huma.Param{}
|
||||
}
|
||||
if _, ok := resp.Headers[middleware.QueueHeader]; !ok {
|
||||
resp.Headers[middleware.QueueHeader] = &huma.Param{
|
||||
Description: "ID of the client's play queue. Send it with the following requests.",
|
||||
Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncLock answers 423 Locked while a sync is running.
|
||||
func (s *Server) syncLock(api huma.API) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if s.sync.IsSyncing() {
|
||||
logging.GetLogger().Info("Syncing is in progress - request blocked")
|
||||
_ = huma.WriteErr(api, ctx, http.StatusLocked, "Syncing is in progress")
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// apiError maps backend errors to API errors.
|
||||
func apiError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
|
||||
return huma.Error404NotFound(err.Error())
|
||||
case errors.Is(err, backend.ErrInvalidName), errors.Is(err, backend.ErrInvalidImage):
|
||||
return huma.Error400BadRequest(err.Error())
|
||||
case errors.Is(err, backend.ErrCoversDisabled), errors.Is(err, backend.ErrUploadsUnavailable):
|
||||
return huma.Error503ServiceUnavailable(err.Error())
|
||||
case errors.Is(err, backend.ErrConflict):
|
||||
return huma.Error409Conflict(err.Error())
|
||||
case errors.Is(err, backend.ErrSyncing):
|
||||
return huma.Error423Locked(err.Error())
|
||||
case errors.Is(err, backend.ErrTooLarge):
|
||||
return huma.Error413RequestEntityTooLarge(err.Error())
|
||||
case errors.Is(err, backend.ErrUpstream):
|
||||
return huma.Error502BadGateway(err.Error())
|
||||
default:
|
||||
logging.GetLogger().Error("Request failed", zap.Error(err))
|
||||
return huma.Error500InternalServerError("Internal Server Error")
|
||||
}
|
||||
}
|
||||
|
||||
// echoContext returns the Echo context behind a Huma context.
|
||||
func echoContext(ctx huma.Context) *echo.Context {
|
||||
return humaecho.Unwrap(ctx)
|
||||
}
|
||||
|
||||
// noBody is the output of operations that only return a status.
|
||||
type noBody struct{}
|
||||
|
||||
// jsonBody wraps a JSON response body.
|
||||
type jsonBody[T any] struct {
|
||||
Body T
|
||||
}
|
||||
|
||||
func ok[T any](body T, err error) (*jsonBody[T], error) {
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &jsonBody[T]{Body: body}, nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type CharacterHandler struct {
|
||||
characters *backend.CharacterService
|
||||
}
|
||||
|
||||
func NewCharacterHandler(characters *backend.CharacterService) *CharacterHandler {
|
||||
return &CharacterHandler{characters: characters}
|
||||
}
|
||||
|
||||
// GetCharacterList godoc
|
||||
// @Summary Get list of characters
|
||||
// @Description Returns a list of all available characters
|
||||
// @Tags characters
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Router /characters [get]
|
||||
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
||||
characters, err := c.characters.List()
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, characters)
|
||||
}
|
||||
|
||||
// GetCharacter godoc
|
||||
// @Summary Get character image
|
||||
// @Description Returns the image for a specific character
|
||||
// @Tags characters
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Param name query string true "Character file name"
|
||||
// @Success 200 {file} file
|
||||
// @Failure 400 {string} string "invalid name"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Router /character [get]
|
||||
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
||||
f, err := c.characters.Open(ctx.QueryParam("name"))
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return serveFile(ctx, f, "")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type coverUploadInput struct {
|
||||
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
|
||||
RawBody []byte `contentType:"image/*" doc:"The image: JPEG, PNG or WebP, at most 10 MB"`
|
||||
}
|
||||
|
||||
// CoverSource is the body of PUT /soundtracks/{uuid}/cover/source.
|
||||
type CoverSource struct {
|
||||
Source string `json:"source" enum:"steamgriddb,anilist" doc:"Where the cover comes from"`
|
||||
ID string `json:"id" minLength:"1" maxLength:"50" doc:"The game's ID on SteamGridDB, or the anime's ID on AniList"`
|
||||
}
|
||||
|
||||
type coverSourceInput struct {
|
||||
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
|
||||
Body CoverSource
|
||||
}
|
||||
|
||||
func (s *Server) registerCovers(a huma.API) {
|
||||
op := func(id, method, path, summary, description string, errs ...int) huma.Operation {
|
||||
return huma.Operation{
|
||||
OperationID: id,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"covers"},
|
||||
Errors: errs,
|
||||
}
|
||||
}
|
||||
// covers returns the cover service, or ErrCoversDisabled when COVERS_PATH is not set.
|
||||
covers := func() (*backend.CoverService, error) {
|
||||
if s.covers == nil {
|
||||
return nil, apiError(backend.ErrCoversDisabled)
|
||||
}
|
||||
return s.covers, nil
|
||||
}
|
||||
|
||||
get := op("get-soundtrack-cover", http.MethodGet, "/soundtracks/{uuid}/cover", "Soundtrack cover",
|
||||
"Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.",
|
||||
http.StatusNotFound)
|
||||
get.Responses = fileResponses("The cover image", "image/jpeg", "image/png", "image/webp")
|
||||
register(s, a, route{op: get},
|
||||
func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) {
|
||||
if s.covers == nil {
|
||||
return nil, huma.Error404NotFound("Not Found")
|
||||
}
|
||||
path, err := s.covers.Path(ctx, in.id())
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return streamFile(path, "", false)
|
||||
})
|
||||
|
||||
upload := op("upload-soundtrack-cover", http.MethodPut, "/soundtracks/{uuid}/cover", "Upload a cover",
|
||||
"Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.",
|
||||
http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)
|
||||
upload.MaxBodyBytes = backend.MaxCoverBytes
|
||||
upload.DefaultStatus = http.StatusNoContent
|
||||
register(s, a, route{op: upload},
|
||||
func(ctx context.Context, in *coverUploadInput) (*struct{}, error) {
|
||||
c, err := covers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.Upload(ctx, uuid.MustParse(in.UUID), in.RawBody); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
source := op("set-soundtrack-cover-source", http.MethodPut, "/soundtracks/{uuid}/cover/source", "Pick a cover",
|
||||
"Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. "+
|
||||
"The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.",
|
||||
http.StatusBadRequest, http.StatusNotFound, http.StatusBadGateway, http.StatusServiceUnavailable)
|
||||
source.DefaultStatus = http.StatusNoContent
|
||||
register(s, a, route{op: source},
|
||||
func(ctx context.Context, in *coverSourceInput) (*struct{}, error) {
|
||||
c, err := covers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.FromSource(ctx, uuid.MustParse(in.UUID), in.Body.Source, in.Body.ID); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
remove := op("delete-soundtrack-cover", http.MethodDelete, "/soundtracks/{uuid}/cover", "Remove the cover",
|
||||
"Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.",
|
||||
http.StatusNotFound, http.StatusServiceUnavailable)
|
||||
remove.DefaultStatus = http.StatusNoContent
|
||||
register(s, a, route{op: remove},
|
||||
func(ctx context.Context, in *uuidInput) (*struct{}, error) {
|
||||
c, err := covers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.Remove(ctx, in.id()); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func pngImage(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// newFakeAniList answers AniList lookups by ID: ID 42 has a cover, others do not.
|
||||
func newFakeAniList(t *testing.T) string {
|
||||
img := pngImage(t)
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write(img)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Variables struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"variables"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
media := any(nil)
|
||||
if req.Variables.ID == 42 {
|
||||
media = map[string]any{"id": 42, "coverImage": map[string]any{"extraLarge": server.URL + "/cover.png"}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": media}})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server.URL
|
||||
}
|
||||
|
||||
func withCovers(t *testing.T) func(*config.Config) {
|
||||
aniList := newFakeAniList(t)
|
||||
dir := filepath.Join(t.TempDir(), "covers")
|
||||
return func(c *config.Config) {
|
||||
c.CoversPath = dir
|
||||
c.AniListURL = aniList
|
||||
}
|
||||
}
|
||||
|
||||
func (e *testEnv) put(path, contentType string, body []byte) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestCoverUploadAndGet(t *testing.T) {
|
||||
e := newTestEnv(t, withCovers(t))
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, "no cover yet")
|
||||
|
||||
img := pngImage(t)
|
||||
rec := e.put(path, "image/png", img)
|
||||
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
||||
|
||||
rec = e.do(http.MethodGet, path)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
|
||||
assert.Equal(t, img, rec.Body.Bytes())
|
||||
assert.NotEmpty(t, rec.Header().Get("Last-Modified"))
|
||||
|
||||
var soundtrack backend.Soundtrack
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()).Body.Bytes(), &soundtrack))
|
||||
assert.NotNil(t, soundtrack.CoverModified)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, e.put(path, "image/png", []byte("not an image")).Code)
|
||||
big := append(pngImage(t), make([]byte, backend.MaxCoverBytes)...)
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge, e.put(path, "image/png", big).Code)
|
||||
assert.Equal(t, http.StatusNotFound,
|
||||
e.put("/api/v1/soundtracks/00000000-0000-0000-0000-000000000000/cover", "image/png", img).Code)
|
||||
}
|
||||
|
||||
func TestCoverPickFromSource(t *testing.T) {
|
||||
e := newTestEnv(t, withCovers(t))
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||
|
||||
rec := e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"42"}`)
|
||||
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"7"}`).Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPut, path+"/source", `{"source":"steamgriddb","id":"7"}`).Code,
|
||||
"SteamGridDB needs an API key")
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path+"/source", `{"source":"igdb","id":"7"}`).Code)
|
||||
}
|
||||
|
||||
func TestCoverDelete(t *testing.T) {
|
||||
e := newTestEnv(t, withCovers(t))
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||
require.Equal(t, http.StatusNoContent, e.put(path, "image/png", pngImage(t)).Code)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, path).Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code)
|
||||
}
|
||||
|
||||
func TestCoversDisabled(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, e.put(path, "image/png", pngImage(t)).Code)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, e.do(http.MethodDelete, path).Code)
|
||||
rec := e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"42"}`)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
body, _ := io.ReadAll(rec.Body)
|
||||
assert.Contains(t, string(body), "not enabled")
|
||||
}
|
||||
|
||||
func TestCoverWritesRequireToken(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth("secret"), withCovers(t))
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||
assert.Equal(t, http.StatusUnauthorized, e.put(path, "image/png", pngImage(t)).Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodDelete, path).Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, path).Code)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type DownloadHandler struct {
|
||||
releases *backend.ReleaseService
|
||||
}
|
||||
|
||||
func NewDownloadHandler(releases *backend.ReleaseService) *DownloadHandler {
|
||||
return &DownloadHandler{releases: releases}
|
||||
}
|
||||
|
||||
// CheckLatest godoc
|
||||
// @Summary Check for latest version
|
||||
// @Description Returns the latest version of the client application
|
||||
// @Tags download
|
||||
// @Produce json
|
||||
// @Success 200 {string} string
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download [get]
|
||||
func (d *DownloadHandler) CheckLatest(ctx *echo.Context) error {
|
||||
latest, err := d.releases.LatestVersion(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, latest)
|
||||
}
|
||||
|
||||
// ListAssetsOfLatest godoc
|
||||
// @Summary List assets of latest version
|
||||
// @Description Lists all assets available for the latest version
|
||||
// @Tags download
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/list [get]
|
||||
func (d *DownloadHandler) ListAssetsOfLatest(ctx *echo.Context) error {
|
||||
assets, err := d.releases.AssetNames(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, assets)
|
||||
}
|
||||
|
||||
// DownloadLatestWindows godoc
|
||||
// @Summary Download latest Windows version
|
||||
// @Description Redirects to download the latest Windows version
|
||||
// @Tags download
|
||||
// @Success 302 {string} string
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/windows [get]
|
||||
func (d *DownloadHandler) DownloadLatestWindows(ctx *echo.Context) error {
|
||||
return d.redirectToAsset(ctx, ".exe")
|
||||
}
|
||||
|
||||
// DownloadLatestLinux godoc
|
||||
// @Summary Download latest Linux version
|
||||
// @Description Redirects to download the latest Linux version
|
||||
// @Tags download
|
||||
// @Success 302 {string} string
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/linux [get]
|
||||
func (d *DownloadHandler) DownloadLatestLinux(ctx *echo.Context) error {
|
||||
return d.redirectToAsset(ctx, ".x86_64")
|
||||
}
|
||||
|
||||
func (d *DownloadHandler) redirectToAsset(ctx *echo.Context, suffix string) error {
|
||||
url, err := d.releases.AssetURL(ctx.Request().Context(), suffix)
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.Redirect(http.StatusFound, url)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// httpErrorHandler writes errors from Echo (unknown routes, panics, ...) as an APIError, like Huma does.
|
||||
func httpErrorHandler(c *echo.Context, err error) {
|
||||
if r, _ := echo.UnwrapResponse(c.Response()); r != nil && r.Committed {
|
||||
return
|
||||
}
|
||||
|
||||
code := http.StatusInternalServerError
|
||||
message := http.StatusText(code)
|
||||
var he *echo.HTTPError
|
||||
var sc echo.HTTPStatusCoder
|
||||
switch {
|
||||
case errors.As(err, &he):
|
||||
code = he.Code
|
||||
message = he.Message
|
||||
case errors.As(err, &sc):
|
||||
code = sc.StatusCode()
|
||||
}
|
||||
if code >= http.StatusInternalServerError && he == nil {
|
||||
logging.GetLogger().Error("Unhandled error", zap.String("path", c.Request().URL.Path), zap.Error(err))
|
||||
}
|
||||
if message == "" {
|
||||
message = http.StatusText(code)
|
||||
}
|
||||
|
||||
if c.Request().Method == http.MethodHead {
|
||||
_ = c.NoContent(code)
|
||||
return
|
||||
}
|
||||
_ = c.JSON(code, &APIError{Message: message})
|
||||
}
|
||||
+51
-40
@@ -1,61 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// serveFile sends a file with support for Range requests and caching headers.
|
||||
// contentType may be empty to detect it from the file extension.
|
||||
func serveFile(c *echo.Context, f *os.File, contentType string) error {
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
// fileResponses documents an endpoint that returns a file with Range support.
|
||||
func fileResponses(description string, contentTypes ...string) map[string]*huma.Response {
|
||||
content := map[string]*huma.MediaType{}
|
||||
for _, contentType := range contentTypes {
|
||||
content[contentType] = &huma.MediaType{Schema: &huma.Schema{Type: "string", Format: "binary"}}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
return map[string]*huma.Response{
|
||||
"200": {Description: description, Content: content},
|
||||
"206": {Description: "Part of the file, for a Range request", Content: content},
|
||||
}
|
||||
if contentType != "" {
|
||||
c.Response().Header().Set(echo.HeaderContentType, contentType)
|
||||
}
|
||||
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveSong streams a song. The write deadline is lifted because songs can take long to send to slow clients.
|
||||
func serveSong(c *echo.Context, path string, err error) error {
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
// streamFile opens path and returns a response that sends it with Range support.
|
||||
// contentType may be empty to detect it from the file extension. Audio has no write
|
||||
// deadline because songs can take long to send to slow clients.
|
||||
func streamFile(path, contentType string, audio bool) (*huma.StreamResponse, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
return nil, huma.Error404NotFound("Not Found")
|
||||
}
|
||||
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
|
||||
return serveFile(c, f, "audio/mpeg")
|
||||
return streamOpenFile(f, contentType, audio)
|
||||
}
|
||||
|
||||
// songError maps backend errors to HTTP errors.
|
||||
func songError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, backend.ErrInvalidName):
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, backend.ErrUpstream):
|
||||
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
|
||||
default:
|
||||
logging.GetLogger().Error("Request failed", zap.Error(err))
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
|
||||
func streamOpenFile(f *os.File, contentType string, audio bool) (*huma.StreamResponse, error) {
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
f.Close()
|
||||
return nil, huma.Error404NotFound("Not Found")
|
||||
}
|
||||
return &huma.StreamResponse{Body: func(ctx huma.Context) {
|
||||
defer f.Close()
|
||||
c := echoContext(ctx)
|
||||
if contentType != "" {
|
||||
c.Response().Header().Set("Content-Type", contentType)
|
||||
}
|
||||
if audio {
|
||||
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
|
||||
}
|
||||
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// streamSong streams the song at path, or returns err mapped to an API error.
|
||||
// The same URL returns a different song each time (random songs, next, previous),
|
||||
// so browsers must not cache it. Without this they cache the response because of
|
||||
// its Last-Modified header, and play the same song again.
|
||||
func streamSong(path string, err error) (*huma.StreamResponse, error) {
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
resp, err := streamFile(path, "audio/mpeg", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := resp.Body
|
||||
resp.Body = func(ctx huma.Context) {
|
||||
ctx.SetHeader("Cache-Control", "no-store")
|
||||
body(ctx)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"music-server/internal/config"
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -22,32 +23,93 @@ func TestMusicRandomAndQueue(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "0123456789")
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/rand")
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/rand")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "0123456789", rec.Body.String())
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addPlayed").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addPlayed").Code)
|
||||
|
||||
var info backend.SongInfo
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info))
|
||||
assert.Equal(t, "Game A", info.Soundtrack)
|
||||
assert.Equal(t, "Song", info.Song)
|
||||
|
||||
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/api/v1/music?song=0"} {
|
||||
for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music?song=0"} {
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPut, "/music/played?song=x").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/api/v1/music/played?song=0").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/api/v1/music/played?song=x").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/reset").Code)
|
||||
}
|
||||
|
||||
func TestMusicQueueID(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "x")
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/rand")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
first := rec.Header().Get("X-Queue-Id")
|
||||
require.NotEmpty(t, first, "a request without a queue ID gets a new queue")
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
|
||||
rec = e.do(http.MethodGet, "/api/v1/music/list")
|
||||
assert.Equal(t, first, rec.Header().Get("X-Queue-Id"), "the sent queue is kept")
|
||||
assert.Contains(t, rec.Body.String(), `"Song":"Song"`)
|
||||
|
||||
// Another client has its own queue.
|
||||
other := e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", "")
|
||||
assert.NotEqual(t, first, other.Header().Get("X-Queue-Id"))
|
||||
assert.JSONEq(t, "[]", other.Body.String())
|
||||
e.queueID = first
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/music/reset")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
second := rec.Header().Get("X-Queue-Id")
|
||||
assert.NotEqual(t, first, second, "reset starts a new queue")
|
||||
assert.JSONEq(t, "[]", e.do(http.MethodGet, "/api/v1/music/list").Body.String())
|
||||
|
||||
// The old ID has ended, so it gets a new queue too.
|
||||
rec = e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", first)
|
||||
assert.NotEqual(t, first, rec.Header().Get("X-Queue-Id"))
|
||||
|
||||
assert.Empty(t, e.do(http.MethodGet, "/api/v1/music/all", "X-Queue-Id", "").Header().Get("X-Queue-Id"),
|
||||
"endpoints that do not use the queue do not start one")
|
||||
}
|
||||
|
||||
func TestMusicQueueEndsAfterSync(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "x")
|
||||
e.do(http.MethodGet, "/api/v1/music/rand")
|
||||
e.do(http.MethodGet, "/api/v1/music/addQue")
|
||||
before := e.queueID
|
||||
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
|
||||
e.server.sync.Wait()
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/list")
|
||||
assert.NotEqual(t, before, rec.Header().Get("X-Queue-Id"))
|
||||
assert.JSONEq(t, "[]", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestMusicSongsAreNotCached(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "x")
|
||||
e.do(http.MethodGet, "/api/v1/music/rand")
|
||||
e.do(http.MethodGet, "/api/v1/music/addQue")
|
||||
for _, path := range []string{"/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic",
|
||||
"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/soundTest"} {
|
||||
rec := e.do(http.MethodGet, path)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMusicRangeRequest(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "0123456789")
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/rand", "Range", "bytes=2-5")
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/rand", "Range", "bytes=2-5")
|
||||
assert.Equal(t, http.StatusPartialContent, rec.Code)
|
||||
assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range"))
|
||||
assert.Equal(t, "2345", rec.Body.String())
|
||||
@@ -56,20 +118,21 @@ func TestMusicRangeRequest(t *testing.T) {
|
||||
|
||||
func TestMusicEmptyStates(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} {
|
||||
for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic"} {
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music?song=abc").Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music").Code)
|
||||
// Invalid or missing parameters fail validation.
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music?song=abc").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music").Code)
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/list")
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/list")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "null\n", rec.Body.String())
|
||||
assert.JSONEq(t, "[]", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestMusicSoundCheck(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/music/soundTest")
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/soundTest")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "sound check", rec.Body.String())
|
||||
}
|
||||
@@ -80,31 +143,41 @@ func TestMusicSoundtrackLists(t *testing.T) {
|
||||
e.addSong("A Game", "Song", "a")
|
||||
|
||||
var ordered []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all/order").Body.Bytes(), &ordered))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all/order").Body.Bytes(), &ordered))
|
||||
assert.Equal(t, []string{"A Game", "B Game"}, ordered)
|
||||
|
||||
var random []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all").Body.Bytes(), &random))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all").Body.Bytes(), &random))
|
||||
assert.ElementsMatch(t, ordered, random)
|
||||
}
|
||||
|
||||
func TestMusicDatabaseError(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.store.Err = errors.New("db down")
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/rand").Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/all/order").Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/rand").Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/all/order").Code)
|
||||
}
|
||||
|
||||
func TestCharacterEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
|
||||
var list []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/characters").Body.Bytes(), &list))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list))
|
||||
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list))
|
||||
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
|
||||
|
||||
rec := e.do(http.MethodGet, "/character?name=Adol.png")
|
||||
var details []struct {
|
||||
Name string `json:"name"`
|
||||
Modified int64 `json:"modified"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/details").Body.Bytes(), &details))
|
||||
require.Len(t, details, 2)
|
||||
assert.Equal(t, "Adol.png", details[0].Name)
|
||||
assert.Equal(t, "Aloy.jpg", details[1].Name)
|
||||
assert.Positive(t, details[0].Modified)
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/v1/character?name=Adol.png")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
|
||||
|
||||
@@ -112,13 +185,14 @@ func TestCharacterEndpoints(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type"))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/character?name=Missing.png").Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/character?name=Missing.png").Code)
|
||||
}
|
||||
|
||||
func TestCharacterPathTraversal(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd", ""} {
|
||||
rec := e.do(http.MethodGet, "/character?name="+name)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/character?name=").Code, "name is required")
|
||||
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} {
|
||||
rec := e.do(http.MethodGet, "/api/v1/character?name="+name)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, name)
|
||||
assert.NotContains(t, rec.Body.String(), "secret", name)
|
||||
}
|
||||
@@ -137,23 +211,43 @@ func TestDownloadEndpoints(t *testing.T) {
|
||||
url := newGitea(t, http.StatusOK)
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url })
|
||||
|
||||
rec := e.do(http.MethodGet, "/download")
|
||||
rec := e.do(http.MethodGet, "/api/v1/download")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, `"v2.0.0"`, rec.Body.String())
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/download/list")
|
||||
assert.JSONEq(t, `["Player.exe"]`, rec.Body.String())
|
||||
|
||||
rec = e.do(http.MethodGet, "/download/windows")
|
||||
rec = e.do(http.MethodGet, "/api/v1/download/windows")
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location"))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/download/linux").Code)
|
||||
// A release without Linux files, like those before 1.9.
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/download/linux").Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/download/linux-package").Code)
|
||||
}
|
||||
|
||||
func TestDownloadLinuxBinaryAndPackage(t *testing.T) {
|
||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"id":1,"name":"v1.9.0","assets":[` +
|
||||
`{"name":"MusicPlayer_1.9.0_linux.tar.gz","browser_download_url":"https://example.com/package"},` +
|
||||
`{"name":"MusicPlayer_1.9.0.x86_64","browser_download_url":"https://example.com/binary"}]}`))
|
||||
}))
|
||||
t.Cleanup(gitea.Close)
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea.URL })
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/v1/download/linux")
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
assert.Equal(t, "https://example.com/binary", rec.Header().Get("Location"), "the bare binary, for updating in place")
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/download/linux-package")
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
assert.Equal(t, "https://example.com/package", rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestDownloadUpstreamDown(t *testing.T) {
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) })
|
||||
for _, path := range []string{"/download", "/download/list", "/download/windows"} {
|
||||
for _, path := range []string{"/api/v1/download", "/api/v1/download/list", "/api/v1/download/windows"} {
|
||||
assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +282,6 @@ func TestTokenAuth(t *testing.T) {
|
||||
token := e.createToken("secret")
|
||||
auth := []string{"Authorization", "Bearer " + token}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/version").Code, "legacy routes need no token")
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
|
||||
@@ -237,6 +330,11 @@ func TestTokenRateLimit(t *testing.T) {
|
||||
assert.Contains(t, rec.Body.String(), "Rate limit exceeded")
|
||||
}
|
||||
|
||||
var (
|
||||
statsSoundtrackUUID = uuid.New()
|
||||
statsSongUUID = uuid.New()
|
||||
)
|
||||
|
||||
// statsStore returns canned statistics.
|
||||
type statsStore struct {
|
||||
repository.Querier
|
||||
@@ -246,8 +344,22 @@ type statsStore struct {
|
||||
func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
|
||||
return []repository.GetMostPlayedSoundtracksWithSongsRow{{
|
||||
SoundtrackID: int32(limit),
|
||||
SoundtrackUuid: statsSoundtrackUUID,
|
||||
SoundtrackName: "Game A",
|
||||
Songs: []byte(`[{"song_name":"Song","times_played":3}]`),
|
||||
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
|
||||
}}, s.err
|
||||
}
|
||||
|
||||
func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.GetAllSoundtracksWithSongsRow, error) {
|
||||
return []repository.GetAllSoundtracksWithSongsRow{{
|
||||
SoundtrackUuid: statsSoundtrackUUID,
|
||||
SoundtrackName: "Game A",
|
||||
AlternativeNames: []string{"GA"},
|
||||
CoverModified: pgtype.Timestamptz{Time: time.Unix(1730000000, 0), Valid: true},
|
||||
SoundtrackPlayed: 4,
|
||||
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
|
||||
}, {
|
||||
SoundtrackName: "Game B",
|
||||
}}, s.err
|
||||
}
|
||||
|
||||
@@ -255,6 +367,10 @@ func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatist
|
||||
return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err
|
||||
}
|
||||
|
||||
func statsService() *backend.StatisticsService {
|
||||
return backend.NewStatisticsService(statsStore{})
|
||||
}
|
||||
|
||||
func TestStatisticsEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.server.stats = backend.NewStatisticsService(statsStore{})
|
||||
@@ -265,14 +381,28 @@ func TestStatisticsEndpoints(t *testing.T) {
|
||||
require.Len(t, soundtracks, 1)
|
||||
assert.Equal(t, int32(10), soundtracks[0].SoundtrackID, "default limit")
|
||||
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
|
||||
assert.Equal(t, statsSoundtrackUUID, soundtracks[0].SoundtrackUuid)
|
||||
assert.Equal(t, statsSongUUID, soundtracks[0].Songs[0].SongUuid)
|
||||
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit=500").Body.Bytes(), &soundtracks))
|
||||
assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped")
|
||||
|
||||
for _, limit := range []string{"0", "-1", "abc"} {
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
|
||||
}
|
||||
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Body.Bytes(), &soundtracks))
|
||||
require.Len(t, soundtracks, 2, "every soundtrack, no limit")
|
||||
assert.Equal(t, "Game A", soundtracks[0].SoundtrackName)
|
||||
assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed)
|
||||
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
|
||||
assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list")
|
||||
assert.Equal(t, []string{"GA"}, soundtracks[0].AlternativeNames)
|
||||
require.NotNil(t, soundtracks[0].CoverModified)
|
||||
assert.Equal(t, int64(1730000000), *soundtracks[0].CoverModified)
|
||||
assert.Equal(t, []string{}, soundtracks[1].AlternativeNames, "no names is an empty list")
|
||||
assert.Nil(t, soundtracks[1].CoverModified)
|
||||
|
||||
var summary backend.StatisticsSummary
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary))
|
||||
assert.Equal(t, backend.StatisticsSummary{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, summary)
|
||||
@@ -280,4 +410,5 @@ func TestStatisticsEndpoints(t *testing.T) {
|
||||
e.server.stats = backend.NewStatisticsService(statsStore{err: errors.New("db down")})
|
||||
e.handler = e.server.RegisterRoutes()
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/summary").Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Code)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// HealthChecker reports the health of a dependency.
|
||||
type HealthChecker interface {
|
||||
Health(ctx context.Context) map[string]string
|
||||
}
|
||||
|
||||
type HealthHandler struct {
|
||||
db HealthChecker
|
||||
}
|
||||
|
||||
func NewHealthHandler(db HealthChecker) *HealthHandler {
|
||||
return &HealthHandler{db: db}
|
||||
}
|
||||
|
||||
// HealthCheck godoc
|
||||
//
|
||||
// @Summary Check server health
|
||||
// @Description Returns the health status of the server
|
||||
// @Tags health
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /health [get]
|
||||
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, h.db.Health(ctx.Request().Context()))
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"music-server/internal/config"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -48,6 +49,8 @@ type testEnv struct {
|
||||
db *fakeDatabase
|
||||
server *Server
|
||||
handler http.Handler
|
||||
// queueID is sent as X-Queue-Id and updated from responses, like a client keeping its queue.
|
||||
queueID string
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
@@ -67,6 +70,8 @@ func newTestEnv(t *testing.T, configure ...func(*config.Config)) *testEnv {
|
||||
FrontendPath: filepath.Join(base, "frontend"),
|
||||
DisableSessionAuth: true,
|
||||
ReleasesURL: "http://127.0.0.1:1",
|
||||
UploadMaxFileSize: 1 << 20,
|
||||
UploadMaxTotalSize: 3 << 20,
|
||||
}
|
||||
for _, c := range configure {
|
||||
c(&cfg)
|
||||
@@ -111,10 +116,16 @@ func (e *testEnv) doBody(method, path, body string, headers ...string) *httptest
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if e.queueID != "" {
|
||||
req.Header.Set(middleware.QueueHeader, e.queueID)
|
||||
}
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(rec, req)
|
||||
if id := rec.Header().Get(middleware.QueueHeader); id != "" {
|
||||
e.queueID = id
|
||||
}
|
||||
return rec
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type uuidInput struct {
|
||||
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack or song"`
|
||||
}
|
||||
|
||||
func (in *uuidInput) id() uuid.UUID {
|
||||
// The format is validated by Huma before the handler runs.
|
||||
return uuid.MustParse(in.UUID)
|
||||
}
|
||||
|
||||
// AlternativeNames is the body of PUT /soundtracks/{uuid}/alternative-names.
|
||||
type AlternativeNames struct {
|
||||
Names []string `json:"names" maxItems:"50" doc:"The new list of alternative names. Replaces the old list; send [] to remove them all."`
|
||||
}
|
||||
|
||||
type alternativeNamesInput struct {
|
||||
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
|
||||
Body AlternativeNames
|
||||
}
|
||||
|
||||
func (in *alternativeNamesInput) id() uuid.UUID {
|
||||
return uuid.MustParse(in.UUID)
|
||||
}
|
||||
|
||||
func (s *Server) registerLibrary(a huma.API) {
|
||||
l := s.library
|
||||
op := func(id, path, summary, description string, errs ...int) huma.Operation {
|
||||
return huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"library"},
|
||||
Errors: errs,
|
||||
}
|
||||
}
|
||||
|
||||
register(s, a, route{op: op("list-soundtracks", "/soundtracks", "List soundtracks",
|
||||
"Returns all soundtracks, sorted by name.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.Soundtrack], error) {
|
||||
return ok(l.Soundtracks(ctx))
|
||||
})
|
||||
register(s, a, route{op: op("get-soundtrack", "/soundtracks/{uuid}", "Get soundtrack",
|
||||
"Returns a soundtrack with its songs, sorted by name.", http.StatusNotFound)},
|
||||
func(ctx context.Context, in *uuidInput) (*jsonBody[backend.SoundtrackWithSongList], error) {
|
||||
return ok(l.Soundtrack(ctx, in.id()))
|
||||
})
|
||||
register(s, a, route{op: op("get-song", "/songs/{uuid}", "Get song",
|
||||
"Returns information about a song.", http.StatusNotFound)},
|
||||
func(ctx context.Context, in *uuidInput) (*jsonBody[backend.Song], error) {
|
||||
return ok(l.Song(ctx, in.id()))
|
||||
})
|
||||
|
||||
names := op("set-alternative-names", "/soundtracks/{uuid}/alternative-names", "Set alternative names",
|
||||
"Replaces the soundtrack's alternative names, which search and the cover search also use. "+
|
||||
"Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
|
||||
http.StatusNotFound)
|
||||
names.Method = http.MethodPut
|
||||
register(s, a, route{op: names},
|
||||
func(ctx context.Context, in *alternativeNamesInput) (*jsonBody[backend.Soundtrack], error) {
|
||||
for _, name := range in.Body.Names {
|
||||
if len(name) > backend.MaxNameLength {
|
||||
return nil, huma.Error422UnprocessableEntity(
|
||||
fmt.Sprintf("names can be at most %d bytes long", backend.MaxNameLength))
|
||||
}
|
||||
}
|
||||
soundtrack, err := l.SetAlternativeNames(ctx, in.id(), in.Body.Names)
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
s.soundtracksChanged(ctx)
|
||||
return ok(soundtrack, nil)
|
||||
})
|
||||
|
||||
file := op("get-song-file", "/songs/{uuid}/file", "Stream song",
|
||||
"Returns the audio file of a song.", http.StatusNotFound)
|
||||
file.Responses = fileResponses("The song (MP3)", audioMPEG)
|
||||
register(s, a, route{op: file, locked: true},
|
||||
func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) {
|
||||
return streamSong(l.SongPath(ctx, in.id()))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLibraryEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
song := e.addSong("Game A", "Song", "0123456789")
|
||||
e.addSong("Game B", "Other", "x")
|
||||
|
||||
var soundtracks []backend.Soundtrack
|
||||
rec := e.do(http.MethodGet, "/api/v1/soundtracks")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtracks))
|
||||
require.Len(t, soundtracks, 2)
|
||||
assert.Equal(t, "Game A", soundtracks[0].Name)
|
||||
assert.Equal(t, song.SoundtrackUuid, soundtracks[0].Uuid)
|
||||
|
||||
var soundtrack backend.SoundtrackWithSongList
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()).Body.Bytes(), &soundtrack))
|
||||
require.Len(t, soundtrack.Songs, 1)
|
||||
assert.Equal(t, song.Uuid, soundtrack.Songs[0].Uuid)
|
||||
|
||||
var info backend.Song
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String()).Body.Bytes(), &info))
|
||||
assert.Equal(t, "Song", info.Name)
|
||||
assert.Equal(t, "Game A", info.SoundtrackName)
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String()+"/file", "Range", "bytes=0-3")
|
||||
assert.Equal(t, http.StatusPartialContent, rec.Code)
|
||||
assert.Equal(t, "0123", rec.Body.String())
|
||||
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
func TestLibraryEndpointErrors(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
missing := uuid.New().String()
|
||||
for _, path := range []string{"/api/v1/soundtracks/" + missing, "/api/v1/songs/" + missing, "/api/v1/songs/" + missing + "/file"} {
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
for _, path := range []string{"/api/v1/soundtracks/not-a-uuid", "/api/v1/songs/123", "/api/v1/songs/123/file"} {
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryEndpointsRequireToken(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth("secret"))
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/soundtracks").Code)
|
||||
}
|
||||
|
||||
func TestSongFileLockedDuringSync(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
release := e.blockSync()
|
||||
defer release()
|
||||
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String()+"/file").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String()).Code, "song info stays available")
|
||||
}
|
||||
|
||||
func TestQueueResponsesIncludeUUIDs(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/rand").Code)
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
|
||||
|
||||
var info map[string]any
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info))
|
||||
assert.Equal(t, song.Uuid.String(), info["SongUuid"])
|
||||
assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"])
|
||||
|
||||
var list []map[string]any
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/list").Body.Bytes(), &list))
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"])
|
||||
}
|
||||
|
||||
func TestSetAlternativeNames(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
song := e.addSong("Final Fantasy VII", "Prelude", "x")
|
||||
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names"
|
||||
|
||||
rec := e.doBody(http.MethodPut, path, `{"names":[" FF7 ","ff7","Final Fantasy VII"]}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var soundtrack backend.Soundtrack
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtrack))
|
||||
assert.Equal(t, []string{"FF7"}, soundtrack.AlternativeNames)
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String())
|
||||
assert.Contains(t, rec.Body.String(), `"alternative_names":["FF7"]`)
|
||||
|
||||
// The search page finds it by the new name right away.
|
||||
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader("search_term=ff7"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec = httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(rec, req)
|
||||
assert.Contains(t, rec.Body.String(), "Final Fantasy VII")
|
||||
assert.Contains(t, rec.Body.String(), "Also known as FF7")
|
||||
}
|
||||
|
||||
func TestSetAlternativeNamesValidation(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names"
|
||||
|
||||
long := strings.Repeat("a", backend.MaxNameLength+1)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{"names":["`+long+`"]}`).Code)
|
||||
many := `{"names":["` + strings.Repeat(`x","`, 50) + `x"]}`
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, many).Code, "at most 50 names")
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{}`).Code, "names is required")
|
||||
assert.Equal(t, http.StatusUnprocessableEntity,
|
||||
e.doBody(http.MethodPut, "/api/v1/soundtracks/not-a-uuid/alternative-names", `{"names":[]}`).Code)
|
||||
}
|
||||
|
||||
func TestSetAlternativeNamesRequiresToken(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth("secret"))
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
rec := e.doBody(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names", `{"names":["A"]}`)
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// DeprecationMiddleware adds deprecation warning to responses
|
||||
// for old endpoints that are being phased out in favor of /api/v1/*
|
||||
func DeprecationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
// Add deprecation warning header
|
||||
c.Response().Header().Add("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
|
||||
c.Response().Header().Add("Deprecation", "true")
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,15 @@
|
||||
// Package middleware provides Echo middleware for the MusicServer application.
|
||||
// Package middleware provides Huma middleware for the MusicServer API.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
||||
)
|
||||
|
||||
// Middleware is a Huma middleware function.
|
||||
type Middleware = func(ctx huma.Context, next func(huma.Context))
|
||||
|
||||
// clientIP returns the client IP as resolved by Echo (respecting trusted proxies).
|
||||
func clientIP(ctx huma.Context) string {
|
||||
return humaecho.Unwrap(ctx).RealIP()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// QueueHeader carries the client's queue ID in requests and responses.
|
||||
const QueueHeader = "X-Queue-Id"
|
||||
|
||||
type queueKey struct{}
|
||||
|
||||
// QueueID returns the queue ID resolved by Queue.
|
||||
func QueueID(ctx context.Context) uuid.UUID {
|
||||
id, _ := ctx.Value(queueKey{}).(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
|
||||
// Queue resolves the queue ID in the X-Queue-Id header with resolve, which starts a new queue
|
||||
// when the header is missing or the queue has ended. The ID is returned in the same header.
|
||||
func Queue(api huma.API, resolve func(context.Context, string) (uuid.UUID, error)) Middleware {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
id, err := resolve(ctx.Context(), ctx.Header(QueueHeader))
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to resolve queue", zap.Error(err))
|
||||
_ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
ctx.SetHeader(QueueHeader, id.String())
|
||||
next(huma.WithValue(ctx, queueKey{}, id))
|
||||
}
|
||||
}
|
||||
@@ -6,26 +6,25 @@ import (
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RateLimit allows each client IP limit requests per window, with bursts up to limit.
|
||||
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc {
|
||||
config := echoMiddleware.DefaultRateLimiterConfig
|
||||
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
|
||||
func RateLimit(api huma.API, limit int, window time.Duration) Middleware {
|
||||
store := echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
|
||||
Rate: float64(limit) / window.Seconds(),
|
||||
Burst: limit,
|
||||
ExpiresIn: window,
|
||||
})
|
||||
config.DenyHandler = func(c *echo.Context, identifier string, err error) error {
|
||||
logging.GetLogger().Warn("Rate limit exceeded",
|
||||
zap.String("ip", identifier),
|
||||
zap.String("path", c.Request().URL.Path))
|
||||
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||
"error": "Rate limit exceeded. Please try again later.",
|
||||
})
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
ip := clientIP(ctx)
|
||||
if allowed, _ := store.Allow(ip); !allowed {
|
||||
logging.GetLogger().Warn("Rate limit exceeded", zap.String("ip", ip), zap.String("path", ctx.URL().Path))
|
||||
_ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.")
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
return echoMiddleware.RateLimiterWithConfig(config)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress.
|
||||
func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
if isSyncing() {
|
||||
logging.GetLogger().Info("Syncing is in progress - request blocked")
|
||||
return c.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,40 +9,49 @@ import (
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type tokenKey struct{}
|
||||
|
||||
// Token returns the session token of an authenticated request.
|
||||
func Token(ctx context.Context) string {
|
||||
token, _ := ctx.Value(tokenKey{}).(string)
|
||||
return token
|
||||
}
|
||||
|
||||
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
|
||||
func BearerToken(c *echo.Context) (string, bool) {
|
||||
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||
func BearerToken(header string) (string, bool) {
|
||||
token, ok := strings.CutPrefix(header, "Bearer ")
|
||||
return token, ok && token != "" && !strings.Contains(token, " ")
|
||||
}
|
||||
|
||||
// TokenAuth validates session tokens. When disabled is true all requests are let through.
|
||||
func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
if disabled {
|
||||
return next(c)
|
||||
// TokenAuth validates the session token for operations that declare a security requirement.
|
||||
// When disabled is true all requests are let through.
|
||||
func TokenAuth(api huma.API, q repository.Querier, disabled bool) Middleware {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if disabled || len(ctx.Operation().Security) == 0 {
|
||||
next(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Request().Header.Get("Authorization") == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
header := ctx.Header("Authorization")
|
||||
if header == "" {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Authorization header required")
|
||||
return
|
||||
}
|
||||
token, ok := BearerToken(c)
|
||||
token, ok := BearerToken(header)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid authorization format. Use: Bearer <token>")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
session, err := q.GetSession(ctx, token)
|
||||
session, err := q.GetSession(ctx.Context(), token)
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Invalid token attempt",
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("error", err.Error()),
|
||||
)
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
|
||||
logging.GetLogger().Warn("Invalid token attempt", zap.String("ip", clientIP(ctx)), zap.Error(err))
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt.Time) {
|
||||
@@ -51,12 +60,30 @@ func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
|
||||
if err := q.DeleteSession(ctx, token); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
|
||||
}
|
||||
}(context.WithoutCancel(ctx))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"})
|
||||
}(context.WithoutCancel(ctx.Context()))
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Token expired")
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("session", session)
|
||||
return next(c)
|
||||
next(huma.WithValue(ctx, tokenKey{}, token))
|
||||
}
|
||||
}
|
||||
|
||||
type clientKey struct{}
|
||||
|
||||
// Client holds request details that handlers need but that are not part of the API input.
|
||||
type Client struct {
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// ClientInfo stores the client's IP and user agent in the request context.
|
||||
func ClientInfo(ctx huma.Context, next func(huma.Context)) {
|
||||
next(huma.WithValue(ctx, clientKey{}, Client{IP: clientIP(ctx), UserAgent: ctx.Header("User-Agent")}))
|
||||
}
|
||||
|
||||
// ClientFrom returns the client details stored by ClientInfo.
|
||||
func ClientFrom(ctx context.Context) Client {
|
||||
client, _ := ctx.Value(clientKey{}).(Client)
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
type characterInput struct {
|
||||
Name string `query:"name" required:"true" doc:"File name of the character image, from the character list"`
|
||||
}
|
||||
|
||||
type redirectOutput struct {
|
||||
Status int
|
||||
Location string `header:"Location" doc:"Download URL of the asset"`
|
||||
}
|
||||
|
||||
type healthOutput struct {
|
||||
Body map[string]string
|
||||
}
|
||||
|
||||
func (s *Server) registerMisc(a huma.API) {
|
||||
get := func(id, path, tag, summary, description string, errs ...int) huma.Operation {
|
||||
return huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{tag},
|
||||
Errors: errs,
|
||||
}
|
||||
}
|
||||
|
||||
register(s, a, route{op: get("get-health", "/health", "server", "Health check",
|
||||
"Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error."),
|
||||
public: true},
|
||||
func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
|
||||
return &healthOutput{Body: s.db.Health(ctx)}, nil
|
||||
})
|
||||
register(s, a, route{op: get("get-version", "/version", "server", "Latest server version",
|
||||
"Returns the server version and its changelog.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) {
|
||||
return ok(backend.GetLatestVersion(), nil)
|
||||
})
|
||||
register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history",
|
||||
"Returns all server versions with their changelogs, newest first.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) {
|
||||
return ok(backend.GetVersionHistory(), nil)
|
||||
})
|
||||
|
||||
register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters",
|
||||
"Returns the file names of all character images.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(s.characters.List())
|
||||
})
|
||||
register(s, a, route{op: get("list-character-details", "/character/details", "characters",
|
||||
"List characters with change times",
|
||||
"Returns every character image with the time it was last changed, so clients can cache images and download only the changed ones.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.CharacterDetails], error) {
|
||||
return ok(s.characters.ListDetails())
|
||||
})
|
||||
image := get("get-character", "/character", "characters", "Character image",
|
||||
"Returns a character image.", http.StatusBadRequest, http.StatusNotFound)
|
||||
image.Responses = fileResponses("The image", "image/png", "image/jpeg")
|
||||
register(s, a, route{op: image},
|
||||
func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) {
|
||||
f, err := s.characters.Open(in.Name)
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return streamOpenFile(f, "", false)
|
||||
})
|
||||
|
||||
r := s.releases
|
||||
register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version",
|
||||
"Returns the name of the latest MusicPlayer release.", http.StatusBadGateway)},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
return ok(r.LatestVersion(ctx))
|
||||
})
|
||||
register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets",
|
||||
"Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway)},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(r.AssetNames(ctx))
|
||||
})
|
||||
download := func(id, path, platform, suffix string) {
|
||||
op := get(id, path, "download", "Download the latest client for "+platform,
|
||||
"Redirects to the download URL of the latest MusicPlayer release for "+platform+".",
|
||||
http.StatusNotFound, http.StatusBadGateway)
|
||||
op.DefaultStatus = http.StatusFound
|
||||
register(s, a, route{op: op},
|
||||
func(ctx context.Context, _ *struct{}) (*redirectOutput, error) {
|
||||
url, err := r.AssetURL(ctx, suffix)
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &redirectOutput{Status: http.StatusFound, Location: url}, nil
|
||||
})
|
||||
}
|
||||
download("download-client-windows", "/download/windows", "Windows", ".exe")
|
||||
download("download-client-linux", "/download/linux", "Linux", ".x86_64")
|
||||
// The package has the icon, a menu entry and install.sh. Releases before 1.9 have none (404).
|
||||
download("download-client-linux-package", "/download/linux-package", "Linux (package with installer)", "_linux.tar.gz")
|
||||
}
|
||||
+151
-218
@@ -1,240 +1,173 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type MusicHandler struct {
|
||||
music *backend.MusicService
|
||||
type songNumberInput struct {
|
||||
Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"`
|
||||
}
|
||||
|
||||
func NewMusicHandler(music *backend.MusicService) *MusicHandler {
|
||||
return &MusicHandler{music: music}
|
||||
// resetOutput returns the ID of the new queue.
|
||||
type resetOutput struct {
|
||||
QueueID string `header:"X-Queue-Id" doc:"ID of the new queue"`
|
||||
}
|
||||
|
||||
// GetSong godoc
|
||||
// @Summary Get a specific song
|
||||
// @Description Returns the song at the given position in the queue
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Param song query int true "Song number in the queue"
|
||||
// @Success 200 {file} file
|
||||
// @Success 206 {file} file
|
||||
// @Failure 400 {string} string "song must be a number"
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music [get]
|
||||
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
||||
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
||||
const audioMPEG = "audio/mpeg"
|
||||
|
||||
func (s *Server) registerMusic(a huma.API) {
|
||||
m := s.music
|
||||
audio := func(id, path, summary, description string, errs ...int) route {
|
||||
return route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"music"},
|
||||
Responses: fileResponses("The song (MP3)", audioMPEG),
|
||||
Errors: errs,
|
||||
},
|
||||
locked: true,
|
||||
}
|
||||
}
|
||||
status := func(id, method, path, summary, description string) route {
|
||||
return route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"music"},
|
||||
DefaultStatus: http.StatusOK,
|
||||
},
|
||||
locked: true,
|
||||
queue: true,
|
||||
}
|
||||
}
|
||||
list := func(id, path, summary, description string, locked bool) route {
|
||||
return route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"music"},
|
||||
},
|
||||
locked: locked,
|
||||
}
|
||||
}
|
||||
// queued marks a route that works on the client's play queue.
|
||||
queued := func(r route) route {
|
||||
r.queue = true
|
||||
return r
|
||||
}
|
||||
queue := middleware.QueueID
|
||||
|
||||
register(s, a, queued(audio("get-queue-song", "/music", "Play a song from the queue",
|
||||
"Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound)),
|
||||
func(ctx context.Context, in *songNumberInput) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.Song(ctx, queue(ctx), in.Song))
|
||||
})
|
||||
register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song",
|
||||
"Returns a random song for checking the volume.", http.StatusNotFound),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.SoundCheckSong())
|
||||
})
|
||||
register(s, a, queued(audio("get-random-song", "/music/rand", "Random song",
|
||||
"Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound)),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.RandomSong(ctx, queue(ctx)))
|
||||
})
|
||||
register(s, a, queued(audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played",
|
||||
"Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound)),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.RandomSongLowChance(ctx, queue(ctx)))
|
||||
})
|
||||
register(s, a, queued(audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal",
|
||||
"Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound)),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.RandomSongClassic(ctx, queue(ctx)))
|
||||
})
|
||||
register(s, a, queued(audio("get-next-song", "/music/next", "Next song in the queue",
|
||||
"Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound)),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.NextSong(ctx, queue(ctx)))
|
||||
})
|
||||
register(s, a, queued(audio("get-previous-song", "/music/previous", "Previous song in the queue",
|
||||
"Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound)),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.PreviousSong(ctx, queue(ctx)))
|
||||
})
|
||||
|
||||
register(s, a, queued(list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false)),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.SongInfo], error) {
|
||||
return ok(m.SongInfo(ctx, queue(ctx)))
|
||||
})
|
||||
register(s, a, queued(list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false)),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SongInfo], error) {
|
||||
return ok(m.PlayedSongs(ctx, queue(ctx)))
|
||||
})
|
||||
randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(m.SoundtrackNamesRandom(ctx))
|
||||
}
|
||||
register(s, a, list("get-soundtrack-names-random", "/music/all", "Soundtrack names, random order",
|
||||
"Returns the names of all soundtracks in random order.", true), randomNames)
|
||||
register(s, a, list("get-soundtrack-names-random-alias", "/music/all/random", "Soundtrack names, random order",
|
||||
"Same as /music/all.", true), randomNames)
|
||||
register(s, a, list("get-soundtrack-names", "/music/all/order", "Soundtrack names, sorted",
|
||||
"Returns the names of all soundtracks in alphabetical order.", true),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(m.SoundtrackNames(ctx))
|
||||
})
|
||||
|
||||
register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue",
|
||||
"Ends the queue, starts a new one and reloads the soundtrack list. "+
|
||||
"The new queue's ID is returned in the X-Queue-Id header; send it with the following requests."),
|
||||
func(ctx context.Context, _ *struct{}) (*resetOutput, error) {
|
||||
id, err := m.NewQueue(ctx, queue(ctx))
|
||||
if err != nil {
|
||||
return ctx.String(http.StatusBadRequest, "song must be a number")
|
||||
return nil, apiError(err)
|
||||
}
|
||||
path, err := m.music.Song(song)
|
||||
return serveSong(ctx, path, err)
|
||||
if err := m.ReloadSoundtracks(ctx); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
|
||||
// GetSoundCheckSong godoc
|
||||
// @Summary Get sound check song
|
||||
// @Description Returns the sound check song
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/soundTest [get]
|
||||
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
||||
path, err := m.music.SoundCheckSong()
|
||||
return serveSong(ctx, path, err)
|
||||
return &resetOutput{QueueID: id.String()}, nil
|
||||
})
|
||||
register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue",
|
||||
"Adds the song returned by the last random song request to the end of the queue."),
|
||||
func(ctx context.Context, _ *struct{}) (*noBody, error) {
|
||||
if err := m.AddLatestToQueue(ctx, queue(ctx)); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
|
||||
// ResetMusic godoc
|
||||
// @Summary Reset music state
|
||||
// @Description Resets the music state
|
||||
// @Tags music
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/reset [get]
|
||||
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
||||
if err := m.music.Reset(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
return &noBody{}, nil
|
||||
})
|
||||
register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played",
|
||||
"Increments the play counters of the current song and its soundtrack."),
|
||||
func(ctx context.Context, _ *struct{}) (*noBody, error) {
|
||||
if err := m.AddLatestPlayed(ctx, queue(ctx)); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
return &noBody{}, nil
|
||||
})
|
||||
register(s, a, status("mark-song-played", http.MethodPut, "/music/played", "Mark a queued song as played",
|
||||
"Increments the play counters of the song at the given queue position. Positions outside the queue are ignored."),
|
||||
func(ctx context.Context, in *songNumberInput) (*noBody, error) {
|
||||
logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song))
|
||||
if err := m.SetPlayed(ctx, queue(ctx), in.Song); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
|
||||
// GetRandomSong godoc
|
||||
// @Summary Get random song
|
||||
// @Description Returns a random song
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand [get]
|
||||
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
||||
path, err := m.music.RandomSong(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetRandomSongLowChance godoc
|
||||
// @Summary Get random song with low chance
|
||||
// @Description Returns a random song, favouring soundtracks that have been played less
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand/low [get]
|
||||
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
||||
path, err := m.music.RandomSongLowChance(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetRandomSongClassic godoc
|
||||
// @Summary Get random classic song
|
||||
// @Description Returns a random song where every song has the same chance
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand/classic [get]
|
||||
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
|
||||
path, err := m.music.RandomSongClassic(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetSongInfo godoc
|
||||
// @Summary Get current song info
|
||||
// @Description Returns information about the current song
|
||||
// @Tags music
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.SongInfo
|
||||
// @Router /music/info [get]
|
||||
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context()))
|
||||
}
|
||||
|
||||
// GetPlayedSongs godoc
|
||||
// @Summary Get played songs list
|
||||
// @Description Returns a list of played songs
|
||||
// @Tags music
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.SongInfo
|
||||
// @Router /music/list [get]
|
||||
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context()))
|
||||
}
|
||||
|
||||
// GetNextSong godoc
|
||||
// @Summary Get next song
|
||||
// @Description Returns the next song in the queue
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/next [get]
|
||||
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
||||
path, err := m.music.NextSong()
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetPreviousSong godoc
|
||||
// @Summary Get previous song
|
||||
// @Description Returns the previous song in the queue
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/previous [get]
|
||||
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
||||
path, err := m.music.PreviousSong()
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetAllSoundtracks godoc
|
||||
// @Summary Get all soundtracks
|
||||
// @Description Returns a list of all soundtracks in order
|
||||
// @Tags music
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/all/order [get]
|
||||
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
||||
names, err := m.music.SoundtrackNames(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, names)
|
||||
}
|
||||
|
||||
// GetAllSoundtracksRandom godoc
|
||||
// @Summary Get all soundtracks random
|
||||
// @Description Returns a list of all soundtracks in random order
|
||||
// @Tags music
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/all/random [get]
|
||||
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
||||
names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, names)
|
||||
}
|
||||
|
||||
// PutPlayed godoc
|
||||
// @Summary Mark song as played
|
||||
// @Description Marks the song at the given queue position as played
|
||||
// @Tags music
|
||||
// @Param song query int true "Song number in the queue"
|
||||
// @Success 200
|
||||
// @Failure 400 {string} string "Bad Request"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/played [put]
|
||||
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
||||
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
||||
if err != nil {
|
||||
return ctx.JSON(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
|
||||
if err := m.music.SetPlayed(ctx.Request().Context(), song); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// AddLatestToQue godoc
|
||||
// @Summary Add latest to queue
|
||||
// @Description Adds the latest song to the queue
|
||||
// @Tags music
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/addQue [get]
|
||||
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
||||
m.music.AddLatestToQueue()
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// AddLatestPlayed godoc
|
||||
// @Summary Add latest to played
|
||||
// @Description Marks the current song as played
|
||||
// @Tags music
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/addPlayed [get]
|
||||
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
|
||||
if err := m.music.AddLatestPlayed(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
return &noBody{}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/config"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// loadSpec loads the OpenAPI 3.0 version of the spec the server publishes.
|
||||
func loadSpec(t *testing.T, e *testEnv) *openapi3.T {
|
||||
t.Helper()
|
||||
rec := e.do(http.MethodGet, "/openapi-3.0.json")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
spec, err := openapi3.NewLoader().LoadFromData(rec.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
return spec
|
||||
}
|
||||
|
||||
func TestOpenAPISpecIsValid(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
spec := loadSpec(t, e)
|
||||
require.NoError(t, spec.Validate(context.Background()))
|
||||
|
||||
// The 3.1 spec is valid JSON with the same paths.
|
||||
var spec31 struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths map[string]any `json:"paths"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/openapi.json").Body.Bytes(), &spec31))
|
||||
assert.Equal(t, "3.1.0", spec31.OpenAPI)
|
||||
assert.Len(t, spec31.Paths, spec.Paths.Len())
|
||||
}
|
||||
|
||||
func TestOpenAPISpecWithoutServerMatchesServedSpec(t *testing.T) {
|
||||
served := newTestEnv(t).do(http.MethodGet, "/openapi.json").Body.Bytes()
|
||||
offline, err := OpenAPISpec().MarshalJSON()
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(served), string(offline))
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentsTheV1API(t *testing.T) {
|
||||
spec := loadSpec(t, newTestEnv(t))
|
||||
|
||||
var operations []string
|
||||
for path, item := range spec.Paths.Map() {
|
||||
assert.True(t, strings.HasPrefix(path, "/api/v1/"), "only /api/v1 is documented: %s", path)
|
||||
for method, op := range item.Operations() {
|
||||
operations = append(operations, method+" "+path)
|
||||
|
||||
public := path == "/api/v1/health" || (path == "/api/v1/token" && method == http.MethodPost)
|
||||
if public {
|
||||
assert.Nil(t, op.Security, "%s %s needs no token", method, path)
|
||||
} else {
|
||||
require.NotNil(t, op.Security, "%s %s needs a token", method, path)
|
||||
assert.Equal(t, openapi3.SecurityRequirements{{bearerAuthName: {}}}, *op.Security)
|
||||
assert.Contains(t, op.Responses.Map(), "401", "%s %s documents 401", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(operations)
|
||||
assert.Equal(t, []string{
|
||||
"DELETE /api/v1/soundtracks/uploads/{id}",
|
||||
"DELETE /api/v1/soundtracks/{uuid}/cover",
|
||||
"DELETE /api/v1/token",
|
||||
"GET /api/v1/character",
|
||||
"GET /api/v1/character/details",
|
||||
"GET /api/v1/character/list",
|
||||
"GET /api/v1/download",
|
||||
"GET /api/v1/download/linux",
|
||||
"GET /api/v1/download/linux-package",
|
||||
"GET /api/v1/download/list",
|
||||
"GET /api/v1/download/windows",
|
||||
"GET /api/v1/health",
|
||||
"GET /api/v1/music",
|
||||
"GET /api/v1/music/addPlayed",
|
||||
"GET /api/v1/music/addQue",
|
||||
"GET /api/v1/music/all",
|
||||
"GET /api/v1/music/all/order",
|
||||
"GET /api/v1/music/all/random",
|
||||
"GET /api/v1/music/info",
|
||||
"GET /api/v1/music/list",
|
||||
"GET /api/v1/music/next",
|
||||
"GET /api/v1/music/previous",
|
||||
"GET /api/v1/music/rand",
|
||||
"GET /api/v1/music/rand/classic",
|
||||
"GET /api/v1/music/rand/low",
|
||||
"GET /api/v1/music/reset",
|
||||
"GET /api/v1/music/soundTest",
|
||||
"GET /api/v1/songs/{uuid}",
|
||||
"GET /api/v1/songs/{uuid}/file",
|
||||
"GET /api/v1/soundtracks",
|
||||
"GET /api/v1/soundtracks/uploads/{id}",
|
||||
"GET /api/v1/soundtracks/{uuid}",
|
||||
"GET /api/v1/soundtracks/{uuid}/cover",
|
||||
"GET /api/v1/statistics/songs/least-played",
|
||||
"GET /api/v1/statistics/songs/most-played",
|
||||
"GET /api/v1/statistics/soundtracks",
|
||||
"GET /api/v1/statistics/soundtracks/last-played",
|
||||
"GET /api/v1/statistics/soundtracks/least-played",
|
||||
"GET /api/v1/statistics/soundtracks/most-played",
|
||||
"GET /api/v1/statistics/soundtracks/never-played",
|
||||
"GET /api/v1/statistics/soundtracks/oldest-played",
|
||||
"GET /api/v1/statistics/summary",
|
||||
"GET /api/v1/sync/progress",
|
||||
"GET /api/v1/version",
|
||||
"GET /api/v1/version/history",
|
||||
"POST /api/v1/soundtracks/uploads",
|
||||
"POST /api/v1/soundtracks/uploads/{id}/finish",
|
||||
"POST /api/v1/sync",
|
||||
"POST /api/v1/sync/full",
|
||||
"POST /api/v1/token",
|
||||
"POST /api/v1/token/cleanup",
|
||||
"PUT /api/v1/music/played",
|
||||
"PUT /api/v1/soundtracks/uploads/{id}/files/{file}",
|
||||
"PUT /api/v1/soundtracks/{uuid}/alternative-names",
|
||||
"PUT /api/v1/soundtracks/{uuid}/cover",
|
||||
"PUT /api/v1/soundtracks/{uuid}/cover/source",
|
||||
}, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented")
|
||||
|
||||
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
|
||||
assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme)
|
||||
}
|
||||
|
||||
// specValidator checks responses against the published spec.
|
||||
type specValidator struct {
|
||||
t *testing.T
|
||||
e *testEnv
|
||||
router routers.Router
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
func newSpecValidator(t *testing.T, e *testEnv) *specValidator {
|
||||
spec := loadSpec(t, e)
|
||||
spec.Servers = openapi3.Servers{{URL: "http://example.com"}}
|
||||
router, err := gorillamux.NewRouter(spec)
|
||||
require.NoError(t, err)
|
||||
return &specValidator{t: t, e: e, router: router, seen: map[string]bool{}}
|
||||
}
|
||||
|
||||
// check sends a request and validates the response status, headers and body against the spec.
|
||||
func (v *specValidator) check(method, path, body string, wantStatus int, headers ...string) *httptest.ResponseRecorder {
|
||||
v.t.Helper()
|
||||
rec := v.e.doBody(method, path, body, headers...)
|
||||
require.Equal(v.t, wantStatus, rec.Code, "%s %s: %s", method, path, rec.Body.String())
|
||||
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
route, params, err := v.router.FindRoute(req)
|
||||
require.NoError(v.t, err, "%s %s is not in the spec", method, path)
|
||||
v.seen[method+" "+route.Path] = true
|
||||
|
||||
input := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route},
|
||||
Status: rec.Code,
|
||||
Header: rec.Header(),
|
||||
Options: &openapi3filter.Options{IncludeResponseStatus: true},
|
||||
}
|
||||
input.SetBodyBytes(rec.Body.Bytes())
|
||||
if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "image/") {
|
||||
input.Body = io.NopCloser(bytes.NewReader(nil))
|
||||
input.Options.ExcludeResponseBody = true
|
||||
}
|
||||
assert.NoError(v.t, openapi3filter.ValidateResponse(context.Background(), input), "%s %s", method, path)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestResponsesMatchOpenAPI(t *testing.T) {
|
||||
gitea := newGitea(t, http.StatusOK)
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }, withCovers(t))
|
||||
v := newSpecValidator(t, e)
|
||||
song := e.addSong("Game A", "Song", "0123456789")
|
||||
songURL := "/api/v1/songs/" + song.Uuid.String()
|
||||
|
||||
// Empty states and errors.
|
||||
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music?song=x", "", http.StatusUnprocessableEntity)
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
|
||||
// Music.
|
||||
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/addQue", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/addPlayed", "", http.StatusOK)
|
||||
v.check(http.MethodPut, "/api/v1/music/played?song=0", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music?song=0", "", http.StatusPartialContent, "Range", "bytes=0-1")
|
||||
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/previous", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand/low", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand/classic", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/soundTest", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all/random", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all/order", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/reset", "", http.StatusOK)
|
||||
|
||||
// Library.
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK)
|
||||
v.check(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names",
|
||||
`{"names":["Game Alpha"]}`, http.StatusOK)
|
||||
v.check(http.MethodPut, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000/alternative-names",
|
||||
`{"names":[]}`, http.StatusNotFound)
|
||||
|
||||
// Covers.
|
||||
coverURL := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
|
||||
v.check(http.MethodGet, coverURL, "", http.StatusNotFound)
|
||||
v.check(http.MethodPut, coverURL, string(pngImage(t)), http.StatusNoContent, "Content-Type", "image/png")
|
||||
v.check(http.MethodPut, coverURL, "plain text", http.StatusBadRequest, "Content-Type", "image/png")
|
||||
v.check(http.MethodGet, coverURL, "", http.StatusOK)
|
||||
v.check(http.MethodPut, coverURL+"/source", `{"source":"anilist","id":"42"}`, http.StatusNoContent)
|
||||
v.check(http.MethodPut, coverURL+"/source", `{"source":"anilist","id":"7"}`, http.StatusNotFound)
|
||||
v.check(http.MethodDelete, coverURL, "", http.StatusNoContent)
|
||||
|
||||
// Uploads.
|
||||
started := v.check(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Uploaded"}`, http.StatusCreated)
|
||||
var upload backend.Upload
|
||||
require.NoError(t, json.Unmarshal(started.Body.Bytes(), &upload))
|
||||
up := "/api/v1/soundtracks/uploads/" + upload.ID.String()
|
||||
v.check(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Uploaded"}`, http.StatusConflict)
|
||||
v.check(http.MethodPut, up+"/files/01.mp3", "ID3 song", http.StatusOK, "Content-Type", "audio/mpeg")
|
||||
v.check(http.MethodPut, up+"/files/02.wav", "ID3 song", http.StatusBadRequest, "Content-Type", "audio/mpeg")
|
||||
v.check(http.MethodGet, up, "", http.StatusOK)
|
||||
v.check(http.MethodPost, up+"/finish", "", http.StatusOK)
|
||||
v.check(http.MethodGet, up, "", http.StatusNotFound)
|
||||
cancelled := v.check(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Cancelled"}`, http.StatusCreated)
|
||||
require.NoError(t, json.Unmarshal(cancelled.Body.Bytes(), &upload))
|
||||
v.check(http.MethodDelete, "/api/v1/soundtracks/uploads/"+upload.ID.String(), "", http.StatusNoContent)
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, songURL, "", http.StatusOK)
|
||||
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
|
||||
|
||||
// Characters, downloads, server info.
|
||||
v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/character/details", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/download", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/download/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/download/windows", "", http.StatusFound)
|
||||
v.check(http.MethodGet, "/api/v1/download/linux", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/download/linux-package", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/health", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK)
|
||||
|
||||
// Statistics (empty database).
|
||||
for _, path := range []string{
|
||||
"/soundtracks/most-played", "/soundtracks/least-played", "/soundtracks/last-played",
|
||||
"/soundtracks/oldest-played", "/songs/most-played", "/songs/least-played",
|
||||
} {
|
||||
v.check(http.MethodGet, "/api/v1/statistics"+path+"?limit=5", "", http.StatusOK)
|
||||
}
|
||||
v.check(http.MethodGet, "/api/v1/statistics/soundtracks/never-played", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/statistics/soundtracks", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
|
||||
|
||||
// Sync, including the locked state and the result after it finishes.
|
||||
release := e.blockSync()
|
||||
v.check(http.MethodPost, "/api/v1/sync", "", http.StatusOK)
|
||||
v.check(http.MethodPost, "/api/v1/sync/full", "", http.StatusLocked)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusLocked)
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
release()
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
|
||||
// Tokens and authentication.
|
||||
auth := newTestEnv(t, withAuth("secret"))
|
||||
va := newSpecValidator(t, auth)
|
||||
va.check(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`, http.StatusUnauthorized)
|
||||
rec := va.check(http.MethodPost, "/api/v1/token", `{"password":"secret","client_type":"api"}`, http.StatusOK)
|
||||
var token TokenResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &token))
|
||||
bearer := []string{"Authorization", "Bearer " + token.Token}
|
||||
va.check(http.MethodGet, "/api/v1/version", "", http.StatusUnauthorized)
|
||||
va.check(http.MethodPost, "/api/v1/token/cleanup", "", http.StatusOK, bearer...)
|
||||
va.check(http.MethodDelete, "/api/v1/token", "", http.StatusOK, bearer...)
|
||||
|
||||
// Statistics with data, through the canned statistics store.
|
||||
stats := newTestEnv(t)
|
||||
stats.server.stats = statsService()
|
||||
stats.handler = stats.server.RegisterRoutes()
|
||||
vs := newSpecValidator(t, stats)
|
||||
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK)
|
||||
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks", "", http.StatusOK)
|
||||
vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
|
||||
|
||||
// Every documented operation was checked at least once (statistics and tokens use other environments).
|
||||
spec := loadSpec(t, e)
|
||||
for path, item := range spec.Paths.Map() {
|
||||
for method := range item.Operations() {
|
||||
key := method + " " + path
|
||||
assert.True(t, v.seen[key] || va.seen[key] || vs.seen[key], "no response check for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-124
@@ -1,62 +1,36 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "music-server/cmd/docs" // registers the OpenAPI spec
|
||||
"music-server/cmd/web"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/metrics"
|
||||
"music-server/internal/server/middleware"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/labstack/echo/v5"
|
||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
||||
"github.com/swaggo/swag"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// @title MusicServer API
|
||||
// @version 1.0
|
||||
// @description API for the MusicServer application
|
||||
// @termsOfService http://sanplex.xyz/terms/
|
||||
|
||||
// @contact.name Sebastian Olsson
|
||||
// @contact.email zarnor91@gmail.com
|
||||
|
||||
// @license.name MIT
|
||||
// @license.url http://opensource.org/licenses/MIT
|
||||
|
||||
// @BasePath /
|
||||
|
||||
// handlers holds all HTTP handlers.
|
||||
type handlers struct {
|
||||
health *HealthHandler
|
||||
version *VersionHandler
|
||||
character *CharacterHandler
|
||||
download *DownloadHandler
|
||||
sync *SyncHandler
|
||||
music *MusicHandler
|
||||
statistics *StatisticsHandler
|
||||
token *TokenHandler
|
||||
}
|
||||
|
||||
func (s *Server) handlers() handlers {
|
||||
return handlers{
|
||||
health: NewHealthHandler(s.db),
|
||||
version: NewVersionHandler(),
|
||||
character: NewCharacterHandler(s.characters),
|
||||
download: NewDownloadHandler(s.releases),
|
||||
sync: NewSyncHandler(s.sync),
|
||||
music: NewMusicHandler(s.music),
|
||||
statistics: NewStatisticsHandler(s.stats),
|
||||
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes() http.Handler {
|
||||
e, _ := s.routes()
|
||||
return e
|
||||
}
|
||||
|
||||
// OpenAPISpec returns the API description. It needs no database or configuration:
|
||||
// the handlers are registered but never called.
|
||||
func OpenAPISpec() *huma.OpenAPI {
|
||||
_, a := (&Server{}).routes()
|
||||
return a.OpenAPI()
|
||||
}
|
||||
|
||||
func (s *Server) routes() (*echo.Echo, huma.API) {
|
||||
e := echo.New()
|
||||
e.HTTPErrorHandler = httpErrorHandler
|
||||
if len(s.cfg.TrustedProxies) > 0 {
|
||||
var trust []echo.TrustOption
|
||||
for _, ipNet := range s.cfg.TrustedProxies {
|
||||
@@ -66,106 +40,67 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
}
|
||||
|
||||
e.Use(logging.RequestLogger())
|
||||
// Outside Recover, so requests that panic are counted with status 500.
|
||||
e.Use(metrics.Middleware())
|
||||
e.Use(echoMiddleware.Recover())
|
||||
// Without allowed origins no CORS headers are sent, so only same-origin browser requests work.
|
||||
// The API uses Bearer tokens, not cookies, so credentials are not allowed.
|
||||
if len(s.cfg.CORSAllowedOrigins) > 0 {
|
||||
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||
AllowOrigins: []string{"https://*", "http://*"},
|
||||
AllowOrigins: s.cfg.CORSAllowedOrigins,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
AllowCredentials: true,
|
||||
AllowHeaders: []string{"Accept", "Authorization", "Cache-Control", "Content-Type", "Range", middleware.QueueHeader},
|
||||
ExposeHeaders: []string{middleware.QueueHeader, "Content-Range", "Accept-Ranges"},
|
||||
MaxAge: 300,
|
||||
}))
|
||||
}
|
||||
|
||||
e.GET("/openapi", serveOpenAPI)
|
||||
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
||||
// The API: /api/v1 is documented at /docs and /openapi.json.
|
||||
a := newAPI(e)
|
||||
a.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a, s.q, s.cfg.DisableSessionAuth))
|
||||
s.registerToken(a)
|
||||
s.registerMisc(a)
|
||||
s.registerSync(a)
|
||||
s.registerMusic(a)
|
||||
s.registerLibrary(a)
|
||||
s.registerCovers(a)
|
||||
s.registerUploads(a)
|
||||
s.registerStatistics(a)
|
||||
|
||||
// Older links to the spec and docs.
|
||||
e.GET("/openapi", redirect("/openapi.json"))
|
||||
e.GET("/swagger/*", redirect("/docs"))
|
||||
|
||||
// Search page and frontend.
|
||||
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
|
||||
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
|
||||
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
|
||||
e.GET("/config.js", s.frontendConfig)
|
||||
e.Static("/", s.cfg.FrontendPath)
|
||||
|
||||
h := s.handlers()
|
||||
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
|
||||
|
||||
// Legacy endpoints (deprecated - use /api/v1/ instead)
|
||||
legacy := e.Group("", middleware.DeprecationMiddleware)
|
||||
registerCommon(legacy, h, syncLock)
|
||||
legacy.GET("/health", h.health.HealthCheck)
|
||||
legacy.GET("/characters", h.character.GetCharacterList)
|
||||
|
||||
// API v1
|
||||
apiV1 := e.Group("/api/v1")
|
||||
tokenGroup := apiV1.Group("/token")
|
||||
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
|
||||
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
|
||||
|
||||
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
|
||||
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
|
||||
registerCommon(protected, h, syncLock)
|
||||
protected.GET("/character/list", h.character.GetCharacterList)
|
||||
|
||||
stats := protected.Group("/statistics")
|
||||
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
|
||||
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
|
||||
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
|
||||
stats.GET("/summary", h.statistics.GetStatisticsSummary)
|
||||
|
||||
for _, r := range e.Router().Routes() {
|
||||
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
|
||||
}
|
||||
return e
|
||||
return e, a
|
||||
}
|
||||
|
||||
// registerCommon registers the routes shared by the legacy and the v1 API.
|
||||
// syncLock is applied to routes that must not run during a sync.
|
||||
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) {
|
||||
g.GET("/version", h.version.GetLatestVersion)
|
||||
g.GET("/version/history", h.version.GetVersionHistory)
|
||||
|
||||
g.GET("/character", h.character.GetCharacter)
|
||||
|
||||
g.GET("/download", h.download.CheckLatest)
|
||||
g.GET("/download/list", h.download.ListAssetsOfLatest)
|
||||
g.GET("/download/windows", h.download.DownloadLatestWindows)
|
||||
g.GET("/download/linux", h.download.DownloadLatestLinux)
|
||||
|
||||
g.GET("/sync/progress", h.sync.SyncProgress)
|
||||
for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} {
|
||||
g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock)
|
||||
}
|
||||
for _, path := range []string{"/sync/full", "/sync/new/full"} {
|
||||
g.POST(path, h.sync.SyncSoundtracksNewFull, syncLock)
|
||||
}
|
||||
g.POST("/sync/reset", h.sync.ResetDB, syncLock)
|
||||
|
||||
music := g.Group("/music", syncLock)
|
||||
music.GET("", h.music.GetSong)
|
||||
music.GET("/soundTest", h.music.GetSoundCheckSong)
|
||||
music.GET("/reset", h.music.ResetMusic)
|
||||
music.GET("/rand", h.music.GetRandomSong)
|
||||
music.GET("/rand/low", h.music.GetRandomSongLowChance)
|
||||
music.GET("/rand/classic", h.music.GetRandomSongClassic)
|
||||
music.GET("/next", h.music.GetNextSong)
|
||||
music.GET("/previous", h.music.GetPreviousSong)
|
||||
music.GET("/all", h.music.GetAllSoundtracksRandom)
|
||||
music.GET("/all/order", h.music.GetAllSoundtracks)
|
||||
music.GET("/all/random", h.music.GetAllSoundtracksRandom)
|
||||
music.PUT("/played", h.music.PutPlayed)
|
||||
music.GET("/addQue", h.music.AddLatestToQue)
|
||||
music.GET("/addPlayed", h.music.AddLatestPlayed)
|
||||
// Info and list are read-only and stay available during a sync.
|
||||
g.GET("/music/info", h.music.GetSongInfo)
|
||||
g.GET("/music/list", h.music.GetPlayedSongs)
|
||||
}
|
||||
|
||||
// serveOpenAPI serves the embedded OpenAPI spec.
|
||||
func serveOpenAPI(c *echo.Context) error {
|
||||
doc, err := swag.ReadDoc()
|
||||
// frontendConfig serves the runtime config of the bundled frontend. The frontend uses
|
||||
// the API password to get a token, so anyone who can open the frontend can read it.
|
||||
func (s *Server) frontendConfig(c *echo.Context) error {
|
||||
cfg, err := json.Marshal(map[string]string{
|
||||
// Empty: the frontend calls this server with relative paths.
|
||||
"API_HOSTNAME": "",
|
||||
"API_PASSWORD": s.cfg.APIPassword,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Blob(http.StatusOK, echo.MIMEApplicationJSON, []byte(doc))
|
||||
c.Response().Header().Set("Cache-Control", "no-store")
|
||||
return c.Blob(http.StatusOK, "text/javascript; charset=utf-8", []byte("window.__RUNTIME_CONFIG__ = "+string(cfg)+";\n"))
|
||||
}
|
||||
|
||||
func redirect(to string) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
return c.Redirect(http.StatusMovedPermanently, to)
|
||||
}
|
||||
}
|
||||
+102
-38
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -27,64 +29,56 @@ func (e *testEnv) blockSync() (release func()) {
|
||||
func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
for _, path := range []string{"/sync", "/sync/new", "/sync/quick", "/sync/full", "/sync/new/full", "/sync/reset"} {
|
||||
for _, path := range []string{"/api/v1/sync", "/api/v1/sync/full", "/api/v1/sync/reset"} {
|
||||
// GET falls through to the frontend's static files, which answer 404.
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path)
|
||||
}
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.False(t, e.server.sync.IsSyncing(), "no sync was started")
|
||||
assert.Len(t, e.store.Songs, 1, "nothing was deleted")
|
||||
}
|
||||
|
||||
func TestSyncStartAndLock(t *testing.T) {
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
t.Run("prefix="+prefix, func(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
release := e.blockSync()
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, prefix+"/sync").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/info").Code, "info is available during sync")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/full").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/reset").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/music/rand").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/info").Code, "info is available during sync")
|
||||
|
||||
progress := e.do(http.MethodGet, prefix+"/sync/progress")
|
||||
progress := e.do(http.MethodGet, "/api/v1/sync/progress")
|
||||
assert.Equal(t, http.StatusOK, progress.Code)
|
||||
assert.Contains(t, progress.Body.String(), `"progress"`)
|
||||
|
||||
release()
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/all").Code)
|
||||
result := e.do(http.MethodGet, prefix+"/sync/progress")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/all").Code)
|
||||
result := e.do(http.MethodGet, "/api/v1/sync/progress")
|
||||
assert.Contains(t, result.Body.String(), `"total_time"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncResetEndpoint(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/sync/reset").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync/reset").Code)
|
||||
assert.Empty(t, e.store.Soundtracks)
|
||||
assert.Empty(t, e.store.Songs)
|
||||
}
|
||||
|
||||
func TestLegacyRoutesAreDeprecated(t *testing.T) {
|
||||
func TestLegacyRoutesAreRemoved(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/version")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "true", rec.Header().Get("Deprecation"))
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/version")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Empty(t, rec.Header().Get("Deprecation"))
|
||||
for _, path := range []string{"/version", "/health", "/music/list", "/music/rand", "/characters", "/sync/progress"} {
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, e.do(http.MethodPost, "/sync").Code)
|
||||
assert.False(t, e.server.sync.IsSyncing())
|
||||
}
|
||||
|
||||
func TestVersionEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
var latest backend.VersionData
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/version").Body.Bytes(), &latest))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/version").Body.Bytes(), &latest))
|
||||
assert.Equal(t, backend.GetLatestVersion(), latest)
|
||||
|
||||
var history []backend.VersionData
|
||||
@@ -94,26 +88,29 @@ func TestVersionEndpoints(t *testing.T) {
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/health")
|
||||
rec := e.do(http.MethodGet, "/api/v1/health")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestOpenAPIAndSwagger(t *testing.T) {
|
||||
func TestSyncAliasesAreRemoved(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
|
||||
for _, path := range []string{"/openapi", "/swagger/doc.json"} {
|
||||
rec := e.do(http.MethodGet, path)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
var spec struct {
|
||||
Paths map[string]map[string]any `json:"paths"`
|
||||
for _, path := range []string{"/api/v1/sync/new", "/api/v1/sync/quick", "/api/v1/sync/new/full"} {
|
||||
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, path).Code, path)
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec), path)
|
||||
assert.Contains(t, spec.Paths, "/api/v1/token", path)
|
||||
assert.Contains(t, spec.Paths["/sync/reset"], "post", path)
|
||||
assert.False(t, e.server.sync.IsSyncing())
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/swagger/index.html").Code)
|
||||
func TestDocsAndSpecLinks(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code)
|
||||
for _, path := range []string{"/openapi.json", "/openapi.yaml", "/openapi-3.0.json"} {
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
rec := e.do(http.MethodGet, "/openapi")
|
||||
assert.Equal(t, http.StatusMovedPermanently, rec.Code)
|
||||
assert.Equal(t, "/openapi.json", rec.Header().Get("Location"))
|
||||
assert.Equal(t, "/docs", e.do(http.MethodGet, "/swagger/index.html").Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestSearchPageAndFrontend(t *testing.T) {
|
||||
@@ -130,6 +127,17 @@ func TestSearchPageAndFrontend(t *testing.T) {
|
||||
assert.Contains(t, rec.Body.String(), "frontend")
|
||||
}
|
||||
|
||||
func TestFrontendConfigHasAPIPassword(t *testing.T) {
|
||||
e := newTestEnv(t, func(c *config.Config) { c.APIPassword = `pa'ss"word` })
|
||||
writeFile(t, filepath.Join(e.cfg.FrontendPath, "config.js"), "stale")
|
||||
|
||||
rec := e.do(http.MethodGet, "/config.js")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "text/javascript; charset=utf-8", rec.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
|
||||
assert.Equal(t, `window.__RUNTIME_CONFIG__ = {"API_HOSTNAME":"","API_PASSWORD":"pa'ss\"word"};`+"\n", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestShutdownWaitsForRequestsBeforeClosingDatabase(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
started := make(chan struct{})
|
||||
@@ -189,3 +197,59 @@ func TestTrustedProxies(t *testing.T) {
|
||||
assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1").Code)
|
||||
assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.2").Code)
|
||||
}
|
||||
|
||||
func TestCORSAllowedOrigins(t *testing.T) {
|
||||
e := newTestEnv(t, func(c *config.Config) { c.CORSAllowedOrigins = []string{"https://music.example.com"} })
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/list", "Origin", "https://music.example.com")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "https://music.example.com", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Expose-Headers"), "X-Queue-Id")
|
||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"))
|
||||
|
||||
preflight := e.do(http.MethodOptions, "/api/v1/music/list", "Origin", "https://music.example.com",
|
||||
"Access-Control-Request-Method", "GET", "Access-Control-Request-Headers", "authorization,x-queue-id")
|
||||
assert.Equal(t, http.StatusNoContent, preflight.Code)
|
||||
assert.Equal(t, "https://music.example.com", preflight.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Contains(t, preflight.Header().Get("Access-Control-Allow-Headers"), "X-Queue-Id")
|
||||
assert.Contains(t, preflight.Header().Get("Access-Control-Allow-Headers"), "Authorization")
|
||||
|
||||
other := e.do(http.MethodGet, "/api/v1/music/list", "Origin", "https://evil.example.com")
|
||||
assert.Empty(t, other.Header().Get("Access-Control-Allow-Origin"), "other origins get no CORS headers")
|
||||
}
|
||||
|
||||
func TestCORSWithoutAllowedOrigins(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/api/v1/version", "Origin", "https://music.example.com")
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "the request itself works; the browser blocks reading it")
|
||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
func TestMetricsAreNotServedOnTheMainPort(t *testing.T) {
|
||||
e := newTestEnv(t, func(c *config.Config) { c.MetricsPort = 2112 })
|
||||
require.NotNil(t, e.server.metrics)
|
||||
for _, path := range []string{"/metrics", "/api/v1/metrics"} {
|
||||
rec := e.do(http.MethodGet, path)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, path)
|
||||
assert.NotContains(t, rec.Body.String(), "go_goroutines", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsOff(t *testing.T) {
|
||||
assert.Nil(t, newTestEnv(t).server.metrics, "MetricsPort 0 means no metrics server")
|
||||
}
|
||||
|
||||
func TestRequestMetricsUseRoutePatterns(t *testing.T) {
|
||||
e := newTestEnv(t, func(c *config.Config) { c.MetricsPort = 2112 })
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String())
|
||||
e.do(http.MethodGet, "/no/such/page?secret=1")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
e.server.metrics.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `http_requests_total{code="200",method="GET",route="/api/v1/songs/:uuid"}`)
|
||||
assert.NotContains(t, body, song.Uuid.String(), "IDs never become labels")
|
||||
assert.NotContains(t, body, "secret", "query strings never become labels")
|
||||
assert.NotContains(t, body, "/no/such/page", "unknown URLs count under the frontend's /* route")
|
||||
}
|
||||
@@ -12,13 +12,15 @@ import (
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/metrics"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Database is the database connection used by the server.
|
||||
type Database interface {
|
||||
HealthChecker
|
||||
Health(ctx context.Context) map[string]string
|
||||
Close()
|
||||
}
|
||||
|
||||
@@ -27,12 +29,21 @@ type Server struct {
|
||||
db Database
|
||||
q repository.Querier
|
||||
music *backend.MusicService
|
||||
library *backend.LibraryService
|
||||
sync *backend.SyncService
|
||||
characters *backend.CharacterService
|
||||
releases *backend.ReleaseService
|
||||
stats *backend.StatisticsService
|
||||
uploads *backend.UploadService
|
||||
search *backend.SearchCache
|
||||
// covers is nil when COVERS_PATH is not set.
|
||||
covers *backend.CoverService
|
||||
// stopCovers ends the background cover search; coversDone is closed when it has ended.
|
||||
stopCovers context.CancelFunc
|
||||
coversDone chan struct{}
|
||||
httpServer *http.Server
|
||||
// metrics serves /metrics on its own port; nil when metrics are off.
|
||||
metrics *metrics.Server
|
||||
}
|
||||
|
||||
// New creates a Server with all services wired up.
|
||||
@@ -47,13 +58,28 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
|
||||
db: database,
|
||||
q: q,
|
||||
music: backend.NewMusicService(q, cfg.SoundCheckPath),
|
||||
library: backend.NewLibraryService(q),
|
||||
characters: characters,
|
||||
releases: backend.NewReleaseService(cfg.ReleasesURL),
|
||||
stats: backend.NewStatisticsService(q),
|
||||
}
|
||||
s.search = backend.NewSearchCache(s.music.SoundtrackNames)
|
||||
s.search = backend.NewSearchCache(s.music.SearchSoundtracks)
|
||||
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
|
||||
s.uploads = backend.NewUploadService(q, cfg.MusicPath, s.sync, s.library, cfg.UploadMaxFileSize, cfg.UploadMaxTotalSize)
|
||||
|
||||
if cfg.CoversPath != "" {
|
||||
s.covers, err = backend.NewCoverService(q, cfg.CoversPath, backend.CoverOptions{
|
||||
SteamGridDBKey: cfg.SteamGridDBAPIKey,
|
||||
SteamGridDBURL: cfg.SteamGridDBURL,
|
||||
AniListURL: cfg.AniListURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("covers path: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.MetricsPort > 0 {
|
||||
s.metrics = metrics.NewServer(cfg.MetricsPort)
|
||||
}
|
||||
s.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: s.RegisterRoutes(),
|
||||
@@ -64,14 +90,50 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// startCoverSearch runs the background cover search until Shutdown. It does nothing when
|
||||
// covers are off or there is no SteamGridDB API key.
|
||||
func (s *Server) startCoverSearch() {
|
||||
if s.covers == nil || !s.covers.SearchesAutomatically() {
|
||||
logging.GetLogger().Info("Automatic cover search is off",
|
||||
zap.Bool("covers_path_set", s.covers != nil))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.stopCovers, s.coversDone = cancel, make(chan struct{})
|
||||
go func() {
|
||||
defer close(s.coversDone)
|
||||
s.covers.Run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// soundtracksChanged reloads the cached soundtrack data after soundtracks were edited, and
|
||||
// lets the cover search try the new names.
|
||||
func (s *Server) soundtracksChanged(ctx context.Context) {
|
||||
if err := s.music.ReloadSoundtracks(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reload soundtracks", zap.Error(err))
|
||||
}
|
||||
if err := s.search.Reload(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reload search cache", zap.Error(err))
|
||||
}
|
||||
if s.covers != nil {
|
||||
s.covers.Trigger()
|
||||
}
|
||||
}
|
||||
|
||||
// afterSync refreshes cached data once a sync has finished.
|
||||
func (s *Server) afterSync(ctx context.Context) {
|
||||
if err := s.music.Reset(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err))
|
||||
if err := s.music.EndAllQueues(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to end the queues after sync", zap.Error(err))
|
||||
}
|
||||
if err := s.music.ReloadSoundtracks(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reload soundtracks after sync", zap.Error(err))
|
||||
}
|
||||
if err := s.search.Reload(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
|
||||
}
|
||||
if s.covers != nil {
|
||||
s.covers.Trigger()
|
||||
}
|
||||
}
|
||||
|
||||
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
|
||||
@@ -106,11 +168,23 @@ func NewServerInstance() *Server {
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create server", zap.Error(err))
|
||||
}
|
||||
prometheus.MustRegister(metrics.NewPoolCollector(database.Pool))
|
||||
s.uploads.RemoveOld()
|
||||
if err := s.sync.UpdateLibraryMetrics(ctx); err != nil {
|
||||
logger.Error("Failed to count the library", zap.Error(err))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server. It returns nil after a graceful shutdown.
|
||||
// ListenAndServe starts the HTTP server and the metrics server. It returns nil after a
|
||||
// graceful shutdown. The server keeps running without metrics if their port cannot be opened.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
if s.metrics != nil {
|
||||
if err := s.metrics.Start(); err != nil {
|
||||
logging.GetLogger().Error("Failed to start the metrics server", zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.startCoverSearch()
|
||||
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
|
||||
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
@@ -121,6 +195,13 @@ func (s *Server) ListenAndServe() error {
|
||||
// Shutdown stops accepting requests, waits for running requests and then closes the database.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
err := s.httpServer.Shutdown(ctx)
|
||||
if s.metrics != nil {
|
||||
err = errors.Join(err, s.metrics.Shutdown(ctx))
|
||||
}
|
||||
if s.stopCovers != nil {
|
||||
s.stopCovers()
|
||||
<-s.coversDone
|
||||
}
|
||||
s.sync.Wait()
|
||||
s.characters.Close()
|
||||
s.db.Close()
|
||||
|
||||
@@ -1,188 +1,76 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultStatsLimit = 10
|
||||
maxStatsLimit = 100
|
||||
)
|
||||
const maxStatsLimit = 100
|
||||
|
||||
// StatisticsHandler handles statistics-related HTTP requests
|
||||
type StatisticsHandler struct {
|
||||
stats *backend.StatisticsService
|
||||
type limitInput struct {
|
||||
Limit int `query:"limit" default:"10" minimum:"1" doc:"Number of results. Values above 100 are capped to 100."`
|
||||
}
|
||||
|
||||
// NewStatisticsHandler creates a new StatisticsHandler
|
||||
func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler {
|
||||
return &StatisticsHandler{stats: stats}
|
||||
func (in *limitInput) capped() int32 {
|
||||
return int32(min(in.Limit, maxStatsLimit))
|
||||
}
|
||||
|
||||
// parseLimit reads the "limit" query parameter, defaulting to 10 and capping at 100.
|
||||
func parseLimit(ctx *echo.Context) (int32, bool) {
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr == "" {
|
||||
return defaultStatsLimit, true
|
||||
func (s *Server) registerStatistics(a huma.API) {
|
||||
st := s.stats
|
||||
op := func(id, path, summary, description string) route {
|
||||
return route{op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: "/statistics" + path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"statistics"},
|
||||
}}
|
||||
}
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return 0, false
|
||||
soundtracks := func(id, path, summary, description string,
|
||||
query func(context.Context, int32) ([]backend.SoundtrackWithSongs, error)) {
|
||||
register(s, a, op(id, path, summary, description),
|
||||
func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
|
||||
return ok(query(ctx, in.capped()))
|
||||
})
|
||||
}
|
||||
return int32(min(limit, maxStatsLimit)), true
|
||||
}
|
||||
|
||||
// withLimit runs a statistics query that takes a limit and writes the result as JSON.
|
||||
func withLimit[T any](ctx *echo.Context, name string, query func(*echo.Context, int32) (T, error)) error {
|
||||
limit, ok := parseLimit(ctx)
|
||||
if !ok {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
result, err := query(ctx, limit)
|
||||
return statsResult(ctx, name, result, err)
|
||||
}
|
||||
|
||||
func statsResult[T any](ctx *echo.Context, name string, result T, err error) error {
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get statistics", zap.String("statistic", name), zap.Error(err))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
|
||||
//
|
||||
// @Summary Get most played soundtracks
|
||||
// @Description Returns the top N most played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/most-played [get]
|
||||
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "most played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.MostPlayedSoundtracks(c.Request().Context(), limit)
|
||||
songs := func(id, path, summary, description string,
|
||||
query func(context.Context, int32) ([]backend.SongInfoForStats, error)) {
|
||||
register(s, a, op(id, path, summary, description),
|
||||
func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SongInfoForStats], error) {
|
||||
return ok(query(ctx, in.capped()))
|
||||
})
|
||||
}
|
||||
|
||||
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
|
||||
//
|
||||
// @Summary Get least played soundtracks
|
||||
// @Description Returns the top N least played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/least-played [get]
|
||||
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.LeastPlayedSoundtracks(c.Request().Context(), limit)
|
||||
soundtracks("get-most-played-soundtracks", "/soundtracks/most-played", "Most played soundtracks",
|
||||
"Returns the most played soundtracks with their songs.", st.MostPlayedSoundtracks)
|
||||
soundtracks("get-least-played-soundtracks", "/soundtracks/least-played", "Least played soundtracks",
|
||||
"Returns the least played soundtracks with their songs.", st.LeastPlayedSoundtracks)
|
||||
soundtracks("get-last-played-soundtracks", "/soundtracks/last-played", "Recently played soundtracks",
|
||||
"Returns the most recently played soundtracks with their songs.", st.LastPlayedSoundtracks)
|
||||
soundtracks("get-oldest-played-soundtracks", "/soundtracks/oldest-played", "Least recently played soundtracks",
|
||||
"Returns the soundtracks played longest ago (played at least once) with their songs.", st.OldestPlayedSoundtracks)
|
||||
songs("get-most-played-songs", "/songs/most-played", "Most played songs",
|
||||
"Returns the most played songs with their soundtrack.", st.MostPlayedSongs)
|
||||
songs("get-least-played-songs", "/songs/least-played", "Least played songs",
|
||||
"Returns the least played songs with their soundtrack.", st.LeastPlayedSongs)
|
||||
|
||||
register(s, a, op("get-all-soundtrack-statistics", "/soundtracks", "All soundtracks with statistics",
|
||||
"Returns every soundtrack with its songs and play counts, sorted by name."),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
|
||||
return ok(st.AllSoundtracks(ctx))
|
||||
})
|
||||
register(s, a, op("get-never-played-soundtracks", "/soundtracks/never-played", "Never played soundtracks",
|
||||
"Returns all soundtracks that have never been played, with their songs."),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
|
||||
return ok(st.NeverPlayedSoundtracks(ctx))
|
||||
})
|
||||
register(s, a, op("get-statistics-summary", "/summary", "Statistics summary", "Returns overall play statistics."),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.StatisticsSummary], error) {
|
||||
return ok(st.Summary(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// GetMostPlayedSongs returns top N most played songs
|
||||
//
|
||||
// @Summary Get most played songs
|
||||
// @Description Returns the top N most played songs with their soundtrack info
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SongInfoForStats
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/songs/most-played [get]
|
||||
func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "most played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
|
||||
return h.stats.MostPlayedSongs(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetLeastPlayedSongs returns top N least played songs
|
||||
//
|
||||
// @Summary Get least played songs
|
||||
// @Description Returns the top N least played songs with their soundtrack info
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SongInfoForStats
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/songs/least-played [get]
|
||||
func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "least played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
|
||||
return h.stats.LeastPlayedSongs(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetLastPlayedSoundtracks returns the most recently played soundtracks
|
||||
//
|
||||
// @Summary Get last played soundtracks
|
||||
// @Description Returns the most recently played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/last-played [get]
|
||||
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "last played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.LastPlayedSoundtracks(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
|
||||
//
|
||||
// @Summary Get oldest played soundtracks
|
||||
// @Description Returns the least recently played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
|
||||
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.OldestPlayedSoundtracks(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||
//
|
||||
// @Summary Get never played soundtracks
|
||||
// @Description Returns all soundtracks that have never been played
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/never-played [get]
|
||||
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
|
||||
result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context())
|
||||
return statsResult(ctx, "never played soundtracks", result, err)
|
||||
}
|
||||
|
||||
// GetStatisticsSummary returns overall statistics
|
||||
//
|
||||
// @Summary Get statistics summary
|
||||
// @Description Returns overall play statistics
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.StatisticsSummary
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/summary [get]
|
||||
func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error {
|
||||
result, err := h.stats.Summary(ctx.Request().Context())
|
||||
return statsResult(ctx, "summary", result, err)
|
||||
}
|
||||
@@ -1,80 +1,85 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
type SyncHandler struct {
|
||||
sync *backend.SyncService
|
||||
func (s *Server) registerSync(a huma.API) {
|
||||
registry := a.OpenAPI().Components.Schemas
|
||||
progress := huma.Operation{
|
||||
OperationID: "get-sync-progress",
|
||||
Method: http.MethodGet,
|
||||
Path: "/sync/progress",
|
||||
Summary: "Sync progress or result",
|
||||
Description: "While a sync is running, returns its progress. Otherwise returns the result of the last sync.",
|
||||
Tags: []string{"sync"},
|
||||
Responses: map[string]*huma.Response{
|
||||
"200": {
|
||||
Description: "Progress of the running sync, or the result of the last sync",
|
||||
Content: map[string]*huma.MediaType{"application/json": {Schema: &huma.Schema{OneOf: []*huma.Schema{
|
||||
registry.Schema(reflect.TypeFor[backend.ProgressResponse](), true, ""),
|
||||
registry.Schema(reflect.TypeFor[backend.SyncResponse](), true, ""),
|
||||
}}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func NewSyncHandler(sync *backend.SyncService) *SyncHandler {
|
||||
return &SyncHandler{sync: sync}
|
||||
}
|
||||
|
||||
// SyncProgress godoc
|
||||
// @Summary Get sync progress
|
||||
// @Description Returns the progress of the running sync, or the result of the last sync
|
||||
// @Tags sync
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.SyncResponse
|
||||
// @Router /sync/progress [get]
|
||||
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
||||
register(s, a, route{op: progress},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) {
|
||||
if s.sync.IsSyncing() {
|
||||
return ctx.JSON(http.StatusOK, s.sync.Progress())
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, s.sync.Result())
|
||||
return ok[any](s.sync.Progress(), nil)
|
||||
}
|
||||
return ok[any](s.sync.Result(), nil)
|
||||
})
|
||||
|
||||
// SyncSoundtracksNewOnlyChanges godoc
|
||||
// @Summary Sync soundtracks with only changes
|
||||
// @Description Starts syncing soundtracks that have changed
|
||||
// @Tags sync
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Start syncing soundtracks"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync [post]
|
||||
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
||||
return s.start(ctx, false, "Start syncing soundtracks")
|
||||
start := func(id, path, summary, description string, full bool) {
|
||||
register(s, a, route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodPost,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"sync"},
|
||||
Errors: []int{http.StatusLocked},
|
||||
},
|
||||
}, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
message := "Start syncing soundtracks"
|
||||
if full {
|
||||
message += " full"
|
||||
}
|
||||
|
||||
// SyncSoundtracksNewFull godoc
|
||||
// @Summary Sync all soundtracks fully
|
||||
// @Description Starts a full sync of all soundtracks
|
||||
// @Tags sync
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Start syncing soundtracks full"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync/full [post]
|
||||
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
||||
return s.start(ctx, true, "Start syncing soundtracks full")
|
||||
}
|
||||
|
||||
func (s *SyncHandler) start(ctx *echo.Context, full bool, message string) error {
|
||||
if !s.sync.Start(full) {
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
return nil, huma.Error423Locked("Syncing is in progress")
|
||||
}
|
||||
logging.GetLogger().Info(message)
|
||||
return ctx.JSON(http.StatusOK, message)
|
||||
return ok(message, nil)
|
||||
})
|
||||
}
|
||||
start("start-sync", "/sync", "Sync changed soundtracks",
|
||||
"Starts syncing the music folder in the background, skipping soundtracks that have not changed. "+
|
||||
"Poll /sync/progress for the result.", false)
|
||||
start("start-full-sync", "/sync/full", "Sync all soundtracks",
|
||||
"Starts a full sync in the background that re-reads every soundtrack and removes songs whose files are gone. "+
|
||||
"Poll /sync/progress for the result.", true)
|
||||
|
||||
// ResetDB godoc
|
||||
// @Summary Reset soundtracks database
|
||||
// @Description Resets the soundtracks database by deleting all soundtracks and songs
|
||||
// @Tags sync
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync/reset [post]
|
||||
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
||||
reset := huma.Operation{
|
||||
OperationID: "reset-library",
|
||||
Method: http.MethodPost,
|
||||
Path: "/sync/reset",
|
||||
Summary: "Delete all soundtracks and songs",
|
||||
Description: "For debugging only.",
|
||||
Tags: []string{"sync"},
|
||||
Hidden: true,
|
||||
}
|
||||
register(s, a, route{op: reset, locked: true},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
logging.GetLogger().Info("Resetting soundtracks database")
|
||||
if err := s.sync.ResetDB(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
|
||||
return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx))
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
@@ -11,8 +12,8 @@ import (
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -20,27 +21,23 @@ const tokenLifetime = 24 * time.Hour
|
||||
|
||||
// TokenRequest represents a request to generate a new token
|
||||
type TokenRequest struct {
|
||||
Password string `json:"password"` // API password, required when API_PASSWORD is set
|
||||
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||
Password string `json:"password,omitempty" doc:"API password, required when the server has one configured"`
|
||||
ClientType string `json:"client_type,omitempty" doc:"Kind of client, for example web, godot or api" default:"web"`
|
||||
}
|
||||
|
||||
// TokenResponse represents the response with a new token
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Token string `json:"token" doc:"Send as Authorization: Bearer <token>"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ClientType string `json:"client_type"`
|
||||
}
|
||||
|
||||
// TokenHandler creates and removes session tokens.
|
||||
type TokenHandler struct {
|
||||
q repository.Querier
|
||||
apiPassword string
|
||||
disableSessionAuth bool
|
||||
type tokenInput struct {
|
||||
Body TokenRequest `required:"false"`
|
||||
}
|
||||
|
||||
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
|
||||
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
|
||||
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
|
||||
type statusMessage struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
@@ -51,115 +48,84 @@ func generateToken() (string, error) {
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (h *TokenHandler) passwordValid(password string) bool {
|
||||
if h.disableSessionAuth || h.apiPassword == "" {
|
||||
func (s *Server) passwordValid(password string) bool {
|
||||
if s.cfg.DisableSessionAuth || s.cfg.APIPassword == "" {
|
||||
return true
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1
|
||||
}
|
||||
|
||||
// CreateTokenHandler creates a new session token
|
||||
//
|
||||
// @Summary Create session token
|
||||
// @Description Returns a new session token for API access. Requires the API password when one is configured.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body TokenRequest true "Password and client type"
|
||||
// @Success 200 {object} TokenResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 429 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [post]
|
||||
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
var req TokenRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||
func (s *Server) registerToken(a huma.API) {
|
||||
create := huma.Operation{
|
||||
OperationID: "create-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/token",
|
||||
Summary: "Create session token",
|
||||
Description: "Returns a token for the other /api/v1 endpoints, valid for 24 hours. " +
|
||||
"Requires the API password when the server has one. Limited to 10 requests per minute per client.",
|
||||
Tags: []string{"auth"},
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
|
||||
Middlewares: huma.Middlewares{middleware.RateLimit(a, 10, time.Minute)},
|
||||
}
|
||||
|
||||
if !h.passwordValid(req.Password) {
|
||||
register(s, a, route{op: create, public: true},
|
||||
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
|
||||
client := middleware.ClientFrom(ctx)
|
||||
if !s.passwordValid(in.Body.Password) {
|
||||
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("user_agent", c.Request().UserAgent()))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
|
||||
zap.String("ip", client.IP), zap.String("user_agent", client.UserAgent))
|
||||
return nil, huma.Error401Unauthorized("Invalid password")
|
||||
}
|
||||
|
||||
if req.ClientType == "" {
|
||||
req.ClientType = "web"
|
||||
clientType := in.Body.ClientType
|
||||
if clientType == "" {
|
||||
clientType = "web"
|
||||
}
|
||||
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
|
||||
return nil, huma.Error500InternalServerError("Failed to generate token")
|
||||
}
|
||||
|
||||
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||
session, err := s.q.CreateSession(ctx, repository.CreateSessionParams{
|
||||
Token: token,
|
||||
IpAddress: c.RealIP(),
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
ClientType: &req.ClientType,
|
||||
IpAddress: client.IP,
|
||||
UserAgent: client.UserAgent,
|
||||
ClientType: &clientType,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to create session", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
|
||||
return nil, huma.Error500InternalServerError("Failed to create session")
|
||||
}
|
||||
return ok(TokenResponse{Token: session.Token, ExpiresAt: session.ExpiresAt.Time, ClientType: clientType}, nil)
|
||||
})
|
||||
|
||||
clientType := req.ClientType
|
||||
if session.ClientType != nil {
|
||||
clientType = *session.ClientType
|
||||
register(s, a, route{op: huma.Operation{
|
||||
OperationID: "delete-token",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/token",
|
||||
Summary: "Invalidate session token",
|
||||
Description: "Deletes the token used for this request.",
|
||||
Tags: []string{"auth"},
|
||||
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
|
||||
if err := s.q.DeleteSession(ctx, middleware.Token(ctx)); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
|
||||
return nil, huma.Error500InternalServerError("Failed to invalidate token")
|
||||
}
|
||||
return c.JSON(http.StatusOK, TokenResponse{
|
||||
Token: session.Token,
|
||||
ExpiresAt: session.ExpiresAt.Time,
|
||||
ClientType: clientType,
|
||||
return ok(statusMessage{Status: "token invalidated"}, nil)
|
||||
})
|
||||
|
||||
register(s, a, route{op: huma.Operation{
|
||||
OperationID: "cleanup-tokens",
|
||||
Method: http.MethodPost,
|
||||
Path: "/token/cleanup",
|
||||
Summary: "Remove expired tokens",
|
||||
Description: "Deletes all expired session tokens.",
|
||||
Tags: []string{"auth"},
|
||||
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
|
||||
if err := s.q.DeleteExpiredSessions(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
|
||||
return nil, huma.Error500InternalServerError("Failed to cleanup sessions")
|
||||
}
|
||||
return ok(statusMessage{Status: "cleanup complete"}, nil)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTokenHandler invalidates a session token
|
||||
//
|
||||
// @Summary Invalidate session token
|
||||
// @Description Deletes the current session token
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer token"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [delete]
|
||||
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
||||
if c.Request().Header.Get("Authorization") == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
}
|
||||
token, ok := middleware.BearerToken(c)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
|
||||
}
|
||||
|
||||
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
|
||||
}
|
||||
|
||||
// CleanupExpiredSessionsHandler removes all expired sessions
|
||||
//
|
||||
// @Summary Cleanup expired sessions
|
||||
// @Description Removes all expired session tokens from the database
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer token"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token/cleanup [post]
|
||||
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
|
||||
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
|
||||
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// UploadRequest is the body of POST /soundtracks/uploads.
|
||||
type UploadRequest struct {
|
||||
Name string `json:"name" minLength:"1" maxLength:"200" doc:"Name of the new soundtrack, also its folder name in the music folder"`
|
||||
}
|
||||
|
||||
type startUploadInput struct {
|
||||
Body UploadRequest
|
||||
}
|
||||
|
||||
type uploadIDInput struct {
|
||||
ID string `path:"id" format:"uuid" doc:"ID of the upload, from POST /soundtracks/uploads"`
|
||||
}
|
||||
|
||||
func (in *uploadIDInput) id() uuid.UUID {
|
||||
return uuid.MustParse(in.ID)
|
||||
}
|
||||
|
||||
type uploadFileInput struct {
|
||||
ID string `path:"id" format:"uuid" doc:"ID of the upload, from POST /soundtracks/uploads"`
|
||||
File string `path:"file" doc:"File name of the song, e.g. \"01 Title.mp3\". Plain names only: put songs from disc folders in one folder, e.g. \"1-01 Title.mp3\"."`
|
||||
RawBody []byte `contentType:"audio/mpeg" doc:"The MP3 file"`
|
||||
}
|
||||
|
||||
type createdUpload struct {
|
||||
Status int
|
||||
Body backend.Upload
|
||||
}
|
||||
|
||||
func (s *Server) registerUploads(a huma.API) {
|
||||
u := s.uploads
|
||||
op := func(id, method, path, summary, description string, errs ...int) route {
|
||||
return route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"uploads"},
|
||||
Errors: errs,
|
||||
},
|
||||
// Uploads and syncs never run at the same time.
|
||||
locked: true,
|
||||
}
|
||||
}
|
||||
|
||||
start := op("start-upload", http.MethodPost, "/soundtracks/uploads", "Start uploading a soundtrack",
|
||||
"Starts adding a new soundtrack. Send its songs with PUT /soundtracks/uploads/{id}/files/{file}, then "+
|
||||
"POST /soundtracks/uploads/{id}/finish. The name must be usable as a folder name, and no soundtrack, "+
|
||||
"folder or other upload may have it, ignoring case. Unfinished uploads are removed after a day.",
|
||||
http.StatusBadRequest, http.StatusConflict, http.StatusServiceUnavailable)
|
||||
start.op.DefaultStatus = http.StatusCreated
|
||||
register(s, a, start, func(ctx context.Context, in *startUploadInput) (*createdUpload, error) {
|
||||
upload, err := u.Start(ctx, in.Body.Name)
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &createdUpload{Status: http.StatusCreated, Body: upload}, nil
|
||||
})
|
||||
|
||||
register(s, a, op("get-upload", http.MethodGet, "/soundtracks/uploads/{id}", "Get an upload",
|
||||
"Returns an unfinished upload with the songs received so far, for example to resume it.", http.StatusNotFound),
|
||||
func(ctx context.Context, in *uploadIDInput) (*jsonBody[backend.Upload], error) {
|
||||
return ok(u.Get(in.id()))
|
||||
})
|
||||
|
||||
file := op("upload-song", http.MethodPut, "/soundtracks/uploads/{id}/files/{file}", "Upload a song",
|
||||
"Stores one song of the upload, replacing a song of the same name, so a failed song can be sent again. "+
|
||||
"Only MP3 files are accepted. The size limits are set on the server with UPLOAD_MAX_FILE_MB and UPLOAD_MAX_TOTAL_MB.",
|
||||
http.StatusBadRequest, http.StatusNotFound, http.StatusRequestEntityTooLarge)
|
||||
file.op.MaxBodyBytes = s.cfg.UploadMaxFileSize
|
||||
register(s, a, file, func(ctx context.Context, in *uploadFileInput) (*jsonBody[backend.UploadFile], error) {
|
||||
id, err := uuid.Parse(in.ID)
|
||||
if err != nil {
|
||||
return nil, huma.Error422UnprocessableEntity("invalid upload ID")
|
||||
}
|
||||
// With an encoded slash (%2F) in the path, Echo leaves the file name encoded.
|
||||
if decoded, err := url.PathUnescape(in.File); err == nil && strings.ContainsAny(decoded, `/\`) {
|
||||
return nil, huma.Error400BadRequest("plain file names only: put songs from disc folders in one folder, e.g. \"1-01 Title.mp3\"")
|
||||
}
|
||||
return ok(u.PutFile(id, in.File, in.RawBody))
|
||||
})
|
||||
|
||||
register(s, a, op("finish-upload", http.MethodPost, "/soundtracks/uploads/{id}/finish", "Finish an upload",
|
||||
"Moves the songs into a new folder in the music folder, adds the soundtrack to the library and returns it. "+
|
||||
"Refused when a soundtrack with the same songs exists.",
|
||||
http.StatusBadRequest, http.StatusNotFound, http.StatusConflict),
|
||||
func(ctx context.Context, in *uploadIDInput) (*jsonBody[backend.SoundtrackWithSongList], error) {
|
||||
soundtrack, err := u.Finish(ctx, in.id())
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
s.soundtracksChanged(ctx)
|
||||
return ok(soundtrack, nil)
|
||||
})
|
||||
|
||||
cancel := op("cancel-upload", http.MethodDelete, "/soundtracks/uploads/{id}", "Cancel an upload",
|
||||
"Deletes an unfinished upload and its songs.", http.StatusNotFound)
|
||||
cancel.op.DefaultStatus = http.StatusNoContent
|
||||
register(s, a, cancel, func(ctx context.Context, in *uploadIDInput) (*struct{}, error) {
|
||||
if err := u.Cancel(in.id()); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mp3Body(content string) []byte {
|
||||
return append([]byte("ID3"), content...)
|
||||
}
|
||||
|
||||
func (e *testEnv) startUpload(name string) backend.Upload {
|
||||
e.t.Helper()
|
||||
rec := e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"`+name+`"}`)
|
||||
require.Equal(e.t, http.StatusCreated, rec.Code, rec.Body.String())
|
||||
var upload backend.Upload
|
||||
require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &upload))
|
||||
return upload
|
||||
}
|
||||
|
||||
func uploadPath(upload backend.Upload) string {
|
||||
return "/api/v1/soundtracks/uploads/" + upload.ID.String()
|
||||
}
|
||||
|
||||
func TestUploadSoundtrackEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
upload := e.startUpload("Chrono Trigger")
|
||||
assert.Equal(t, "Chrono Trigger", upload.Name)
|
||||
|
||||
for _, song := range []string{"01 Wind Scene.mp3", "02 Corridors of Time.mp3"} {
|
||||
rec := e.put(uploadPath(upload)+"/files/"+url.PathEscape(song), "audio/mpeg", mp3Body(song))
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := e.do(http.MethodGet, uploadPath(upload))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), `"name":"02 Corridors of Time.mp3"`)
|
||||
|
||||
rec = e.do(http.MethodPost, uploadPath(upload)+"/finish")
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var soundtrack backend.SoundtrackWithSongList
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtrack))
|
||||
assert.Equal(t, "Chrono Trigger", soundtrack.Name)
|
||||
assert.Len(t, soundtrack.Songs, 2)
|
||||
|
||||
// It is in the library, the soundtrack lists and the search right away.
|
||||
assert.Contains(t, e.do(http.MethodGet, "/api/v1/soundtracks").Body.String(), `"name":"Chrono Trigger"`)
|
||||
assert.Contains(t, e.do(http.MethodGet, "/api/v1/music/all/order").Body.String(), "Chrono Trigger")
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, uploadPath(upload)).Code)
|
||||
}
|
||||
|
||||
func TestUploadEndpointErrors(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Kingdom Hearts", "Dearly Beloved", "x")
|
||||
|
||||
assert.Equal(t, http.StatusConflict, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"kingdom hearts"}`).Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"a/b"}`).Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":""}`).Code)
|
||||
|
||||
upload := e.startUpload("Game")
|
||||
assert.Equal(t, http.StatusBadRequest, e.put(uploadPath(upload)+"/files/song.wav", "audio/wav", mp3Body("x")).Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.put(uploadPath(upload)+"/files/song.mp3", "audio/mpeg", []byte("text")).Code)
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge,
|
||||
e.put(uploadPath(upload)+"/files/big.mp3", "audio/mpeg", mp3Body(strings.Repeat("x", 1<<20))).Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.put(uploadPath(upload)+"/files/Disc%201%2F01.mp3", "audio/mpeg", mp3Body("x")).Code,
|
||||
"an encoded / in the file name")
|
||||
rec := e.put(uploadPath(upload)+"/files/Disc-1/01.mp3", "audio/mpeg", mp3Body("x"))
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, "a / in the file name")
|
||||
assert.Contains(t, rec.Body.String(), "plain file names only")
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPost, uploadPath(upload)+"/finish").Code, "no songs yet")
|
||||
|
||||
missing := "/api/v1/soundtracks/uploads/00000000-0000-0000-0000-000000000000"
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, missing).Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.put(missing+"/files/a.mp3", "audio/mpeg", mp3Body("x")).Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodPost, missing+"/finish").Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodDelete, missing).Code)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, uploadPath(upload)).Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, uploadPath(upload)).Code)
|
||||
}
|
||||
|
||||
func TestUploadEndpointsLockedDuringSync(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
upload := e.startUpload("Game")
|
||||
require.Equal(t, http.StatusOK, e.put(uploadPath(upload)+"/files/01.mp3", "audio/mpeg", mp3Body("x")).Code)
|
||||
release := e.blockSync()
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
|
||||
|
||||
assert.Equal(t, http.StatusLocked, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Other"}`).Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, uploadPath(upload)+"/finish").Code)
|
||||
|
||||
release()
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, uploadPath(upload)+"/finish").Code)
|
||||
}
|
||||
|
||||
func TestUploadEndpointsRequireToken(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth("secret"))
|
||||
assert.Equal(t, http.StatusUnauthorized, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Game"}`).Code)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type VersionHandler struct{}
|
||||
|
||||
func NewVersionHandler() *VersionHandler {
|
||||
return &VersionHandler{}
|
||||
}
|
||||
|
||||
// GetVersionHistory godoc
|
||||
//
|
||||
// @Summary Getting the version history of the backend
|
||||
// @Description get version history
|
||||
// @Tags version
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.VersionData
|
||||
// @Router /version/history [get]
|
||||
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, backend.GetVersionHistory())
|
||||
}
|
||||
|
||||
// GetLatestVersion godoc
|
||||
//
|
||||
// @Summary Getting the latest version of the backend
|
||||
// @Description get latest version info
|
||||
// @Tags version
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.VersionData
|
||||
// @Router /version [get]
|
||||
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, backend.GetLatestVersion())
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// Store is an in-memory implementation of the queries used by the application.
|
||||
@@ -25,6 +26,8 @@ type Store struct {
|
||||
Soundtracks []repository.Soundtrack
|
||||
Songs []repository.Song
|
||||
Sessions map[string]repository.Session
|
||||
Queues []repository.Queue
|
||||
QueueSongs []repository.QueueSong
|
||||
|
||||
// Err, when set, is returned by every implemented query.
|
||||
Err error
|
||||
@@ -52,12 +55,15 @@ func (s *Store) AddSoundtrack(st repository.Soundtrack) repository.Soundtrack {
|
||||
return st
|
||||
}
|
||||
|
||||
// AddTestSong inserts a song and returns it with its UUID set.
|
||||
// AddTestSong inserts a song and returns it with its UUID and soundtrack UUID set.
|
||||
func (s *Store) AddTestSong(song repository.Song) repository.Song {
|
||||
defer s.lock()()
|
||||
if song.Uuid == uuid.Nil {
|
||||
song.Uuid = uuid.New()
|
||||
}
|
||||
if i := s.soundtrackIndex(song.SoundtrackID); song.SoundtrackUuid == uuid.Nil && i >= 0 {
|
||||
song.SoundtrackUuid = s.Soundtracks[i].Uuid
|
||||
}
|
||||
s.Songs = append(s.Songs, song)
|
||||
return song
|
||||
}
|
||||
@@ -162,9 +168,127 @@ func (s *Store) RemoveSoundtrackDeletionDate(_ context.Context, id int32) error
|
||||
return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.Deleted = nil })
|
||||
}
|
||||
|
||||
func (s *Store) AddSoundtrackPlayed(_ context.Context, id int32) error {
|
||||
func (s *Store) AddSoundtrackPlayedByUUID(_ context.Context, id uuid.UUID) error {
|
||||
defer s.lock()()
|
||||
now := time.Now()
|
||||
return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.TimesPlayed++; st.LastPlayed = &now })
|
||||
for i := range s.Soundtracks {
|
||||
if s.Soundtracks[i].Uuid == id {
|
||||
s.Soundtracks[i].TimesPlayed++
|
||||
s.Soundtracks[i].LastPlayed = &now
|
||||
}
|
||||
}
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetSoundtrackById(_ context.Context, id int32) (repository.Soundtrack, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.Soundtrack{}, s.Err
|
||||
}
|
||||
if i := s.soundtrackIndex(id); i >= 0 && s.Soundtracks[i].Deleted == nil {
|
||||
return s.Soundtracks[i], nil
|
||||
}
|
||||
return repository.Soundtrack{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository.Soundtrack, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.Soundtrack{}, s.Err
|
||||
}
|
||||
for _, st := range s.Soundtracks {
|
||||
if st.Uuid == id && st.Deleted == nil {
|
||||
return st, nil
|
||||
}
|
||||
}
|
||||
return repository.Soundtrack{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (s *Store) soundtrackByUUID(id uuid.UUID) int {
|
||||
return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.Uuid == id })
|
||||
}
|
||||
|
||||
func (s *Store) updateSoundtrackByUUID(id uuid.UUID, f func(*repository.Soundtrack)) error {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return s.Err
|
||||
}
|
||||
if i := s.soundtrackByUUID(id); i >= 0 {
|
||||
f(&s.Soundtracks[i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetAlternativeNames(_ context.Context, arg repository.SetAlternativeNamesParams) (repository.Soundtrack, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.Soundtrack{}, s.Err
|
||||
}
|
||||
i := s.soundtrackByUUID(arg.Uuid)
|
||||
if i < 0 || s.Soundtracks[i].Deleted != nil {
|
||||
return repository.Soundtrack{}, pgx.ErrNoRows
|
||||
}
|
||||
st := &s.Soundtracks[i]
|
||||
st.AlternativeNames = slices.Clone(arg.Names)
|
||||
if st.CoverFile == nil {
|
||||
st.CoverChecked = pgtype.Timestamptz{}
|
||||
}
|
||||
return *st, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListSoundtracksNeedingCover(
|
||||
_ context.Context, checkedBefore pgtype.Timestamptz,
|
||||
) ([]repository.ListSoundtracksNeedingCoverRow, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
var rows []repository.ListSoundtracksNeedingCoverRow
|
||||
for _, st := range sortedByName(slices.Clone(s.Soundtracks)) {
|
||||
if st.Deleted != nil || st.CoverFile != nil || st.CoverManual ||
|
||||
(st.CoverChecked.Valid && !st.CoverChecked.Time.Before(checkedBefore.Time)) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, repository.ListSoundtracksNeedingCoverRow{
|
||||
Uuid: st.Uuid, SoundtrackName: st.SoundtrackName, AlternativeNames: st.AlternativeNames,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetCover(_ context.Context, arg repository.SetCoverParams) error {
|
||||
return s.updateSoundtrackByUUID(arg.Uuid, func(st *repository.Soundtrack) {
|
||||
now := pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||
st.CoverFile, st.CoverSource, st.CoverExternalID = arg.File, arg.Source, arg.ExternalID
|
||||
st.CoverManual, st.CoverModified, st.CoverChecked = arg.Manual, now, now
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) MarkCoverChecked(_ context.Context, id uuid.UUID) error {
|
||||
return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) {
|
||||
st.CoverChecked = pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ClearCover(_ context.Context, id uuid.UUID) error {
|
||||
return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) {
|
||||
st.CoverFile, st.CoverSource, st.CoverExternalID, st.CoverManual = nil, nil, nil, false
|
||||
st.CoverModified, st.CoverChecked = pgtype.Timestamptz{}, pgtype.Timestamptz{}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListCoverFiles(context.Context) ([]string, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
var files []string
|
||||
for _, st := range s.Soundtracks {
|
||||
if st.CoverFile != nil {
|
||||
files = append(files, *st.CoverFile)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
|
||||
@@ -231,22 +355,46 @@ func (s *Store) UpdateSongByUUID(_ context.Context, arg repository.UpdateSongByU
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) AddSongPlayed(_ context.Context, arg repository.AddSongPlayedParams) error {
|
||||
func (s *Store) AddSongPlayedByUUID(_ context.Context, id uuid.UUID) error {
|
||||
defer s.lock()()
|
||||
for i := range s.Songs {
|
||||
if s.Songs[i].SoundtrackID == arg.SoundtrackID && s.Songs[i].SongName == arg.SongName {
|
||||
if s.Songs[i].Uuid == id {
|
||||
s.Songs[i].TimesPlayed++
|
||||
}
|
||||
}
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveBrokenSong(_ context.Context, arg repository.RemoveBrokenSongParams) error {
|
||||
func (s *Store) GetSongsBySoundtrackUUID(_ context.Context, id uuid.UUID) ([]repository.Song, error) {
|
||||
defer s.lock()()
|
||||
s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool {
|
||||
return song.SoundtrackID == arg.SoundtrackID && song.Path == arg.Path
|
||||
})
|
||||
return s.Err
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
var songs []repository.Song
|
||||
for _, song := range s.Songs {
|
||||
if song.SoundtrackUuid == id {
|
||||
songs = append(songs, song)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(songs, func(a, b repository.Song) int { return cmp.Compare(a.SongName, b.SongName) })
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetActiveSongByUUID(_ context.Context, id uuid.UUID) (repository.GetActiveSongByUUIDRow, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.GetActiveSongByUUIDRow{}, s.Err
|
||||
}
|
||||
for _, song := range s.Songs {
|
||||
if song.Uuid != id {
|
||||
continue
|
||||
}
|
||||
if i := s.soundtrackIndex(song.SoundtrackID); i >= 0 && s.Soundtracks[i].Deleted == nil {
|
||||
st := s.Soundtracks[i]
|
||||
return repository.GetActiveSongByUUIDRow{Song: song, SoundtrackName: st.SoundtrackName, SoundtrackPath: st.Path}, nil
|
||||
}
|
||||
}
|
||||
return repository.GetActiveSongByUUIDRow{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (s *Store) RemoveBrokenSongs(_ context.Context, uuids []uuid.UUID) error {
|
||||
@@ -325,3 +473,186 @@ func (s *Store) HasSession(token string) bool {
|
||||
_, ok := s.Sessions[token]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Store) queueIndex(id uuid.UUID) int {
|
||||
return slices.IndexFunc(s.Queues, func(q repository.Queue) bool { return q.Uuid == id })
|
||||
}
|
||||
|
||||
func (s *Store) updateQueue(id uuid.UUID, f func(*repository.Queue)) error {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return s.Err
|
||||
}
|
||||
if i := s.queueIndex(id); i >= 0 {
|
||||
f(&s.Queues[i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateQueue(context.Context) (repository.Queue, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.Queue{}, s.Err
|
||||
}
|
||||
queue := repository.Queue{Uuid: uuid.New(), CurrentSong: -1, StartedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}}
|
||||
s.Queues = append(s.Queues, queue)
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetQueue(_ context.Context, id uuid.UUID) (repository.Queue, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.Queue{}, s.Err
|
||||
}
|
||||
if i := s.queueIndex(id); i >= 0 {
|
||||
return s.Queues[i], nil
|
||||
}
|
||||
return repository.Queue{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func endQueue(q *repository.Queue) {
|
||||
if !q.EndedAt.Valid {
|
||||
q.EndedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) EndQueue(_ context.Context, id uuid.UUID) error {
|
||||
return s.updateQueue(id, endQueue)
|
||||
}
|
||||
|
||||
func (s *Store) EndActiveQueues(context.Context) error {
|
||||
defer s.lock()()
|
||||
for i := range s.Queues {
|
||||
endQueue(&s.Queues[i])
|
||||
}
|
||||
return s.Err
|
||||
}
|
||||
|
||||
func (s *Store) SetQueueLastFetched(_ context.Context, arg repository.SetQueueLastFetchedParams) error {
|
||||
return s.updateQueue(arg.Uuid, func(q *repository.Queue) {
|
||||
q.LastFetchedSongUuid, q.LastFetchedMode = arg.LastFetchedSongUuid, arg.LastFetchedMode
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) SetQueueCurrentSong(_ context.Context, arg repository.SetQueueCurrentSongParams) error {
|
||||
return s.updateQueue(arg.Uuid, func(q *repository.Queue) { q.CurrentSong = arg.CurrentSong })
|
||||
}
|
||||
|
||||
func (s *Store) AddLastFetchedToQueue(_ context.Context, id uuid.UUID) error {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return s.Err
|
||||
}
|
||||
i := s.queueIndex(id)
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
queue := &s.Queues[i]
|
||||
songIndex := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == queue.LastFetchedSongUuid })
|
||||
if songIndex < 0 {
|
||||
return nil
|
||||
}
|
||||
song := s.Songs[songIndex]
|
||||
var soundtrackName string
|
||||
if st := s.soundtrackIndex(song.SoundtrackID); st >= 0 {
|
||||
soundtrackName = s.Soundtracks[st].SoundtrackName
|
||||
}
|
||||
mode := "unknown"
|
||||
if queue.LastFetchedMode != nil {
|
||||
mode = *queue.LastFetchedMode
|
||||
}
|
||||
songNo := int32(0)
|
||||
for _, qs := range s.QueueSongs {
|
||||
if qs.QueueUuid == id {
|
||||
songNo = max(songNo, qs.SongNo+1)
|
||||
}
|
||||
}
|
||||
s.QueueSongs = append(s.QueueSongs, repository.QueueSong{
|
||||
QueueUuid: id, SongNo: songNo, SongUuid: song.Uuid, SoundtrackName: soundtrackName, SongName: song.SongName,
|
||||
Mode: mode,
|
||||
})
|
||||
queue.CurrentSong = songNo
|
||||
queue.LastFetchedSongUuid, queue.LastFetchedMode = uuid.Nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListQueueSongs(_ context.Context, id uuid.UUID) ([]repository.ListQueueSongsRow, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
var rows []repository.ListQueueSongsRow
|
||||
for _, qs := range s.QueueSongs {
|
||||
if qs.QueueUuid != id {
|
||||
continue
|
||||
}
|
||||
row := repository.ListQueueSongsRow{
|
||||
SongNo: qs.SongNo, SongUuid: qs.SongUuid, SoundtrackName: qs.SoundtrackName, SongName: qs.SongName, Mode: qs.Mode,
|
||||
}
|
||||
if i := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == qs.SongUuid }); i >= 0 {
|
||||
song := s.Songs[i]
|
||||
row.Path, row.TimesPlayed, row.SoundtrackUuid = &song.Path, &song.TimesPlayed, song.SoundtrackUuid
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
slices.SortFunc(rows, func(a, b repository.ListQueueSongsRow) int { return cmp.Compare(a.SongNo, b.SongNo) })
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountLibrary(context.Context) (repository.CountLibraryRow, error) {
|
||||
defer s.lock()()
|
||||
if s.Err != nil {
|
||||
return repository.CountLibraryRow{}, s.Err
|
||||
}
|
||||
var count repository.CountLibraryRow
|
||||
for _, st := range s.Soundtracks {
|
||||
if st.Deleted != nil {
|
||||
continue
|
||||
}
|
||||
count.Soundtracks++
|
||||
for _, song := range s.Songs {
|
||||
if song.SoundtrackID == st.ID {
|
||||
count.Songs++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Statistics queries return empty results; tests that need data use their own fakes.
|
||||
|
||||
func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetLeastPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetLeastPlayedSoundtracksWithSongsRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetLastPlayedSoundtracks(context.Context, int32) ([]repository.GetLastPlayedSoundtracksRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetOldestPlayedSoundtracks(context.Context, int32) ([]repository.GetOldestPlayedSoundtracksRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetAllSoundtracksWithSongs(context.Context) ([]repository.GetAllSoundtracksWithSongsRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetNeverPlayedSoundtracks(context.Context) ([]repository.GetNeverPlayedSoundtracksRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetMostPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetMostPlayedSongsWithSoundtrackRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetLeastPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetLeastPlayedSongsWithSoundtrackRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
|
||||
return repository.GetStatisticsSummaryRow{}, s.Err
|
||||
}
|
||||
@@ -15,12 +15,11 @@ sqlc-generate:
|
||||
migrate-create name:
|
||||
@migrate create -ext sql -dir internal/db/migrations -seq {{name}}
|
||||
|
||||
# Generate OpenAPI docs (version pinned in go.mod)
|
||||
swag-generate:
|
||||
@echo "Generating OpenAPI docs..."
|
||||
@go tool swag init -g internal/server/routes.go -o cmd/docs --parseInternal
|
||||
# Write openapi.json and openapi-3.0.json without starting the server, e.g. `just openapi ../MusicPlayer/api`
|
||||
openapi out=".":
|
||||
@go run ./cmd/openapi -out {{out}}
|
||||
|
||||
build: sqlc-generate templ-build swag-generate
|
||||
build: sqlc-generate templ-build
|
||||
@echo "Building..."
|
||||
@go build -o main cmd/main.go
|
||||
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user