17 Commits
Author SHA1 Message Date
SansanandClaude Opus 5.5 9b5a3c58c6 Pin the frontend image to v1.3.0
CI / test (push) Successful in 14s
CI / integration (push) Successful in 19s
CI / publish (push) Skipped
CI / docker (push) Successful in 22s
The server image used musicfrontend:latest, so it got whatever frontend was
newest when it was built. Pin the version instead, and bump it when a new
frontend is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:17:38 +02:00
SansanandClaude Opus 5.5 8da5468efb Show sync times as minutes and seconds
CI / integration (push) Successful in 19s
CI / test (push) Successful in 23s
CI / publish (push) Skipped
CI / docker (push) Successful in 46s
time_spent and total_time are now "MM:SS" instead of "HH:MM:SS". A sync longer
than an hour keeps counting minutes, e.g. "62:03".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:07:02 +02:00
SansanandClaude Opus 5.5 439ffede18 Build the image with Go 1.26
CI / integration (push) Successful in 18s
CI / test (push) Successful in 21s
CI / publish (push) Skipped
CI / docker (push) Successful in 50s
The Prometheus client and the golang.org/x updates raised go.mod to Go 1.26,
so the Docker build with golang:1.25-alpine failed in go mod download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:05 +02:00
SansanandClaude Opus 5.5 c70eb4d13c Add GET /api/v1/download/linux-package
CI / docker (push) Failing after 20s
CI / test (push) Successful in 23s
CI / integration (push) Successful in 25s
CI / publish (push) Skipped
Redirects to the newest MusicPlayer release's _linux.tar.gz, which has the
icon, a menu entry and install.sh. /download/linux keeps serving the bare
.x86_64, which updating in place needs. Releases without the package get 404.

Closes #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:40:22 +02:00
SansanandClaude Opus 5.5 bb8de5548d Add alternative names and cover art for soundtracks
CI / docker (push) Failing after 22s
CI / integration (push) Successful in 26s
CI / test (push) Successful in 26s
CI / publish (push) Skipped
Alternative names (#32):
- PUT /api/v1/soundtracks/{uuid}/alternative-names replaces a soundtrack's
  alternative names (trimmed, deduplicated, without the soundtrack's own name).
- alternative_names is in the soundtrack and statistics responses.
- /find matches them too and shows which name matched.

Covers (#12), stored in COVERS_PATH and off when it is not set:
- GET, PUT (upload) and DELETE /api/v1/soundtracks/{uuid}/cover, and
  PUT .../cover/source to pick a SteamGridDB game or AniList anime by ID.
  Uploaded and picked covers are never replaced automatically.
- With STEAMGRIDDB_API_KEY, a background job searches SteamGridDB, then
  AniList, by name, alternative names and the name without bracketed parts,
  at startup, after a sync and after names change. Search results are ranked
  by matching words, so "Civilization IV" gets "Sid Meier's Civilization IV".
- cover_modified (Unix seconds) is in the soundtrack and statistics responses,
  so clients can cache covers.

The new columns are added to migration 000006, which is not released yet.

Closes #12, closes #32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:57:18 +02:00
SansanandClaude Opus 5.5 f79f51ea4c Expose Prometheus metrics on a separate port
CI / integration (push) Failing after 14s
CI / docker (push) Failing after 20s
CI / test (push) Successful in 1m10s
CI / publish (push) Skipped
Metrics are served on METRICS_PORT (default 2112, "off" to disable), never on
the public port. Besides the Go runtime and process metrics this adds:

- http_requests_total and http_request_duration_seconds per route pattern,
  from an Echo middleware.
- musicserver_songs_requested_total and musicserver_songs_played_total by
  pick mode (random, low, classic). The mode is now stored with each queued
  song (migration 000006, not released yet).
- Library size, sync duration, last successful sync, broken songs from the
  last full sync, and the database connection pool.

Refs #35, #36, #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:57:58 +02:00
Sansan c62b65d7cd Add GET /api/v1/character/details with each image's change time
CI / integration (push) Successful in 21s
CI / test (push) Successful in 23s
CI / publish (push) Skipped
CI / docker (push) Successful in 48s
Returns every character image with when it was last changed, in Unix seconds,
so clients can cache the images and download only the ones that changed.
2026-10-07 21:57:26 +02:00
Sansan 76a1b946eb Stop browsers from caching songs
CI / docker (push) Successful in 1m6s
CI / test (push) Successful in 22s
CI / integration (push) Successful in 23s
CI / publish (push) Skipped
/music/rand and the other song endpoints return a different song from the
same URL, but the responses had a Last-Modified header and no Cache-Control,
so browsers cached them. The same song played again and the server never
picked a new one, so /music/addQue added nothing. Send Cache-Control:
no-store again, as the server did before the rewrite.

Also allow the Cache-Control request header in CORS, which the frontend
sends to bypass responses that browsers have already cached.
2026-10-07 20:09:30 +02:00
Sansan d31bf3551c Allow CORS origins from CORS_ALLOWED_ORIGINS
CI / test (push) Successful in 23s
CI / publish (push) Skipped
CI / docker (push) Successful in 46s
CI / integration (push) Successful in 19s
Echo compares allowed origins literally, so "https://*" and "http://*" never
matched a real origin and no CORS headers were sent. The allowed origins now
come from CORS_ALLOWED_ORIGINS (comma-separated, checked at startup). When it
is empty, only same-origin requests work, which is enough for the bundled
frontend.

Credentials are no longer allowed since the API uses Bearer tokens. Browsers
may now send Range and X-Queue-Id and read X-Queue-Id and Content-Range.
2026-10-07 20:03:13 +02:00
Sansan a705d7b476 Store each client's play queue in the database
The queue lived in memory and was shared by all clients, so a restart or a
finished sync cleared it. Every client now has its own queue in the new
queue and queue_song tables (added to the undeployed migration 000006).

The music endpoints return the queue ID in the X-Queue-Id header and expect
it back. A request without an ID, or with an ended one, starts a new queue.
/music/reset ends the queue and returns a new ID, and a sync ends all
running queues. Ended queues are kept as history.

The deprecated legacy routes without the /api/v1 prefix are removed.
2026-10-07 20:03:03 +02:00
Sansan b5dc42b654 Load every soundtrack when the search page opens
CI / integration (push) Successful in 18s
CI / test (push) Successful in 21s
CI / publish (push) Skipped
CI / docker (push) Successful in 44s
2026-10-07 16:46:31 +02:00
Sansan 080bacae80 Add GET /api/v1/statistics/soundtracks with every soundtrack and its songs
CI / integration (push) Successful in 18s
CI / test (push) Successful in 22s
CI / publish (push) Skipped
CI / docker (push) Successful in 41s
The other soundtrack statistics are capped at 100, so a client that shows every soundtrack (the MusicPlayer statistics tab) had no way to get them all. This one has no limit and is sorted by name.
2026-10-06 23:14:49 +02:00
Sansan fc7d787014 Serve the frontend's config.js with the API password
CI / docker (push) Successful in 47s
CI / integration (push) Successful in 17s
CI / test (push) Successful in 21s
CI / publish (push) Skipped
The bundled frontend gets a token with API_PASSWORD from config.js. The server now
generates config.js from its own config, so setting API_PASSWORD on the server
container is enough.
2026-10-06 19:01:17 +02:00
Sansan 6da96f79e7 Add a command to write the OpenAPI spec without a server
CI / test (push) Successful in 28s
CI / integration (push) Successful in 39s
CI / publish (push) Skipped
CI / docker (push) Successful in 51s
`just openapi [dir]` (go run ./cmd/openapi) writes openapi.json (3.1) and openapi-3.0.json without a database or configuration. A test checks it matches the spec the server serves.
2026-10-06 17:01:12 +02:00
Sansan 973ebf224b Generate the OpenAPI spec with Huma instead of swag
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
2026-10-06 16:31:06 +02:00
Sansan 0ec2425f78 Return every error as {"error": "..."}
Echo errors, middleware and handlers used three different formats (message object, error object, bare string). One error handler now writes all of them the same way; status codes are unchanged.
2026-10-06 16:21:14 +02:00
Sansan bc7d406e41 Remove Godot OpenAPI generator script
It produced code that doesn't parse in Godot 4. Typed response classes will be generated in MusicPlayer instead (Sansan/MusicPlayer#40).
2026-10-06 16:12:05 +02:00
88 changed files with 5901 additions and 6403 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ tmp_dir = "tmp"
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd" cmd = "go build -o ./tmp/main ./cmd"
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "swagger", "cmd/swagger"] exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
exclude_unchanged = false exclude_unchanged = false
+7
View File
@@ -10,3 +10,10 @@ main
# Database dumps # Database dumps
*_dump.sql *_dump.sql
# Generated with `just openapi`
/openapi.json
/openapi-3.0.json
# Local cover images (COVERS_PATH)
/covers/
+7 -5
View File
@@ -1,9 +1,9 @@
# The frontend is built and published separately; override to pin a version. # The frontend is built and published separately. Bump this when a new frontend is released.
ARG FRONTEND_IMAGE=gitea.sanplex.xyz/sansan/musicfrontend:latest ARG FRONTEND_IMAGE=gitea.sanplex.xyz/sansan/musicfrontend:v1.3.0
# Stage 1: Build the backend on the build machine and cross-compile for the target platform, # Stage 1: Build the backend on the build machine and cross-compile for the target platform,
# so multi-platform builds need no emulation. # 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 ARG TARGETOS TARGETARCH
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -18,8 +18,6 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
# Stage 2: Frontend static files (the same for every platform) # Stage 2: Frontend static files (the same for every platform)
FROM --platform=$BUILDPLATFORM ${FRONTEND_IMAGE} AS frontend 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. # Stage 3: Final image. No RUN steps here, so no emulation is needed for other architectures.
FROM alpine:3.22 FROM alpine:3.22
@@ -32,10 +30,14 @@ 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.
USER 1000:1000 USER 1000:1000
EXPOSE 8080 EXPOSE 8080
# Prometheus metrics (METRICS_PORT). Keep it off the public proxy.
EXPOSE 2112
VOLUME /sorted VOLUME /sorted
VOLUME /characters VOLUME /characters
# Required: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME, MUSIC_PATH, CHARACTERS_PATH # 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).
ENV PORT=8080 ENV PORT=8080
CMD ["./main"] CMD ["./main"]
-1780
View File
File diff suppressed because it is too large. Load diff
-224
View File
@@ -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
File diff suppressed because it is too large. Load diff
+51
View File
@@ -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
}
+28
View File
@@ -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)
}
}
+6
View File
@@ -135,6 +135,12 @@ main {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.matched-name {
margin: 0.25rem 0 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
#search_term { #search_term {
+27 -9
View File
@@ -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 // search returns the soundtracks matching searchText: exact matches first, then matches
// ignoring spaces and special characters, then matches with the letters in order. // ignoring spaces and special characters, then matches with the letters in order. Each
func search(searchText string, soundtrackData []backend.SoundtrackData) []string { // step tries the soundtrack's own name before its alternative names.
var results []string func search(searchText string, soundtrackData []backend.SoundtrackData) []SearchResult {
var results []SearchResult
seen := make(map[string]bool) seen := make(map[string]bool)
add := func(match func(backend.SoundtrackData) bool) { add := func(match func(backend.SearchName) bool) {
for _, data := range soundtrackData { for _, data := range soundtrackData {
if !seen[data.Original] && match(data) { if seen[data.Original] {
results = append(results, 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 seen[data.Original] = true
} }
} }
@@ -50,8 +68,8 @@ func search(searchText string, soundtrackData []backend.SoundtrackData) []string
lowercase := strings.ToLower(searchText) lowercase := strings.ToLower(searchText)
cleaned := backend.CleanSearchTerm(searchText) cleaned := backend.CleanSearchTerm(searchText)
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Lowercase, lowercase) }) add(func(n backend.SearchName) bool { return strings.Contains(n.Lowercase, lowercase) })
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Cleaned, cleaned) }) add(func(n backend.SearchName) bool { return strings.Contains(n.Cleaned, cleaned) })
add(func(d backend.SoundtrackData) bool { return backend.MatchesSubsequence(cleaned, d.Cleaned) }) add(func(n backend.SearchName) bool { return backend.MatchesSubsequence(cleaned, n.Cleaned) })
return results return results
} }
+7 -6
View File
@@ -3,14 +3,12 @@ package web
templ SearchForm() { templ SearchForm() {
@Base() { @Base() {
<button id="dark-mode-toggle">🌙</button> <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> <button type="button" id="clear" name="clear">Clear</button>
<div id="soundtracks-container"></div> <div id="soundtracks-container"></div>
<script> <script>
document.addEventListener('readystatechange', () => { document.addEventListener('readystatechange', () => {
if (document.readyState == 'complete') { if (document.readyState == 'complete') {
// Initialize search
htmlx.ajax('POST', '/find', '#soundtracks-container');
document.getElementById("search_term").focus(); document.getElementById("search_term").focus();
// Initialize theme from localStorage (default to dark) // Initialize theme from localStorage (default to dark)
@@ -35,17 +33,20 @@ templ SearchForm() {
document.getElementById("clear").addEventListener("click", function (event) { document.getElementById("clear").addEventListener("click", function (event) {
document.getElementById("search_term").value = ""; document.getElementById("search_term").value = "";
htmlx.ajax('POST', '/find', '#soundtracks-container'); htmx.ajax('POST', '/find', '#soundtracks-container');
document.getElementById("search_term").focus(); document.getElementById("search_term").focus();
}); });
</script> </script>
} }
} }
templ FoundSoundtracks(soundtracks []string) { templ FoundSoundtracks(soundtracks []SearchResult) {
for _, soundtrack := range soundtracks { for _, soundtrack := range soundtracks {
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6"> <div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
<p 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> </div>
} }
} }
+49 -6
View File
@@ -28,8 +28,24 @@ var soundtracks = []string{
"Ys III - Wanderers from Ys (+The Oath in Felghana)", "Ys III - Wanderers from Ys (+The Oath in Felghana)",
} }
func newCache(names []string) *backend.SearchCache { func newCache(names []string, alternatives ...map[string][]string) *backend.SearchCache {
return backend.NewSearchCache(func(context.Context) ([]string, error) { return names, nil }) 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 { func searchData(t *testing.T) []backend.SoundtrackData {
@@ -62,23 +78,50 @@ func TestSearch(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) { 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) { 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) { func TestSearchOrdering(t *testing.T) {
// "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order. // "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) require.GreaterOrEqual(t, len(results), 2)
assert.Equal(t, "MG", results[0]) assert.Equal(t, "MG", results[0])
assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots") 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) { func TestSearchHandler(t *testing.T) {
handler := NewSearchHandler(newCache(soundtracks)) handler := NewSearchHandler(newCache(soundtracks))
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode())) 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) { 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() rec := httptest.NewRecorder()
NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil)) NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil))
assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Equal(t, http.StatusInternalServerError, rec.Code)
+36 -44
View File
@@ -1,6 +1,6 @@
module music-server module music-server
go 1.25.0 go 1.26.0
require ( require (
github.com/MShekow/directory-checksum v1.4.18 github.com/MShekow/directory-checksum v1.4.18
@@ -8,28 +8,30 @@ require (
github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/labstack/echo/v5 v5.1.1 github.com/labstack/echo/v5 v5.3.0
github.com/lib/pq v1.12.3 // indirect github.com/lib/pq v1.12.3 // indirect
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.1
github.com/swaggo/echo-swagger/v2 v2.0.1
github.com/swaggo/swag v1.16.6
github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go v0.42.0
go.uber.org/zap v1.28.0 go.uber.org/zap v1.28.0
) )
require ( require (
github.com/danielgtaylor/huma/v2 v2.39.1
github.com/getkin/kin-openapi v0.149.0
github.com/google/uuid v1.6.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 ( require (
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // 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/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cli/browser v1.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/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // 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/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect
@@ -50,25 +50,21 @@ require (
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect github.com/gorilla/mux v1.8.1 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.23 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // 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/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // 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/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/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // 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/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/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // 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/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/urfave/cli/v2 v2.27.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/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/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/multierr v1.10.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.5 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/crypto v0.51.0 // indirect golang.org/x/mod v0.41.0 // indirect
golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect golang.org/x/tools v0.49.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect google.golang.org/protobuf v1.36.12 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
) )
tool ( tool github.com/a-h/templ/cmd/templ
github.com/a-h/templ/cmd/templ
github.com/swaggo/swag/cmd/swag
)
+82 -88
View File
@@ -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/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 h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 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 h1:1nPPVl7uREa6WMTAPKoWW/GylhnASs0C9C+GPiwLwXA=
github.com/MShekow/directory-checksum v1.4.18/go.mod h1:iUupsPb0X0BumQQymLrpD5Pkqe/CbV13OSgosw1oFc4= github.com/MShekow/directory-checksum v1.4.18/go.mod h1:iUupsPb0X0BumQQymLrpD5Pkqe/CbV13OSgosw1oFc4=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -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/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 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= 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.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 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/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 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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.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 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/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 h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= 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/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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 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 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 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-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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 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.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= 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 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= 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 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= 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/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/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 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= 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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4 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/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4=
github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE=
github.com/swaggo/echo-swagger/v2 v2.0.1 h1:jKR3QiK+ciGjxE0+7qZ/azjtlx/pTVls7pJFJqdJoJI=
github.com/swaggo/echo-swagger/v2 v2.0.1/go.mod h1:BbgiO9XKX6yYU5Rq4ejqVlQI0mVRv6ziFKd0XgdztnQ=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk=
github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= 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 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= 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 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= 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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 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.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 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-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-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-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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.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 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 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 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= 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 -1
View File
@@ -28,7 +28,7 @@ func (c *CharacterService) List() ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var characters []string characters := []string{}
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() && isImage(entry.Name()) { if !entry.IsDir() && isImage(entry.Name()) {
characters = append(characters, entry.Name()) characters = append(characters, entry.Name())
@@ -37,6 +37,33 @@ func (c *CharacterService) List() ([]string, error) {
return characters, nil 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. // Open opens the character image with the given file name.
func (c *CharacterService) Open(name string) (*os.File, error) { func (c *CharacterService) Open(name string) (*os.File, error) {
if name == "" || name != filepath.Base(name) || !isImage(name) { if name == "" || name != filepath.Base(name) || !isImage(name) {
+27
View File
@@ -4,6 +4,7 @@ import (
"io" "io"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -31,6 +32,32 @@ func TestCharacterList(t *testing.T) {
assert.Equal(t, []string{"Adol.png", "Aloy.JPG"}, characters) 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) { func TestCharacterOpen(t *testing.T) {
c := newCharacterService(t) c := newCharacterService(t)
+289
View File
@@ -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()
}
+410
View File
@@ -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}
}
+425
View File
@@ -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)
}
}
+4
View File
@@ -11,6 +11,10 @@ var (
ErrNotFound = errors.New("not found") ErrNotFound = errors.New("not found")
// ErrInvalidName is returned when a requested file name is not allowed. // ErrInvalidName is returned when a requested file name is not allowed.
ErrInvalidName = errors.New("invalid name") 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")
// ErrUpstream is returned when an external service could not be reached. // ErrUpstream is returned when an external service could not be reached.
ErrUpstream = errors.New("upstream service unavailable") ErrUpstream = errors.New("upstream service unavailable")
) )
+58
View File
@@ -3,23 +3,30 @@ package backend
import ( import (
"context" "context"
"errors" "errors"
"strings"
"time" "time"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
) )
// Soundtrack is a soundtrack as returned by the API. // Soundtrack is a soundtrack as returned by the API.
type Soundtrack struct { type Soundtrack struct {
Uuid uuid.UUID `json:"uuid"` Uuid uuid.UUID `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
TimesPlayed int32 `json:"times_played"` TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played,omitempty"` LastPlayed *time.Time `json:"last_played,omitempty"`
Added time.Time `json:"added"` 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. // SoundtrackWithSongList is a soundtrack with all its songs.
type SoundtrackWithSongList struct { type SoundtrackWithSongList struct {
Soundtrack Soundtrack
@@ -50,12 +57,63 @@ func toSoundtrack(s repository.Soundtrack) Soundtrack {
return Soundtrack{ return Soundtrack{
Uuid: s.Uuid, Uuid: s.Uuid,
Name: s.SoundtrackName, Name: s.SoundtrackName,
AlternativeNames: nonNil(s.AlternativeNames),
TimesPlayed: s.TimesPlayed, TimesPlayed: s.TimesPlayed,
LastPlayed: s.LastPlayed, LastPlayed: s.LastPlayed,
Added: s.Added, 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 { func notFound(err error) error {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound return ErrNotFound
@@ -29,6 +29,11 @@ func TestLibraryAndStatisticsWithPostgres(t *testing.T) {
s.Wait() s.Wait()
require.Empty(t, s.Result().CatchedErrors) 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) library := NewLibraryService(q)
soundtracks, err := library.Soundtracks(ctx) soundtracks, err := library.Soundtracks(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -70,6 +75,22 @@ func TestLibraryAndStatisticsWithPostgres(t *testing.T) {
assert.Equal(t, song.Uuid, songs[0].SongUuid) assert.Equal(t, song.Uuid, songs[0].SongUuid)
assert.Equal(t, bird.Uuid, songs[0].SoundtrackUuid) 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) never, err := stats.NeverPlayedSoundtracks(ctx)
require.NoError(t, err) require.NoError(t, err)
for _, st := range never { for _, st := range never {
+45 -1
View File
@@ -10,6 +10,7 @@ import (
"music-server/internal/testutil/fakedb" "music-server/internal/testutil/fakedb"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -24,11 +25,54 @@ func TestLibrarySoundtracks(t *testing.T) {
soundtracks, err := NewLibraryService(store).Soundtracks(context.Background()) soundtracks, err := NewLibraryService(store).Soundtracks(context.Background())
require.NoError(t, err) require.NoError(t, err)
require.Len(t, soundtracks, 2) require.Len(t, soundtracks, 2)
assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game"}, soundtracks[0]) 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, b.Uuid, soundtracks[1].Uuid)
assert.Equal(t, int32(3), soundtracks[1].TimesPlayed) 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) { func TestLibrarySoundtrackWithSongs(t *testing.T) {
store := fakedb.New() store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A") st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A")
+108
View File
@@ -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))
}
+193 -85
View File
@@ -12,6 +12,7 @@ import (
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/metrics"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -21,6 +22,24 @@ import (
// maxPickAttempts bounds how many broken songs are skipped before giving up. // maxPickAttempts bounds how many broken songs are skipped before giving up.
const maxPickAttempts = 100 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 { type SongInfo struct {
SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"` SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"`
Soundtrack string `json:"Soundtrack"` Soundtrack string `json:"Soundtrack"`
@@ -32,23 +51,19 @@ type SongInfo struct {
SongNo int `json:"SongNo"` 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 { type MusicService struct {
q repository.Querier q repository.Querier
soundCheckPath string soundCheckPath string
cacheMu sync.Mutex cacheMu sync.Mutex
soundtracks []repository.Soundtrack 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. // NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs.
func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService { 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) { func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) {
@@ -64,10 +79,8 @@ func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtr
return m.soundtracks, nil return m.soundtracks, nil
} }
// Reset clears the play queue and reloads the soundtrack list. // ReloadSoundtracks reloads the soundtrack list.
func (m *MusicService) Reset(ctx context.Context) error { func (m *MusicService) ReloadSoundtracks(ctx context.Context) error {
m.queue = nil
m.currentSong = -1
m.cacheMu.Lock() m.cacheMu.Lock()
m.soundtracks = nil m.soundtracks = nil
m.cacheMu.Unlock() m.cacheMu.Unlock()
@@ -75,6 +88,36 @@ func (m *MusicService) Reset(ctx context.Context) error {
return err 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. // SoundCheckSong returns the path of a random sound check song.
func (m *MusicService) SoundCheckSong() (string, error) { func (m *MusicService) SoundCheckSong() (string, error) {
files, err := os.ReadDir(m.soundCheckPath) files, err := os.ReadDir(m.soundCheckPath)
@@ -88,44 +131,57 @@ func (m *MusicService) SoundCheckSong() (string, error) {
return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil
} }
// AddLatestToQueue appends the last fetched random song to the queue. // AddLatestToQueue appends the last fetched random song to the queue and makes it the current song.
func (m *MusicService) AddLatestToQueue() { func (m *MusicService) AddLatestToQueue(ctx context.Context, queueID uuid.UUID) error {
if m.lastFetched.Path != "" { return m.q.AddLastFetchedToQueue(ctx, queueID)
m.currentSong = len(m.queue)
m.queue = append(m.queue, m.lastFetched)
m.lastFetched = repository.Song{}
}
} }
// AddLatestPlayed increments the play counters of the current song. // AddLatestPlayed increments the play counters of the current song.
func (m *MusicService) AddLatestPlayed(ctx context.Context) error { func (m *MusicService) AddLatestPlayed(ctx context.Context, queueID uuid.UUID) error {
if !m.validIndex(m.currentSong) { queue, songs, err := m.loadQueue(ctx, queueID)
return nil 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. // SetPlayed increments the play counters of the song at the given queue position.
func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error { func (m *MusicService) SetPlayed(ctx context.Context, queueID uuid.UUID, songNumber int) error {
if !m.validIndex(songNumber) { _, songs, err := m.loadQueue(ctx, queueID)
return nil if err != nil {
return err
} }
return m.markPlayed(ctx, m.queue[songNumber]) return m.markPlayed(ctx, songs, songNumber)
} }
func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error { // 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 { if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil {
return err return err
} }
return m.q.AddSongPlayedByUUID(ctx, song.Uuid) if err := m.q.AddSongPlayedByUUID(ctx, song.SongUuid); err != nil {
return err
}
metrics.SongsPlayed.WithLabelValues(song.Mode).Inc()
return nil
} }
func (m *MusicService) validIndex(i int) bool { func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) {
return i >= 0 && i < len(m.queue) 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. // 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) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
return "", err return "", err
@@ -134,29 +190,44 @@ func (m *MusicService) RandomSong(ctx context.Context) (string, error) {
for i, s := range soundtracks { for i, s := range soundtracks {
candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1} 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. // 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) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
return "", err 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) song, err := m.pickSong(ctx, candidates)
if err != nil { if err != nil {
return "", err 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 return song.Path, nil
} }
// RandomSongClassic picks a random song where every song has the same chance. // 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 { for range maxPickAttempts {
row, err := m.q.GetRandomActiveSong(ctx) row, err := m.q.GetRandomActiveSong(ctx)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -171,73 +242,97 @@ func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) {
} }
continue continue
} }
m.lastFetched = row.Song return m.remember(ctx, queueID, row.Song, PickClassic)
return row.Song.Path, nil
} }
return "", ErrNoSongs return "", ErrNoSongs
} }
// SongInfo returns information about the current song in the queue. // SongInfo returns information about the current song in the queue.
func (m *MusicService) SongInfo(ctx context.Context) SongInfo { func (m *MusicService) SongInfo(ctx context.Context, queueID uuid.UUID) (SongInfo, error) {
if !m.validIndex(m.currentSong) { queue, songs, err := m.loadQueue(ctx, queueID)
return SongInfo{} if err != nil {
return SongInfo{}, err
} }
song := m.queue[m.currentSong] current := int(queue.CurrentSong)
soundtrack := m.soundtrackFor(ctx, song) if current < 0 || current >= len(songs) {
return SongInfo{ return SongInfo{}, nil
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: true,
SongNo: m.currentSong,
} }
return m.songInfo(ctx, songs[current], true), nil
} }
// PlayedSongs returns all songs in the queue. // PlayedSongs returns all songs in the queue.
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo { func (m *MusicService) PlayedSongs(ctx context.Context, queueID uuid.UUID) ([]SongInfo, error) {
var songList []SongInfo queue, songs, err := m.loadQueue(ctx, queueID)
for i, song := range m.queue { if err != nil {
soundtrack := m.soundtrackFor(ctx, song) return nil, err
songList = append(songList, SongInfo{
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == m.currentSong,
SongNo: i,
})
} }
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. // Song jumps to the given queue position (clamped to the queue) and returns its path.
func (m *MusicService) Song(songNumber int) (string, error) { func (m *MusicService) Song(ctx context.Context, queueID uuid.UUID, songNumber int) (string, error) {
if len(m.queue) == 0 { _, songs, err := m.loadQueue(ctx, queueID)
return "", ErrQueueEmpty if err != nil {
return "", err
} }
m.currentSong = max(0, min(songNumber, len(m.queue)-1)) return m.jump(ctx, queueID, songs, songNumber)
return m.queue[m.currentSong].Path, nil
} }
// NextSong moves to the next song in the queue, staying on the last one at the end. // NextSong moves to the next song in the queue, staying on the last one at the end.
func (m *MusicService) NextSong() (string, error) { func (m *MusicService) NextSong(ctx context.Context, queueID uuid.UUID) (string, error) {
if len(m.queue) == 0 { queue, songs, err := m.loadQueue(ctx, queueID)
return "", ErrQueueEmpty 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. // PreviousSong moves to the previous song in the queue, staying on the first one at the start.
func (m *MusicService) PreviousSong() (string, error) { func (m *MusicService) PreviousSong(ctx context.Context, queueID uuid.UUID) (string, error) {
if len(m.queue) == 0 { 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 "", 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. // SoundtrackNames returns all soundtrack names in alphabetical order.
@@ -253,6 +348,19 @@ func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
return names, nil 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. // SoundtrackNamesRandom returns all soundtrack names in random order.
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) { func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
names, err := m.SoundtrackNames(ctx) names, err := m.SoundtrackNames(ctx)
@@ -352,16 +460,16 @@ func songIsPlayable(song repository.Song, soundtrackPath string) bool {
return err == nil && !info.IsDir() 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) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err)) logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err))
return repository.Soundtrack{} return repository.Soundtrack{}, false
} }
for _, soundtrack := range soundtracks { for _, soundtrack := range soundtracks {
if soundtrack.Uuid == song.SoundtrackUuid { if soundtrack.Uuid == soundtrackUUID {
return soundtrack return soundtrack, true
} }
} }
return repository.Soundtrack{} return repository.Soundtrack{}, false
} }
+180 -44
View File
@@ -33,30 +33,55 @@ func addSoundtrackWithSongs(t *testing.T, store *fakedb.Store, musicDir, name st
return 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) { func TestMusicServiceEmptyQueue(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) 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) assert.ErrorIs(t, err, ErrQueueEmpty)
_, err = m.PreviousSong() _, err = m.PreviousSong(ctx, q)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
_, err = m.Song(0) _, err = m.Song(ctx, q, 0)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
assert.Equal(t, SongInfo{}, m.SongInfo(context.Background())) info, err := m.SongInfo(ctx, q)
assert.Empty(t, m.PlayedSongs(context.Background())) require.NoError(t, err)
assert.NoError(t, m.AddLatestPlayed(context.Background())) assert.Equal(t, SongInfo{}, info)
assert.NoError(t, m.SetPlayed(context.Background(), -1)) 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) { func TestMusicServiceNoSoundtracks(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) m := NewMusicService(fakedb.New(), t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
_, err := m.RandomSong(ctx) _, err := m.RandomSong(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
_, err = m.RandomSongLowChance(ctx) _, err = m.RandomSongLowChance(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
_, err = m.RandomSongClassic(ctx) _, err = m.RandomSongClassic(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
} }
@@ -65,7 +90,7 @@ func TestMusicServiceSoundtracksWithoutSongs(t *testing.T) {
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"}) store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
_, err := m.RandomSong(context.Background()) _, err := m.RandomSong(context.Background(), newQueue(t, m))
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
} }
@@ -75,70 +100,182 @@ func TestMusicServiceQueue(t *testing.T) {
addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2") addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2")
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
first, err := m.RandomSong(ctx) first := queueSong(t, m, q)
require.NoError(t, err) second := queueSong(t, m, q)
m.AddLatestToQueue()
second, err := m.RandomSong(ctx)
require.NoError(t, err)
m.AddLatestToQueue()
info := m.SongInfo(ctx) info, err := m.SongInfo(ctx, q)
require.NoError(t, err)
assert.Equal(t, "Game A", info.Soundtrack) assert.Equal(t, "Game A", info.Soundtrack)
soundtrack, _ := store.Soundtrack("Game A") soundtrack, _ := store.Soundtrack("Game A")
assert.Equal(t, soundtrack.Uuid, info.SoundtrackUuid) assert.Equal(t, soundtrack.Uuid, info.SoundtrackUuid)
assert.Contains(t, []string{first, second}, songPathByUUID(store, info.SongUuid)) assert.Equal(t, second, songPathByUUID(store, info.SongUuid))
assert.Equal(t, 1, info.SongNo) assert.Equal(t, 1, info.SongNo)
assert.True(t, info.CurrentlyPlaying) assert.True(t, info.CurrentlyPlaying)
path, err := m.PreviousSong() path, err := m.PreviousSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, first, path) assert.Equal(t, first, path)
path, err = m.PreviousSong() path, err = m.PreviousSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, first, path, "previous stays on the first song") 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) require.NoError(t, err)
assert.Equal(t, second, path) assert.Equal(t, second, path)
path, err = m.NextSong() path, err = m.NextSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, second, path, "next stays on the last song") 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) require.NoError(t, err)
assert.Equal(t, second, path, "song number is clamped") 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) require.Len(t, played, 2)
assert.False(t, played[0].CurrentlyPlaying) assert.False(t, played[0].CurrentlyPlaying)
assert.True(t, played[1].CurrentlyPlaying) assert.True(t, played[1].CurrentlyPlaying)
}
require.NoError(t, m.Reset(ctx)) func TestMusicServiceQueuesAreSeparate(t *testing.T) {
_, err = m.NextSong() 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) 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) { func TestMusicServiceAddLatestToQueueWithoutFetch(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) m := NewMusicService(fakedb.New(), t.TempDir())
m.AddLatestToQueue() q := newQueue(t, m)
_, err := m.NextSong() require.NoError(t, m.AddLatestToQueue(context.Background(), q))
_, err := m.NextSong(context.Background(), q)
assert.ErrorIs(t, err, ErrQueueEmpty) 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) { func TestMusicServiceMarkPlayed(t *testing.T) {
store := fakedb.New() store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
queueSong(t, m, q)
_, err := m.RandomSong(ctx) require.NoError(t, m.AddLatestPlayed(ctx, q))
require.NoError(t, err) require.NoError(t, m.SetPlayed(ctx, q, 0))
m.AddLatestToQueue() require.NoError(t, m.SetPlayed(ctx, q, 5), "out of range is ignored")
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")
updated, _ := store.Soundtrack("Game A") updated, _ := store.Soundtrack("Game A")
assert.Equal(t, int32(2), updated.TimesPlayed) assert.Equal(t, int32(2), updated.TimesPlayed)
@@ -155,9 +292,10 @@ func TestMusicServiceRemovesBrokenSongs(t *testing.T) {
writeFile(t, st.Path+wav, "wav") writeFile(t, st.Path+wav, "wav")
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
q := newQueue(t, m)
for range 20 { for range 20 {
path, err := m.RandomSong(context.Background()) path, err := m.RandomSong(context.Background(), q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, st.Path+"Good.mp3", path) assert.Equal(t, st.Path+"Good.mp3", path)
} }
@@ -172,9 +310,10 @@ func TestMusicServiceRandomSongClassic(t *testing.T) {
missing := "Missing.mp3" missing := "Missing.mp3"
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
q := newQueue(t, m)
for range 20 { for range 20 {
path, err := m.RandomSongClassic(context.Background()) path, err := m.RandomSongClassic(context.Background(), q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, st.Path+"Good.mp3", path) assert.Equal(t, st.Path+"Good.mp3", path)
} }
@@ -244,12 +383,9 @@ func TestMarkPlayedUsesSongUUID(t *testing.T) {
writeFile(t, st.Path+fileName, "copy") writeFile(t, st.Path+fileName, "copy")
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Song", Path: st.Path + fileName, FileName: &fileName}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Song", Path: st.Path + fileName, FileName: &fileName})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() q := newQueue(t, m)
queueSong(t, m, q)
_, err := m.RandomSong(ctx) require.NoError(t, m.AddLatestPlayed(context.Background(), q))
require.NoError(t, err)
m.AddLatestToQueue()
require.NoError(t, m.AddLatestPlayed(ctx))
songs := store.SongsOf(st.ID) songs := store.SongsOf(st.ID)
require.Len(t, songs, 2) require.Len(t, songs, 2)
+113
View File
@@ -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)
}
+20
View File
@@ -52,6 +52,26 @@ func TestReleaseService(t *testing.T) {
assert.ErrorIs(t, err, ErrNotFound) 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) { func TestReleaseServiceCachesRelease(t *testing.T) {
server, calls := newGitea(t, http.StatusOK) server, calls := newGitea(t, http.StatusOK)
r := NewReleaseService(server.URL) r := NewReleaseService(server.URL)
+26 -10
View File
@@ -6,23 +6,40 @@ import (
"sync" "sync"
) )
// SoundtrackData holds precomputed search data for a soundtrack // SearchSoundtrack is a soundtrack's name and alternative names, as loaded for search.
type SoundtrackData struct { type SearchSoundtrack struct {
Name string
AlternativeNames []string
}
// SearchName holds a name prepared for matching.
type SearchName struct {
Original string Original string
Lowercase string Lowercase string
Cleaned 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 { type SearchCache struct {
load func(context.Context) ([]string, error) load func(context.Context) ([]SearchSoundtrack, error)
mu sync.Mutex mu sync.Mutex
cache []SoundtrackData cache []SoundtrackData
} }
// NewSearchCache creates a SearchCache that gets the soundtrack names from load. // NewSearchCache creates a SearchCache that gets the soundtracks from load.
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache { func NewSearchCache(load func(context.Context) ([]SearchSoundtrack, error)) *SearchCache {
return &SearchCache{load: load} return &SearchCache{load: load}
} }
@@ -52,10 +69,9 @@ func (s *SearchCache) reload(ctx context.Context) error {
} }
cache := make([]SoundtrackData, len(soundtracks)) cache := make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks { for i, soundtrack := range soundtracks {
cache[i] = SoundtrackData{ cache[i] = SoundtrackData{SearchName: newSearchName(soundtrack.Name)}
Original: soundtrack, for _, name := range soundtrack.AlternativeNames {
Lowercase: strings.ToLower(soundtrack), cache[i].Alternatives = append(cache[i].Alternatives, newSearchName(name))
Cleaned: CleanSearchTerm(soundtrack),
} }
} }
s.cache = cache s.cache = cache
+9 -6
View File
@@ -40,28 +40,31 @@ func TestMatchesSubsequence(t *testing.T) {
} }
func TestSearchCache(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 calls := 0
cache := NewSearchCache(func(context.Context) ([]string, error) { cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) {
calls++ calls++
return names, nil return soundtracks, nil
}) })
ctx := context.Background() ctx := context.Background()
data, err := cache.Get(ctx) data, err := cache.Get(ctx)
require.NoError(t, err) 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) _, _ = cache.Get(ctx)
assert.Equal(t, 1, calls, "data is cached") assert.Equal(t, 1, calls, "data is cached")
names = []string{"Chrono Trigger"} soundtracks = []SearchSoundtrack{{Name: "Chrono Trigger"}}
require.NoError(t, cache.Reload(ctx)) require.NoError(t, cache.Reload(ctx))
data, _ = cache.Get(ctx) data, _ = cache.Get(ctx)
assert.Equal(t, "Chrono Trigger", data[0].Original) assert.Equal(t, "Chrono Trigger", data[0].Original)
} }
func TestSearchCacheLoadError(t *testing.T) { 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()) _, err := cache.Get(context.Background())
assert.Error(t, err) assert.Error(t, err)
} }
+16 -4
View File
@@ -15,8 +15,10 @@ type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"` 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"` Songs []SongInfoForStats `json:"songs"`
} }
@@ -59,22 +61,25 @@ type soundtrackRow interface {
repository.GetMostPlayedSoundtracksWithSongsRow | repository.GetMostPlayedSoundtracksWithSongsRow |
repository.GetLeastPlayedSoundtracksWithSongsRow | repository.GetLeastPlayedSoundtracksWithSongsRow |
repository.GetLastPlayedSoundtracksRow | repository.GetLastPlayedSoundtracksRow |
repository.GetOldestPlayedSoundtracksRow repository.GetOldestPlayedSoundtracksRow |
repository.GetAllSoundtracksWithSongsRow
} }
func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) { func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []SoundtrackWithSongs result := []SoundtrackWithSongs{}
for _, r := range rows { for _, r := range rows {
row := repository.GetMostPlayedSoundtracksWithSongsRow(r) row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
result = append(result, SoundtrackWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid, SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
AlternativeNames: nonNil(row.AlternativeNames),
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed, SoundtrackLastPlayed: row.SoundtrackLastPlayed,
CoverModified: unixSeconds(row.CoverModified),
Songs: parseSongs(row.Songs), Songs: parseSongs(row.Songs),
}) })
} }
@@ -89,7 +94,7 @@ func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []SongInfoForStats result := []SongInfoForStats{}
for _, r := range rows { for _, r := range rows {
result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r))) result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r)))
} }
@@ -106,6 +111,11 @@ func parseSongs(data []byte) []SongInfoForStats {
return songs 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 // MostPlayedSoundtracks returns the top N most played soundtracks with their songs
func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) { func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit)) return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit))
@@ -132,13 +142,15 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []SoundtrackWithSongs result := []SoundtrackWithSongs{}
for _, row := range rows { for _, row := range rows {
result = append(result, SoundtrackWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid, SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
AlternativeNames: nonNil(row.AlternativeNames),
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
CoverModified: unixSeconds(row.CoverModified),
Songs: parseSongs(row.Songs), Songs: parseSongs(row.Songs),
}) })
} }
+59 -5
View File
@@ -19,6 +19,7 @@ import (
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/metrics"
"github.com/MShekow/directory-checksum/directory_checksum" "github.com/MShekow/directory-checksum/directory_checksum"
"github.com/google/uuid" "github.com/google/uuid"
@@ -44,12 +45,12 @@ type SyncResponse struct {
SoundtracksChangedContent []string `json:"soundtracks_changed_content"` SoundtracksChangedContent []string `json:"soundtracks_changed_content"`
SoundtracksRemoved []string `json:"soundtracks_removed"` SoundtracksRemoved []string `json:"soundtracks_removed"`
CatchedErrors []string `json:"catched_errors"` CatchedErrors []string `json:"catched_errors"`
TotalTime string `json:"total_time"` TotalTime string `json:"total_time" doc:"How long the sync took, as minutes and seconds, e.g. \"02:15\""`
} }
type ProgressResponse struct { type ProgressResponse struct {
Progress string `json:"progress"` Progress string `json:"progress"`
TimeSpent string `json:"time_spent"` TimeSpent string `json:"time_spent" doc:"How long the sync has been running, as minutes and seconds, e.g. \"02:15\""`
} }
type SoundtrackStatus int type SoundtrackStatus int
@@ -136,11 +137,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 { func (s *SyncService) Result() SyncResponse {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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. // ResetDB deletes all songs and soundtracks.
@@ -151,8 +164,11 @@ func (s *SyncService) ResetDB(ctx context.Context) error {
return s.q.ClearSoundtracks(ctx) return s.q.ClearSoundtracks(ctx)
} }
// formatDuration shows d as minutes and seconds, "MM:SS". A sync longer than an hour
// keeps counting minutes, e.g. "75:03".
func formatDuration(d time.Duration) string { func formatDuration(d time.Duration) string {
return time.Time{}.Add(d).Format("15:04:05") seconds := int(d / time.Second)
return fmt.Sprintf("%02d:%02d", seconds/60, seconds%60)
} }
// syncRun holds the state of a single sync. // syncRun holds the state of a single sync.
@@ -187,6 +203,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) }} 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 := run.execute(ctx, func(total int) { s.foldersTotal.Store(int64(total)) })
result.TotalTime = formatDuration(time.Since(start)) result.TotalTime = formatDuration(time.Since(start))
s.recordMetrics(ctx, full, time.Since(start), len(result.CatchedErrors) == 0)
if s.onComplete != nil { if s.onComplete != nil {
s.onComplete(ctx) s.onComplete(ctx)
@@ -206,6 +223,42 @@ func (s *SyncService) run(ctx context.Context, full bool) {
zap.Int("errors", len(result.CatchedErrors))) 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 { func (r *syncRun) execute(ctx context.Context, setTotal func(int)) SyncResponse {
var err error var err error
r.existing, err = r.q.GetAllSoundtracksIncludingDeleted(ctx) r.existing, err = r.q.GetAllSoundtracksIncludingDeleted(ctx)
@@ -611,6 +664,7 @@ func (r *syncRun) removeBrokenSongs(ctx context.Context) {
} }
_ = g.Wait() _ = g.Wait()
metrics.BrokenSongs.Set(float64(len(broken)))
if len(broken) > 0 { if len(broken) > 0 {
if err := r.q.RemoveBrokenSongs(ctx, broken); err != nil { if err := r.q.RemoveBrokenSongs(ctx, broken); err != nil {
r.addError("RemoveBrokenSongs", err, "") r.addError("RemoveBrokenSongs", err, "")
+43 -2
View File
@@ -371,7 +371,14 @@ func TestSyncStartOnlyOnce(t *testing.T) {
func TestSyncProgressBeforeFirstSync(t *testing.T) { func TestSyncProgressBeforeFirstSync(t *testing.T) {
s := NewSyncService(fakedb.New(), t.TempDir(), nil) s := NewSyncService(fakedb.New(), t.TempDir(), nil)
assert.Equal(t, "0", s.Progress().Progress) 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) { func TestSyncCallsOnCompleteBeforeFinishing(t *testing.T) {
@@ -394,5 +401,39 @@ func TestResetDB(t *testing.T) {
} }
func TestFormatDuration(t *testing.T) { func TestFormatDuration(t *testing.T) {
assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second)) assert.Equal(t, "00:00", formatDuration(0))
assert.Equal(t, "00:07", formatDuration(7*time.Second+900*time.Millisecond), "parts of a second are left out")
assert.Equal(t, "02:15", formatDuration(2*time.Minute+15*time.Second))
assert.Equal(t, "62:03", formatDuration(time.Hour+2*time.Minute+3*time.Second), "minutes keep counting past an hour")
}
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)
} }
+67
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -16,6 +17,9 @@ const DefaultReleasesURL = "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicP
// Config holds all settings read from the environment. // Config holds all settings read from the environment.
type Config struct { type Config struct {
Port int 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 DBHost string
DBPort string DBPort string
@@ -27,6 +31,14 @@ type Config struct {
CharactersPath string CharactersPath string
SoundCheckPath string SoundCheckPath string
FrontendPath string FrontendPath string
// 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 LogLevel string
LogJSON bool LogJSON bool
@@ -38,6 +50,10 @@ type Config struct {
// TrustedProxies are CIDR ranges whose X-Forwarded-For header is trusted when resolving client IPs. // TrustedProxies are CIDR ranges whose X-Forwarded-For header is trusted when resolving client IPs.
TrustedProxies []*net.IPNet 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 ReleasesURL string
} }
@@ -57,6 +73,8 @@ func load(getenv func(string) string) (Config, error) {
CharactersPath: getenv("CHARACTERS_PATH"), CharactersPath: getenv("CHARACTERS_PATH"),
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"), SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"), FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
CoversPath: getenv("COVERS_PATH"),
SteamGridDBAPIKey: getenv("STEAMGRIDDB_API_KEY"),
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"), LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
LogJSON: getenv("LOG_JSON") == "true", LogJSON: getenv("LOG_JSON") == "true",
APIPassword: getenv("API_PASSWORD"), APIPassword: getenv("API_PASSWORD"),
@@ -72,6 +90,11 @@ func load(getenv func(string) string) (Config, error) {
} }
cfg.Port = port cfg.Port = port
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{ for name, value := range map[string]string{
"DB_HOST": cfg.DBHost, "DB_HOST": cfg.DBHost,
"DB_PORT": cfg.DBPort, "DB_PORT": cfg.DBPort,
@@ -91,6 +114,11 @@ func load(getenv func(string) string) (Config, error) {
errs = append(errs, fmt.Errorf("TRUSTED_PROXIES: %w", err)) 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...) return cfg, errors.Join(errs...)
} }
@@ -110,6 +138,45 @@ func parseCIDRs(value string) ([]*net.IPNet, error) {
return nets, nil return nets, 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 { func withDefault(value, fallback string) string {
if value == "" { if value == "" {
return fallback return fallback
+48
View File
@@ -31,6 +31,39 @@ func TestLoadDefaults(t *testing.T) {
assert.Equal(t, "songs", cfg.SoundCheckPath) assert.Equal(t, "songs", cfg.SoundCheckPath)
assert.Equal(t, DefaultReleasesURL, cfg.ReleasesURL) assert.Equal(t, DefaultReleasesURL, cfg.ReleasesURL)
assert.Empty(t, cfg.TrustedProxies) 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 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) { func TestLoadMissingRequired(t *testing.T) {
@@ -56,6 +89,21 @@ func TestLoadTrustedProxies(t *testing.T) {
assert.ErrorContains(t, err, "TRUSTED_PROXIES") 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) { func TestLoadInvalidPort(t *testing.T) {
values := validEnv() values := validEnv()
values["PORT"] = "abc" values["PORT"] = "abc"
+3 -1
View File
@@ -65,7 +65,7 @@ func TestMigrationsKeepData(t *testing.T) {
assert.Error(t, err, "game table is renamed") assert.Error(t, err, "game table is renamed")
} }
func TestFingerprintMigrationDown(t *testing.T) { func TestFingerprintAndQueueMigrationDown(t *testing.T) {
database := openDatabase(t, pgtest.NewDatabase(t)) database := openDatabase(t, pgtest.NewDatabase(t))
m, closeFn, err := database.NewMigrator() m, closeFn, err := database.NewMigrator()
require.NoError(t, err) require.NoError(t, err)
@@ -75,6 +75,8 @@ func TestFingerprintMigrationDown(t *testing.T) {
require.NoError(t, m.Steps(-1)) require.NoError(t, m.Steps(-1))
assert.Equal(t, 0, count(t, database, assert.Equal(t, 0, count(t, database,
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'soundtrack' AND column_name = 'fingerprint'")) "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()) 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 '';
+51
View File
@@ -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;
+41
View File
@@ -65,3 +65,44 @@ SELECT uuid FROM soundtrack WHERE id = $1;
-- name: AddSoundtrackPlayedByUUID :exec -- name: AddSoundtrackPlayedByUUID :exec
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1; 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;
+45
View File
@@ -5,6 +5,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -33,6 +35,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -95,6 +99,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.added, g.added,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -121,6 +127,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -148,6 +156,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -180,3 +190,38 @@ SELECT
COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
FROM soundtrack FROM soundtrack
WHERE deleted IS NULL; 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;
+26
View File
@@ -11,6 +11,25 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 { type Session struct {
Token string `json:"token"` Token string `json:"token"`
IpAddress string `json:"ip_address"` IpAddress string `json:"ip_address"`
@@ -52,6 +71,13 @@ type Soundtrack struct {
Hash string `json:"hash"` Hash string `json:"hash"`
Uuid uuid.UUID `json:"uuid"` Uuid uuid.UUID `json:"uuid"`
Fingerprint string `json:"fingerprint"` 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 { type Vgmq struct {
+25
View File
@@ -9,10 +9,14 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
type Querier interface { type Querier interface {
AddHashToSong(ctx context.Context, arg AddHashToSongParams) error 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 AddSong(ctx context.Context, arg AddSongParams) error
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
@@ -20,17 +24,26 @@ type Querier interface {
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error) CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
CheckSongWithHash(ctx context.Context, hash string) (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 ClearSongs(ctx context.Context) error
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
ClearSoundtracks(ctx context.Context) 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) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteExpiredSessions(ctx context.Context) error DeleteExpiredSessions(ctx context.Context) error
DeleteSession(ctx context.Context, token string) 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) FetchAllSongs(ctx context.Context) ([]Song, error)
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error) GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error)
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, 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) GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
// Last played soundtracks (most recently played) // Last played soundtracks (most recently played)
GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error)
@@ -46,6 +59,7 @@ type Querier interface {
GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error)
// Oldest played soundtracks (least recently played, but has been played at least once) // Oldest played soundtracks (least recently played, but has been played at least once)
GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error)
GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error)
GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error) GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error)
GetSession(ctx context.Context, token string) (Session, error) GetSession(ctx context.Context, token string) (Session, error)
GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error)
@@ -62,12 +76,23 @@ type Querier interface {
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) 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) 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 MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
ResetSoundtrackIdSeq(ctx context.Context) (int64, 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 SetSoundtrackDeletionDate(ctx context.Context) error
SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error
UpdateSong(ctx context.Context, arg UpdateSongParams) error UpdateSong(ctx context.Context, arg UpdateSongParams) error
+176
View File
@@ -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
}
+187 -4
View File
@@ -9,6 +9,7 @@ import (
"context" "context"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
@@ -29,6 +30,19 @@ func (q *Queries) AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UU
return err 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 const clearSoundtracks = `-- name: ClearSoundtracks :exec
DELETE FROM soundtrack DELETE FROM soundtrack
` `
@@ -39,7 +53,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
} }
const findAllSoundtracks = `-- name: FindAllSoundtracks :many 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 FROM soundtrack
WHERE deleted IS NULL WHERE deleted IS NULL
ORDER BY soundtrack_name ORDER BY soundtrack_name
@@ -67,6 +81,13 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -79,7 +100,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
} }
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many 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 FROM soundtrack
ORDER BY soundtrack_name ORDER BY soundtrack_name
` `
@@ -106,6 +127,13 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -129,7 +157,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
} }
const getSoundtrackById = `-- name: GetSoundtrackById :one 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 FROM soundtrack
WHERE id = $1 WHERE id = $1
AND deleted IS NULL AND deleted IS NULL
@@ -151,12 +179,19 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
) )
return i, err return i, err
} }
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one 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 FROM soundtrack
WHERE uuid = $1 WHERE uuid = $1
AND deleted IS NULL AND deleted IS NULL
@@ -178,6 +213,13 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
) )
return i, err return i, err
} }
@@ -253,6 +295,76 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
return err 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 const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[])) UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
` `
@@ -282,6 +394,77 @@ func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) {
return setval, err 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 const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
` `
+120
View File
@@ -10,14 +10,106 @@ import (
"time" "time"
"github.com/google/uuid" "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 const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -44,6 +136,8 @@ type GetLastPlayedSoundtracksRow struct {
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
AlternativeNames []string `json:"alternative_names"`
CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"` Songs []byte `json:"songs"`
} }
@@ -63,6 +157,8 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -140,6 +236,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -167,6 +265,8 @@ type GetLeastPlayedSoundtracksWithSongsRow struct {
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
AlternativeNames []string `json:"alternative_names"`
CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"` Songs []byte `json:"songs"`
} }
@@ -186,6 +286,8 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -263,6 +365,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -290,6 +394,8 @@ type GetMostPlayedSoundtracksWithSongsRow struct {
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
AlternativeNames []string `json:"alternative_names"`
CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"` Songs []byte `json:"songs"`
} }
@@ -309,6 +415,8 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -328,6 +436,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.added, g.added,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -353,6 +463,8 @@ type GetNeverPlayedSoundtracksRow struct {
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
AlternativeNames []string `json:"alternative_names"`
CoverModified pgtype.Timestamptz `json:"cover_modified"`
Added time.Time `json:"added"` Added time.Time `json:"added"`
Songs []byte `json:"songs"` Songs []byte `json:"songs"`
} }
@@ -372,6 +484,8 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.Added, &i.Added,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -391,6 +505,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -417,6 +533,8 @@ type GetOldestPlayedSoundtracksRow struct {
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
AlternativeNames []string `json:"alternative_names"`
CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"` Songs []byte `json:"songs"`
} }
@@ -436,6 +554,8 @@ func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) (
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
+53
View File
@@ -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.",
})
)
+49
View File
@@ -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
}
}
}
+75
View File
@@ -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"))
}
+70
View File
@@ -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()))
}
+33
View File
@@ -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"))
}
+60
View File
@@ -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)
}
+63
View File
@@ -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())
}
+179
View File
@@ -0,0 +1,179 @@
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):
return huma.Error503ServiceUnavailable(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
}
-51
View File
@@ -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, "")
}
+114
View File
@@ -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
})
}
+143
View File
@@ -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)
}
-81
View File
@@ -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)
}
+42
View File
@@ -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
View File
@@ -1,61 +1,72 @@
package server package server
import ( import (
"errors"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"music-server/internal/backend" "github.com/danielgtaylor/huma/v2"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
) )
// serveFile sends a file with support for Range requests and caching headers. // fileResponses documents an endpoint that returns a file with Range support.
// contentType may be empty to detect it from the file extension. func fileResponses(description string, contentTypes ...string) map[string]*huma.Response {
func serveFile(c *echo.Context, f *os.File, contentType string) error { content := map[string]*huma.MediaType{}
defer f.Close() for _, contentType := range contentTypes {
info, err := f.Stat() content[contentType] = &huma.MediaType{Schema: &huma.Schema{Type: "string", Format: "binary"}}
if err != nil {
return err
} }
if info.IsDir() { return map[string]*huma.Response{
return echo.NewHTTPError(http.StatusNotFound, "Not Found") "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. // streamFile opens path and returns a response that sends it with Range support.
func serveSong(c *echo.Context, path string, err error) error { // contentType may be empty to detect it from the file extension. Audio has no write
if err != nil { // deadline because songs can take long to send to slow clients.
return songError(err) func streamFile(path, contentType string, audio bool) (*huma.StreamResponse, error) {
}
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "Not Found") return nil, huma.Error404NotFound("Not Found")
} }
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{}) return streamOpenFile(f, contentType, audio)
return serveFile(c, f, "audio/mpeg")
} }
// songError maps backend errors to HTTP errors. func streamOpenFile(f *os.File, contentType string, audio bool) (*huma.StreamResponse, error) {
func songError(err error) error { info, err := f.Stat()
switch { if err != nil || info.IsDir() {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound): f.Close()
return echo.NewHTTPError(http.StatusNotFound, err.Error()) return nil, huma.Error404NotFound("Not Found")
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")
} }
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
} }
+152 -30
View File
@@ -23,32 +23,93 @@ func TestMusicRandomAndQueue(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "0123456789") 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) require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type")) assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
assert.Equal(t, "0123456789", rec.Body.String()) 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, "/api/v1/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/addPlayed").Code)
var info backend.SongInfo 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, "Game A", info.Soundtrack)
assert.Equal(t, "Song", info.Song) 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.MethodGet, path).Code, path)
} }
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/api/v1/music/played?song=0").Code)
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPut, "/music/played?song=x").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, "/music/reset").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) { func TestMusicRangeRequest(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "0123456789") 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, http.StatusPartialContent, rec.Code)
assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range")) assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range"))
assert.Equal(t, "2345", rec.Body.String()) assert.Equal(t, "2345", rec.Body.String())
@@ -57,20 +118,21 @@ func TestMusicRangeRequest(t *testing.T) {
func TestMusicEmptyStates(t *testing.T) { func TestMusicEmptyStates(t *testing.T) {
e := newTestEnv(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.StatusNotFound, e.do(http.MethodGet, path).Code, path)
} }
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music?song=abc").Code) // Invalid or missing parameters fail validation.
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music").Code) 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, http.StatusOK, rec.Code)
assert.Equal(t, "null\n", rec.Body.String()) assert.JSONEq(t, "[]", rec.Body.String())
} }
func TestMusicSoundCheck(t *testing.T) { func TestMusicSoundCheck(t *testing.T) {
e := newTestEnv(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, http.StatusOK, rec.Code)
assert.Equal(t, "sound check", rec.Body.String()) assert.Equal(t, "sound check", rec.Body.String())
} }
@@ -81,31 +143,41 @@ func TestMusicSoundtrackLists(t *testing.T) {
e.addSong("A Game", "Song", "a") e.addSong("A Game", "Song", "a")
var ordered []string 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) assert.Equal(t, []string{"A Game", "B Game"}, ordered)
var random []string 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) assert.ElementsMatch(t, ordered, random)
} }
func TestMusicDatabaseError(t *testing.T) { func TestMusicDatabaseError(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.store.Err = errors.New("db down") 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, "/api/v1/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/all/order").Code)
} }
func TestCharacterEndpoints(t *testing.T) { func TestCharacterEndpoints(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
var list []string 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) 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)) 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) 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, http.StatusOK, rec.Code)
assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
@@ -113,13 +185,14 @@ func TestCharacterEndpoints(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) 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) { func TestCharacterPathTraversal(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd", ""} { assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/character?name=").Code, "name is required")
rec := e.do(http.MethodGet, "/character?name="+name) 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.Equal(t, http.StatusBadRequest, rec.Code, name)
assert.NotContains(t, rec.Body.String(), "secret", name) assert.NotContains(t, rec.Body.String(), "secret", name)
} }
@@ -138,23 +211,43 @@ func TestDownloadEndpoints(t *testing.T) {
url := newGitea(t, http.StatusOK) url := newGitea(t, http.StatusOK)
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url }) 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.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `"v2.0.0"`, rec.Body.String()) assert.JSONEq(t, `"v2.0.0"`, rec.Body.String())
rec = e.do(http.MethodGet, "/api/v1/download/list") rec = e.do(http.MethodGet, "/api/v1/download/list")
assert.JSONEq(t, `["Player.exe"]`, rec.Body.String()) 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, http.StatusFound, rec.Code)
assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location")) 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) { func TestDownloadUpstreamDown(t *testing.T) {
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) }) 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) assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path)
} }
} }
@@ -189,7 +282,6 @@ func TestTokenAuth(t *testing.T) {
token := e.createToken("secret") token := e.createToken("secret")
auth := []string{"Authorization", "Bearer " + token} 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, "/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.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code)
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
@@ -258,10 +350,27 @@ func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit i
}}, s.err }}, 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
}
func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) { func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err
} }
func statsService() *backend.StatisticsService {
return backend.NewStatisticsService(statsStore{})
}
func TestStatisticsEndpoints(t *testing.T) { func TestStatisticsEndpoints(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.server.stats = backend.NewStatisticsService(statsStore{}) e.server.stats = backend.NewStatisticsService(statsStore{})
@@ -279,9 +388,21 @@ func TestStatisticsEndpoints(t *testing.T) {
assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped") assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped")
for _, limit := range []string{"0", "-1", "abc"} { 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 var summary backend.StatisticsSummary
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary)) 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) assert.Equal(t, backend.StatisticsSummary{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, summary)
@@ -289,4 +410,5 @@ func TestStatisticsEndpoints(t *testing.T) {
e.server.stats = backend.NewStatisticsService(statsStore{err: errors.New("db down")}) e.server.stats = backend.NewStatisticsService(statsStore{err: errors.New("db down")})
e.handler = e.server.RegisterRoutes() 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/summary").Code)
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Code)
} }
-33
View File
@@ -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()))
}
+9
View File
@@ -14,6 +14,7 @@ import (
"music-server/internal/config" "music-server/internal/config"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware"
"music-server/internal/testutil/fakedb" "music-server/internal/testutil/fakedb"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -48,6 +49,8 @@ type testEnv struct {
db *fakeDatabase db *fakeDatabase
server *Server server *Server
handler http.Handler 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) { func writeFile(t *testing.T, path, content string) {
@@ -111,10 +114,16 @@ func (e *testEnv) doBody(method, path, body string, headers ...string) *httptest
if body != "" { if body != "" {
req.Header.Set("Content-Type", "application/json") 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 { for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1]) req.Header.Set(headers[i], headers[i+1])
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
e.handler.ServeHTTP(rec, req) e.handler.ServeHTTP(rec, req)
if id := rec.Header().Get(middleware.QueueHeader); id != "" {
e.queueID = id
}
return rec return rec
} }
+69 -85
View File
@@ -1,111 +1,95 @@
package server package server
import ( import (
"context"
"fmt"
"net/http" "net/http"
"music-server/internal/backend" "music-server/internal/backend"
"github.com/danielgtaylor/huma/v2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v5"
) )
type LibraryHandler struct { type uuidInput struct {
library *backend.LibraryService UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack or song"`
} }
func NewLibraryHandler(library *backend.LibraryService) *LibraryHandler { func (in *uuidInput) id() uuid.UUID {
return &LibraryHandler{library: library} // The format is validated by Huma before the handler runs.
return uuid.MustParse(in.UUID)
} }
// uuidParam parses the :uuid path parameter. // AlternativeNames is the body of PUT /soundtracks/{uuid}/alternative-names.
func uuidParam(ctx *echo.Context) (uuid.UUID, error) { type AlternativeNames struct {
id, err := uuid.Parse(ctx.Param("uuid")) Names []string `json:"names" maxItems:"50" doc:"The new list of alternative names. Replaces the old list; send [] to remove them all."`
if err != nil {
return uuid.Nil, echo.NewHTTPError(http.StatusBadRequest, "invalid uuid")
}
return id, nil
} }
// GetSoundtracks godoc type alternativeNamesInput struct {
// UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
// @Summary List soundtracks Body AlternativeNames
// @Description Returns all soundtracks with their UUIDs, sorted by name
// @Tags library
// @Produce json
// @Success 200 {array} backend.Soundtrack
// @Router /api/v1/soundtracks [get]
func (l *LibraryHandler) GetSoundtracks(ctx *echo.Context) error {
soundtracks, err := l.library.Soundtracks(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, soundtracks)
} }
// GetSoundtrack godoc func (in *alternativeNamesInput) id() uuid.UUID {
// return uuid.MustParse(in.UUID)
// @Summary Get soundtrack
// @Description Returns a soundtrack with its songs
// @Tags library
// @Produce json
// @Param uuid path string true "Soundtrack UUID"
// @Success 200 {object} backend.SoundtrackWithSongList
// @Failure 400 {string} string "invalid uuid"
// @Failure 404 {string} string "not found"
// @Router /api/v1/soundtracks/{uuid} [get]
func (l *LibraryHandler) GetSoundtrack(ctx *echo.Context) error {
id, err := uuidParam(ctx)
if err != nil {
return err
}
soundtrack, err := l.library.Soundtrack(ctx.Request().Context(), id)
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, soundtrack)
} }
// GetSong godoc func (s *Server) registerLibrary(a huma.API) {
// l := s.library
// @Summary Get song op := func(id, path, summary, description string, errs ...int) huma.Operation {
// @Description Returns information about a song return huma.Operation{
// @Tags library OperationID: id,
// @Produce json Method: http.MethodGet,
// @Param uuid path string true "Song UUID" Path: path,
// @Success 200 {object} backend.Song Summary: summary,
// @Failure 400 {string} string "invalid uuid" Description: description,
// @Failure 404 {string} string "not found" Tags: []string{"library"},
// @Router /api/v1/songs/{uuid} [get] Errors: errs,
func (l *LibraryHandler) GetSong(ctx *echo.Context) error {
id, err := uuidParam(ctx)
if err != nil {
return err
} }
song, err := l.library.Song(ctx.Request().Context(), id)
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, song)
} }
// GetSongFile godoc register(s, a, route{op: op("list-soundtracks", "/soundtracks", "List soundtracks",
// "Returns all soundtracks, sorted by name.")},
// @Summary Stream song func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.Soundtrack], error) {
// @Description Returns the audio file of a song. Supports Range requests. return ok(l.Soundtracks(ctx))
// @Tags library })
// @Produce audio/mpeg register(s, a, route{op: op("get-soundtrack", "/soundtracks/{uuid}", "Get soundtrack",
// @Param uuid path string true "Song UUID" "Returns a soundtrack with its songs, sorted by name.", http.StatusNotFound)},
// @Success 200 {file} file func(ctx context.Context, in *uuidInput) (*jsonBody[backend.SoundtrackWithSongList], error) {
// @Success 206 {file} file return ok(l.Soundtrack(ctx, in.id()))
// @Failure 400 {string} string "invalid uuid" })
// @Failure 404 {string} string "not found" register(s, a, route{op: op("get-song", "/songs/{uuid}", "Get song",
// @Failure 423 {string} string "Syncing is in progress" "Returns information about a song.", http.StatusNotFound)},
// @Router /api/v1/songs/{uuid}/file [get] func(ctx context.Context, in *uuidInput) (*jsonBody[backend.Song], error) {
func (l *LibraryHandler) GetSongFile(ctx *echo.Context) error { return ok(l.Song(ctx, in.id()))
id, err := uuidParam(ctx) })
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 { if err != nil {
return err return nil, apiError(err)
} }
path, err := l.library.SongPath(ctx.Request().Context(), id) s.soundtracksChanged(ctx)
return serveSong(ctx, path, err) 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()))
})
} }
+50 -5
View File
@@ -3,6 +3,8 @@ package server
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"music-server/internal/backend" "music-server/internal/backend"
@@ -48,14 +50,13 @@ func TestLibraryEndpointErrors(t *testing.T) {
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) 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"} { for _, path := range []string{"/api/v1/soundtracks/not-a-uuid", "/api/v1/songs/123", "/api/v1/songs/123/file"} {
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, path).Code, path) assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, path).Code, path)
} }
} }
func TestLibraryEndpointsRequireToken(t *testing.T) { func TestLibraryEndpointsRequireToken(t *testing.T) {
e := newTestEnv(t, withAuth("secret")) e := newTestEnv(t, withAuth("secret"))
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/soundtracks").Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/soundtracks").Code)
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/soundtracks").Code, "not available on legacy routes")
} }
func TestSongFileLockedDuringSync(t *testing.T) { func TestSongFileLockedDuringSync(t *testing.T) {
@@ -72,11 +73,11 @@ func TestSongFileLockedDuringSync(t *testing.T) {
func TestQueueResponsesIncludeUUIDs(t *testing.T) { func TestQueueResponsesIncludeUUIDs(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
song := e.addSong("Game A", "Song", "x") song := e.addSong("Game A", "Song", "x")
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/rand").Code) require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/rand").Code)
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code) require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
var info map[string]any var info map[string]any
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, song.Uuid.String(), info["SongUuid"]) assert.Equal(t, song.Uuid.String(), info["SongUuid"])
assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"]) assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"])
@@ -85,3 +86,47 @@ func TestQueueResponsesIncludeUUIDs(t *testing.T) {
require.Len(t, list, 1) require.Len(t, list, 1)
assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"]) 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)
}
-16
View File
@@ -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)
}
}
+14 -1
View File
@@ -1,2 +1,15 @@
// Package middleware provides Echo middleware for the MusicServer application. // Package middleware provides Huma middleware for the MusicServer API.
package middleware 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()
}
+38
View File
@@ -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))
}
}
+11 -12
View File
@@ -6,26 +6,25 @@ import (
"music-server/internal/logging" "music-server/internal/logging"
"github.com/labstack/echo/v5" "github.com/danielgtaylor/huma/v2"
echoMiddleware "github.com/labstack/echo/v5/middleware" echoMiddleware "github.com/labstack/echo/v5/middleware"
"go.uber.org/zap" "go.uber.org/zap"
) )
// RateLimit allows each client IP limit requests per window, with bursts up to limit. // RateLimit allows each client IP limit requests per window, with bursts up to limit.
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc { func RateLimit(api huma.API, limit int, window time.Duration) Middleware {
config := echoMiddleware.DefaultRateLimiterConfig store := echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
Rate: float64(limit) / window.Seconds(), Rate: float64(limit) / window.Seconds(),
Burst: limit, Burst: limit,
ExpiresIn: window, ExpiresIn: window,
}) })
config.DenyHandler = func(c *echo.Context, identifier string, err error) error { return func(ctx huma.Context, next func(huma.Context)) {
logging.GetLogger().Warn("Rate limit exceeded", ip := clientIP(ctx)
zap.String("ip", identifier), if allowed, _ := store.Allow(ip); !allowed {
zap.String("path", c.Request().URL.Path)) logging.GetLogger().Warn("Rate limit exceeded", zap.String("ip", ip), zap.String("path", ctx.URL().Path))
return c.JSON(http.StatusTooManyRequests, map[string]string{ _ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.")
"error": "Rate limit exceeded. Please try again later.", return
}) }
next(ctx)
} }
return echoMiddleware.RateLimiterWithConfig(config)
} }
-22
View File
@@ -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)
}
}
}
+51 -24
View File
@@ -9,40 +9,49 @@ import (
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"github.com/labstack/echo/v5" "github.com/danielgtaylor/huma/v2"
"go.uber.org/zap" "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. // BearerToken extracts the token from an "Authorization: Bearer <token>" header.
func BearerToken(c *echo.Context) (string, bool) { func BearerToken(header string) (string, bool) {
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ") token, ok := strings.CutPrefix(header, "Bearer ")
return token, ok && token != "" && !strings.Contains(token, " ") return token, ok && token != "" && !strings.Contains(token, " ")
} }
// TokenAuth validates session tokens. When disabled is true all requests are let through. // TokenAuth validates the session token for operations that declare a security requirement.
func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc { // When disabled is true all requests are let through.
return func(next echo.HandlerFunc) echo.HandlerFunc { func TokenAuth(api huma.API, q repository.Querier, disabled bool) Middleware {
return func(c *echo.Context) error { return func(ctx huma.Context, next func(huma.Context)) {
if disabled { if disabled || len(ctx.Operation().Security) == 0 {
return next(c) next(ctx)
return
} }
if c.Request().Header.Get("Authorization") == "" { header := ctx.Header("Authorization")
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"}) if header == "" {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Authorization header required")
return
} }
token, ok := BearerToken(c) token, ok := BearerToken(header)
if !ok { 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.Context(), token)
session, err := q.GetSession(ctx, token)
if err != nil { if err != nil {
logging.GetLogger().Warn("Invalid token attempt", logging.GetLogger().Warn("Invalid token attempt", zap.String("ip", clientIP(ctx)), zap.Error(err))
zap.String("ip", c.RealIP()), _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid or expired token")
zap.String("error", err.Error()), return
)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
} }
if time.Now().After(session.ExpiresAt.Time) { 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 { if err := q.DeleteSession(ctx, token); err != nil {
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err)) logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
} }
}(context.WithoutCancel(ctx)) }(context.WithoutCancel(ctx.Context()))
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"}) _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Token expired")
return
} }
c.Set("session", session) next(huma.WithValue(ctx, tokenKey{}, token))
return next(c)
} }
} }
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
} }
+107
View File
@@ -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
View File
@@ -1,240 +1,173 @@
package server package server
import ( import (
"context"
"net/http" "net/http"
"strconv"
"music-server/internal/backend" "music-server/internal/backend"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/labstack/echo/v5" "github.com/danielgtaylor/huma/v2"
"go.uber.org/zap" "go.uber.org/zap"
) )
type MusicHandler struct { type songNumberInput struct {
music *backend.MusicService Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"`
} }
func NewMusicHandler(music *backend.MusicService) *MusicHandler { // resetOutput returns the ID of the new queue.
return &MusicHandler{music: music} type resetOutput struct {
QueueID string `header:"X-Queue-Id" doc:"ID of the new queue"`
} }
// GetSong godoc const audioMPEG = "audio/mpeg"
// @Summary Get a specific song
// @Description Returns the song at the given position in the queue func (s *Server) registerMusic(a huma.API) {
// @Tags music m := s.music
// @Produce audio/mpeg audio := func(id, path, summary, description string, errs ...int) route {
// @Param song query int true "Song number in the queue" return route{
// @Success 200 {file} file op: huma.Operation{
// @Success 206 {file} file OperationID: id,
// @Failure 400 {string} string "song must be a number" Method: http.MethodGet,
// @Failure 404 {string} string "Not Found" Path: path,
// @Failure 423 {string} string "Syncing is in progress" Summary: summary,
// @Router /music [get] Description: description,
func (m *MusicHandler) GetSong(ctx *echo.Context) error { Tags: []string{"music"},
song, err := strconv.Atoi(ctx.QueryParam("song")) 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 { if err != nil {
return ctx.String(http.StatusBadRequest, "song must be a number") return nil, apiError(err)
} }
path, err := m.music.Song(song) if err := m.ReloadSoundtracks(ctx); err != nil {
return serveSong(ctx, path, err) return nil, apiError(err)
} }
return &resetOutput{QueueID: id.String()}, nil
// GetSoundCheckSong godoc })
// @Summary Get sound check song register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue",
// @Description Returns the sound check song "Adds the song returned by the last random song request to the end of the queue."),
// @Tags music func(ctx context.Context, _ *struct{}) (*noBody, error) {
// @Produce audio/mpeg if err := m.AddLatestToQueue(ctx, queue(ctx)); err != nil {
// @Success 200 {file} file return nil, apiError(err)
// @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 &noBody{}, nil
// ResetMusic godoc })
// @Summary Reset music state register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played",
// @Description Resets the music state "Increments the play counters of the current song and its soundtrack."),
// @Tags music func(ctx context.Context, _ *struct{}) (*noBody, error) {
// @Success 200 if err := m.AddLatestPlayed(ctx, queue(ctx)); err != nil {
// @Failure 423 {string} string "Syncing is in progress" return nil, apiError(err)
// @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 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)
} }
return &noBody{}, nil
// 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)
} }
+293
View File
@@ -0,0 +1,293 @@
package server
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"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/{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/{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/sync",
"POST /api/v1/sync/full",
"POST /api/v1/token",
"POST /api/v1/token/cleanup",
"PUT /api/v1/music/played",
"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)
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)
}
}
}
+58 -131
View File
@@ -1,64 +1,36 @@
package server package server
import ( import (
"encoding/json"
"net/http" "net/http"
"time"
_ "music-server/cmd/docs" // registers the OpenAPI spec
"music-server/cmd/web" "music-server/cmd/web"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/metrics"
"music-server/internal/server/middleware" "music-server/internal/server/middleware"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware" echoMiddleware "github.com/labstack/echo/v5/middleware"
echoSwagger "github.com/swaggo/echo-swagger/v2"
"github.com/swaggo/swag"
"go.uber.org/zap" "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
library *LibraryHandler
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),
library: NewLibraryHandler(s.library),
statistics: NewStatisticsHandler(s.stats),
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
}
}
func (s *Server) RegisterRoutes() http.Handler { 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 := echo.New()
e.HTTPErrorHandler = httpErrorHandler
if len(s.cfg.TrustedProxies) > 0 { if len(s.cfg.TrustedProxies) > 0 {
var trust []echo.TrustOption var trust []echo.TrustOption
for _, ipNet := range s.cfg.TrustedProxies { for _, ipNet := range s.cfg.TrustedProxies {
@@ -68,111 +40,66 @@ func (s *Server) RegisterRoutes() http.Handler {
} }
e.Use(logging.RequestLogger()) e.Use(logging.RequestLogger())
// Outside Recover, so requests that panic are counted with status 500.
e.Use(metrics.Middleware())
e.Use(echoMiddleware.Recover()) 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{ e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: []string{"https://*", "http://*"}, AllowOrigins: s.cfg.CORSAllowedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, AllowHeaders: []string{"Accept", "Authorization", "Cache-Control", "Content-Type", "Range", middleware.QueueHeader},
AllowCredentials: true, ExposeHeaders: []string{middleware.QueueHeader, "Content-Range", "Accept-Ranges"},
MaxAge: 300, MaxAge: 300,
})) }))
}
e.GET("/openapi", serveOpenAPI) // The API: /api/v1 is documented at /docs and /openapi.json.
e.GET("/swagger/*", echoSwagger.WrapHandler) 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.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("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm()))) e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search))) e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
e.GET("/config.js", s.frontendConfig)
e.Static("/", s.cfg.FrontendPath) 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)
protected.GET("/soundtracks", h.library.GetSoundtracks)
protected.GET("/soundtracks/:uuid", h.library.GetSoundtrack)
protected.GET("/songs/:uuid", h.library.GetSong)
protected.GET("/songs/:uuid/file", h.library.GetSongFile, syncLock)
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() { for _, r := range e.Router().Routes() {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path)) 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. // frontendConfig serves the runtime config of the bundled frontend. The frontend uses
// syncLock is applied to routes that must not run during a sync. // the API password to get a token, so anyone who can open the frontend can read it.
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) { func (s *Server) frontendConfig(c *echo.Context) error {
g.GET("/version", h.version.GetLatestVersion) cfg, err := json.Marshal(map[string]string{
g.GET("/version/history", h.version.GetVersionHistory) // Empty: the frontend calls this server with relative paths.
"API_HOSTNAME": "",
g.GET("/character", h.character.GetCharacter) "API_PASSWORD": s.cfg.APIPassword,
})
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()
if err != nil { if err != nil {
return err 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
View File
@@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -27,64 +29,56 @@ func (e *testEnv) blockSync() (release func()) {
func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) { func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "a") e.addSong("Game A", "Song", "a")
for _, prefix := range []string{"", "/api/v1"} { for _, path := range []string{"/api/v1/sync", "/api/v1/sync/full", "/api/v1/sync/reset"} {
for _, path := range []string{"/sync", "/sync/new", "/sync/quick", "/sync/full", "/sync/new/full", "/sync/reset"} {
// GET falls through to the frontend's static files, which answer 404. // 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.False(t, e.server.sync.IsSyncing(), "no sync was started")
assert.Len(t, e.store.Songs, 1, "nothing was deleted") assert.Len(t, e.store.Songs, 1, "nothing was deleted")
} }
func TestSyncStartAndLock(t *testing.T) { func TestSyncStartAndLock(t *testing.T) {
for _, prefix := range []string{"", "/api/v1"} {
t.Run("prefix="+prefix, func(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "a") e.addSong("Game A", "Song", "a")
release := e.blockSync() release := e.blockSync()
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, prefix+"/sync").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/full").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/reset").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/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.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.Equal(t, http.StatusOK, progress.Code)
assert.Contains(t, progress.Body.String(), `"progress"`) assert.Contains(t, progress.Body.String(), `"progress"`)
release() release()
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/all").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/all").Code)
result := e.do(http.MethodGet, prefix+"/sync/progress") result := e.do(http.MethodGet, "/api/v1/sync/progress")
assert.Contains(t, result.Body.String(), `"total_time"`) assert.Contains(t, result.Body.String(), `"total_time"`)
})
}
} }
func TestSyncResetEndpoint(t *testing.T) { func TestSyncResetEndpoint(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "a") 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.Soundtracks)
assert.Empty(t, e.store.Songs) assert.Empty(t, e.store.Songs)
} }
func TestLegacyRoutesAreDeprecated(t *testing.T) { func TestLegacyRoutesAreRemoved(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
rec := e.do(http.MethodGet, "/version") for _, path := range []string{"/version", "/health", "/music/list", "/music/rand", "/characters", "/sync/progress"} {
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
assert.Equal(t, "true", rec.Header().Get("Deprecation")) }
assert.Equal(t, http.StatusMethodNotAllowed, e.do(http.MethodPost, "/sync").Code)
rec = e.do(http.MethodGet, "/api/v1/version") assert.False(t, e.server.sync.IsSyncing())
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Deprecation"))
} }
func TestVersionEndpoints(t *testing.T) { func TestVersionEndpoints(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
var latest backend.VersionData 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) assert.Equal(t, backend.GetLatestVersion(), latest)
var history []backend.VersionData var history []backend.VersionData
@@ -94,26 +88,29 @@ func TestVersionEndpoints(t *testing.T) {
func TestHealthEndpoint(t *testing.T) { func TestHealthEndpoint(t *testing.T) {
e := newTestEnv(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.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
} }
func TestOpenAPIAndSwagger(t *testing.T) { func TestSyncAliasesAreRemoved(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
for _, path := range []string{"/api/v1/sync/new", "/api/v1/sync/quick", "/api/v1/sync/new/full"} {
for _, path := range []string{"/openapi", "/swagger/doc.json"} { assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, path).Code, path)
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"`
} }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec), path) assert.False(t, e.server.sync.IsSyncing())
assert.Contains(t, spec.Paths, "/api/v1/token", path)
assert.Contains(t, spec.Paths["/sync/reset"], "post", path)
} }
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) { func TestSearchPageAndFrontend(t *testing.T) {
@@ -130,6 +127,17 @@ func TestSearchPageAndFrontend(t *testing.T) {
assert.Contains(t, rec.Body.String(), "frontend") 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) { func TestShutdownWaitsForRequestsBeforeClosingDatabase(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
started := make(chan struct{}) 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.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) 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")
}
+81 -5
View File
@@ -12,13 +12,15 @@ import (
"music-server/internal/db" "music-server/internal/db"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/metrics"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap" "go.uber.org/zap"
) )
// Database is the database connection used by the server. // Database is the database connection used by the server.
type Database interface { type Database interface {
HealthChecker Health(ctx context.Context) map[string]string
Close() Close()
} }
@@ -33,7 +35,14 @@ type Server struct {
releases *backend.ReleaseService releases *backend.ReleaseService
stats *backend.StatisticsService stats *backend.StatisticsService
search *backend.SearchCache 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 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. // New creates a Server with all services wired up.
@@ -53,9 +62,22 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
releases: backend.NewReleaseService(cfg.ReleasesURL), releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q), 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.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
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{ s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port), Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: s.RegisterRoutes(), Handler: s.RegisterRoutes(),
@@ -66,14 +88,50 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
return s, nil 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. // afterSync refreshes cached data once a sync has finished.
func (s *Server) afterSync(ctx context.Context) { func (s *Server) afterSync(ctx context.Context) {
if err := s.music.Reset(ctx); err != nil { if err := s.music.EndAllQueues(ctx); err != nil {
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err)) 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 { if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err)) 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. // NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
@@ -108,11 +166,22 @@ func NewServerInstance() *Server {
if err != nil { if err != nil {
logger.Fatal("Failed to create server", zap.Error(err)) logger.Fatal("Failed to create server", zap.Error(err))
} }
prometheus.MustRegister(metrics.NewPoolCollector(database.Pool))
if err := s.sync.UpdateLibraryMetrics(ctx); err != nil {
logger.Error("Failed to count the library", zap.Error(err))
}
return s 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 { 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)) logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err return err
@@ -123,6 +192,13 @@ func (s *Server) ListenAndServe() error {
// Shutdown stops accepting requests, waits for running requests and then closes the database. // Shutdown stops accepting requests, waits for running requests and then closes the database.
func (s *Server) Shutdown(ctx context.Context) error { func (s *Server) Shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx) 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.sync.Wait()
s.characters.Close() s.characters.Close()
s.db.Close() s.db.Close()
+55 -167
View File
@@ -1,188 +1,76 @@
package server package server
import ( import (
"context"
"net/http" "net/http"
"strconv"
"music-server/internal/backend" "music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5" "github.com/danielgtaylor/huma/v2"
"go.uber.org/zap"
) )
const ( const maxStatsLimit = 100
defaultStatsLimit = 10
maxStatsLimit = 100
)
// StatisticsHandler handles statistics-related HTTP requests type limitInput struct {
type StatisticsHandler struct { Limit int `query:"limit" default:"10" minimum:"1" doc:"Number of results. Values above 100 are capped to 100."`
stats *backend.StatisticsService
} }
// NewStatisticsHandler creates a new StatisticsHandler func (in *limitInput) capped() int32 {
func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler { return int32(min(in.Limit, maxStatsLimit))
return &StatisticsHandler{stats: stats}
} }
// parseLimit reads the "limit" query parameter, defaulting to 10 and capping at 100. func (s *Server) registerStatistics(a huma.API) {
func parseLimit(ctx *echo.Context) (int32, bool) { st := s.stats
limitStr := ctx.QueryParam("limit") op := func(id, path, summary, description string) route {
if limitStr == "" { return route{op: huma.Operation{
return defaultStatsLimit, true OperationID: id,
Method: http.MethodGet,
Path: "/statistics" + path,
Summary: summary,
Description: description,
Tags: []string{"statistics"},
}}
} }
limit, err := strconv.Atoi(limitStr) soundtracks := func(id, path, summary, description string,
if err != nil || limit <= 0 { query func(context.Context, int32) ([]backend.SoundtrackWithSongs, error)) {
return 0, false 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 songs := func(id, path, summary, description string,
} query func(context.Context, int32) ([]backend.SongInfoForStats, error)) {
register(s, a, op(id, path, summary, description),
// withLimit runs a statistics query that takes a limit and writes the result as JSON. func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SongInfoForStats], error) {
func withLimit[T any](ctx *echo.Context, name string, query func(*echo.Context, int32) (T, error)) error { return ok(query(ctx, in.capped()))
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)
}) })
} }
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs soundtracks("get-most-played-soundtracks", "/soundtracks/most-played", "Most played soundtracks",
// "Returns the most played soundtracks with their songs.", st.MostPlayedSoundtracks)
// @Summary Get least played soundtracks soundtracks("get-least-played-soundtracks", "/soundtracks/least-played", "Least played soundtracks",
// @Description Returns the top N least played soundtracks with their songs "Returns the least played soundtracks with their songs.", st.LeastPlayedSoundtracks)
// @Tags statistics soundtracks("get-last-played-soundtracks", "/soundtracks/last-played", "Recently played soundtracks",
// @Produce json "Returns the most recently played soundtracks with their songs.", st.LastPlayedSoundtracks)
// @Param limit query int false "Number of results (default: 10, max: 100)" soundtracks("get-oldest-played-soundtracks", "/soundtracks/oldest-played", "Least recently played soundtracks",
// @Success 200 {array} backend.SoundtrackWithSongs "Returns the soundtracks played longest ago (played at least once) with their songs.", st.OldestPlayedSoundtracks)
// @Failure 400 {object} map[string]string songs("get-most-played-songs", "/songs/most-played", "Most played songs",
// @Failure 500 {object} map[string]string "Returns the most played songs with their soundtrack.", st.MostPlayedSongs)
// @Router /api/v1/statistics/soundtracks/least-played [get] songs("get-least-played-songs", "/songs/least-played", "Least played songs",
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error { "Returns the least played songs with their soundtrack.", st.LeastPlayedSongs)
return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.LeastPlayedSoundtracks(c.Request().Context(), limit) 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)
}
+63 -58
View File
@@ -1,80 +1,85 @@
package server package server
import ( import (
"context"
"net/http" "net/http"
"reflect"
"music-server/internal/backend" "music-server/internal/backend"
"music-server/internal/logging" "music-server/internal/logging"
"github.com/labstack/echo/v5" "github.com/danielgtaylor/huma/v2"
) )
type SyncHandler struct { func (s *Server) registerSync(a huma.API) {
sync *backend.SyncService 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, ""),
}}}},
},
},
} }
register(s, a, route{op: progress},
func NewSyncHandler(sync *backend.SyncService) *SyncHandler { func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) {
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 {
if s.sync.IsSyncing() { if s.sync.IsSyncing() {
return ctx.JSON(http.StatusOK, s.sync.Progress()) return ok[any](s.sync.Progress(), nil)
}
return ctx.JSON(http.StatusOK, s.sync.Result())
} }
return ok[any](s.sync.Result(), nil)
})
// SyncSoundtracksNewOnlyChanges godoc start := func(id, path, summary, description string, full bool) {
// @Summary Sync soundtracks with only changes register(s, a, route{
// @Description Starts syncing soundtracks that have changed op: huma.Operation{
// @Tags sync OperationID: id,
// @Produce json Method: http.MethodPost,
// @Success 200 {string} string "Start syncing soundtracks" Path: path,
// @Failure 423 {string} string "Syncing is in progress" Summary: summary,
// @Router /sync [post] Description: description,
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error { Tags: []string{"sync"},
return s.start(ctx, false, "Start syncing soundtracks") 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) { 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) 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 reset := huma.Operation{
// @Summary Reset soundtracks database OperationID: "reset-library",
// @Description Resets the soundtracks database by deleting all soundtracks and songs Method: http.MethodPost,
// @Tags sync Path: "/sync/reset",
// @Produce json Summary: "Delete all soundtracks and songs",
// @Success 200 {string} string "Soundtracks and songs are deleted from the database" Description: "For debugging only.",
// @Failure 423 {string} string "Syncing is in progress" Tags: []string{"sync"},
// @Router /sync/reset [post] Hidden: true,
func (s *SyncHandler) ResetDB(ctx *echo.Context) error { }
register(s, a, route{op: reset, locked: true},
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
logging.GetLogger().Info("Resetting soundtracks database") logging.GetLogger().Info("Resetting soundtracks database")
if err := s.sync.ResetDB(ctx.Request().Context()); err != nil { return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx))
return songError(err) })
}
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
} }
+67 -101
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
@@ -11,8 +12,8 @@ import (
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware" "music-server/internal/server/middleware"
"github.com/danielgtaylor/huma/v2"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -20,27 +21,23 @@ const tokenLifetime = 24 * time.Hour
// TokenRequest represents a request to generate a new token // TokenRequest represents a request to generate a new token
type TokenRequest struct { type TokenRequest struct {
Password string `json:"password"` // API password, required when API_PASSWORD is set Password string `json:"password,omitempty" doc:"API password, required when the server has one configured"`
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api" 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 // TokenResponse represents the response with a new token
type TokenResponse struct { type TokenResponse struct {
Token string `json:"token"` Token string `json:"token" doc:"Send as Authorization: Bearer <token>"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
ClientType string `json:"client_type"` ClientType string `json:"client_type"`
} }
// TokenHandler creates and removes session tokens. type tokenInput struct {
type TokenHandler struct { Body TokenRequest `required:"false"`
q repository.Querier
apiPassword string
disableSessionAuth bool
} }
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password. type statusMessage struct {
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler { Status string `json:"status"`
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
} }
func generateToken() (string, error) { func generateToken() (string, error) {
@@ -51,115 +48,84 @@ func generateToken() (string, error) {
return base64.URLEncoding.EncodeToString(b), nil return base64.URLEncoding.EncodeToString(b), nil
} }
func (h *TokenHandler) passwordValid(password string) bool { func (s *Server) passwordValid(password string) bool {
if h.disableSessionAuth || h.apiPassword == "" { if s.cfg.DisableSessionAuth || s.cfg.APIPassword == "" {
return true 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 func (s *Server) registerToken(a huma.API) {
// create := huma.Operation{
// @Summary Create session token OperationID: "create-token",
// @Description Returns a new session token for API access. Requires the API password when one is configured. Method: http.MethodPost,
// @Tags auth Path: "/token",
// @Accept json Summary: "Create session token",
// @Produce json Description: "Returns a token for the other /api/v1 endpoints, valid for 24 hours. " +
// @Param request body TokenRequest true "Password and client type" "Requires the API password when the server has one. Limited to 10 requests per minute per client.",
// @Success 200 {object} TokenResponse Tags: []string{"auth"},
// @Failure 400 {object} map[string]string Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
// @Failure 401 {object} map[string]string Middlewares: huma.Middlewares{middleware.RateLimit(a, 10, time.Minute)},
// @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"})
} }
register(s, a, route{op: create, public: true},
if !h.passwordValid(req.Password) { 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", logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()), zap.String("ip", client.IP), zap.String("user_agent", client.UserAgent))
zap.String("user_agent", c.Request().UserAgent())) return nil, huma.Error401Unauthorized("Invalid password")
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
} }
if req.ClientType == "" { clientType := in.Body.ClientType
req.ClientType = "web" if clientType == "" {
clientType = "web"
} }
token, err := generateToken() token, err := generateToken()
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.Error(err)) 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 := s.q.CreateSession(ctx, repository.CreateSessionParams{
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
Token: token, Token: token,
IpAddress: c.RealIP(), IpAddress: client.IP,
UserAgent: c.Request().UserAgent(), UserAgent: client.UserAgent,
ClientType: &req.ClientType, ClientType: &clientType,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true}, ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
}) })
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to create session", zap.Error(err)) 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 register(s, a, route{op: huma.Operation{
if session.ClientType != nil { OperationID: "delete-token",
clientType = *session.ClientType 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{ return ok(statusMessage{Status: "token invalidated"}, nil)
Token: session.Token, })
ExpiresAt: session.ExpiresAt.Time,
ClientType: clientType, 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"})
}
-39
View File
@@ -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())
}
+273
View File
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
) )
// Store is an in-memory implementation of the queries used by the application. // Store is an in-memory implementation of the queries used by the application.
@@ -25,6 +26,8 @@ type Store struct {
Soundtracks []repository.Soundtrack Soundtracks []repository.Soundtrack
Songs []repository.Song Songs []repository.Song
Sessions map[string]repository.Session Sessions map[string]repository.Session
Queues []repository.Queue
QueueSongs []repository.QueueSong
// Err, when set, is returned by every implemented query. // Err, when set, is returned by every implemented query.
Err error Err error
@@ -190,6 +193,93 @@ func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository
return repository.Soundtrack{}, pgx.ErrNoRows 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 { func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
defer s.lock()() defer s.lock()()
if s.Err != nil { if s.Err != nil {
@@ -372,3 +462,186 @@ func (s *Store) HasSession(token string) bool {
_, ok := s.Sessions[token] _, ok := s.Sessions[token]
return ok 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
}
+4 -5
View File
@@ -15,12 +15,11 @@ sqlc-generate:
migrate-create name: migrate-create name:
@migrate create -ext sql -dir internal/db/migrations -seq {{name}} @migrate create -ext sql -dir internal/db/migrations -seq {{name}}
# Generate OpenAPI docs (version pinned in go.mod) # Write openapi.json and openapi-3.0.json without starting the server, e.g. `just openapi ../MusicPlayer/api`
swag-generate: openapi out=".":
@echo "Generating OpenAPI docs..." @go run ./cmd/openapi -out {{out}}
@go tool swag init -g internal/server/routes.go -o cmd/docs --parseInternal
build: sqlc-generate templ-build swag-generate build: sqlc-generate templ-build
@echo "Building..." @echo "Building..."
@go build -o main cmd/main.go @go build -o main cmd/main.go
+5
View File
@@ -21,6 +21,11 @@ sql:
pointer: true pointer: true
import: "time" import: "time"
type: "Time" type: "Time"
- db_type: "text"
nullable: true
go_type:
pointer: true
type: "string"
- db_type: "pg_catalog.varchar" - db_type: "pg_catalog.varchar"
nullable: true nullable: true
go_type: go_type: