8 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
60 changed files with 3380 additions and 120 deletions

No files matched your search

+3
View File
@@ -14,3 +14,6 @@ main
# Generated with `just openapi` # Generated with `just openapi`
/openapi.json /openapi.json
/openapi-3.0.json /openapi-3.0.json
# Local cover images (COVERS_PATH)
/covers/
+7 -3
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 ./
@@ -30,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"]
+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
} }
+5 -2
View File
@@ -40,10 +40,13 @@ templ SearchForm() {
} }
} }
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)
+19 -13
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
@@ -11,7 +11,7 @@ require (
github.com/labstack/echo/v5 v5.3.0 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/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
) )
@@ -20,7 +20,9 @@ require (
github.com/danielgtaylor/huma/v2 v2.39.1 github.com/danielgtaylor/huma/v2 v2.39.1
github.com/getkin/kin-openapi v0.149.0 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.22.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 (
@@ -29,6 +31,7 @@ require (
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.2.2 // 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
@@ -37,7 +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/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
@@ -55,7 +57,8 @@ require (
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.19.0 // 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/echo/v4 v4.15.4 // indirect
github.com/labstack/gommon v0.5.0 // 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
@@ -71,13 +74,15 @@ 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/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // 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/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/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
@@ -92,14 +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
golang.org/x/crypto v0.54.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/mod v0.37.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/mod v0.41.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.47.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
) )
tool github.com/a-h/templ/cmd/templ tool github.com/a-h/templ/cmd/templ
+40 -28
View File
@@ -14,6 +14,8 @@ 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.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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=
@@ -35,8 +37,6 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g= 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/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=
@@ -99,12 +99,14 @@ 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.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.19.0/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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs= github.com/labstack/echo/v4 v4.15.4 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/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 h1:KT74Mprk053PQEHwSZdeCDIz1BigTZOZhavMD0c9Fjs=
@@ -141,6 +143,8 @@ 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 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
@@ -154,10 +158,16 @@ 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/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
@@ -173,8 +183,8 @@ 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/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=
@@ -211,36 +221,38 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0 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/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 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=
+27
View File
@@ -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)
+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))
}
+52 -9
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"`
@@ -145,7 +164,11 @@ func (m *MusicService) markPlayed(ctx context.Context, songs []repository.ListQu
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.SongUuid) if err := m.q.AddSongPlayedByUUID(ctx, song.SongUuid); err != nil {
return err
}
metrics.SongsPlayed.WithLabelValues(song.Mode).Inc()
return nil
} }
func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) { func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) {
@@ -167,7 +190,7 @@ func (m *MusicService) RandomSong(ctx context.Context, queueID uuid.UUID) (strin
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, queueID, 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.
@@ -176,23 +199,30 @@ func (m *MusicService) RandomSongLowChance(ctx context.Context, queueID uuid.UUI
if err != nil { if err != nil {
return "", err return "", err
} }
return m.pickAndRemember(ctx, queueID, lowChanceWeights(soundtracks)) return m.pickAndRemember(ctx, queueID, lowChanceWeights(soundtracks), PickLow)
} }
func (m *MusicService) pickAndRemember(ctx context.Context, queueID uuid.UUID, 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
} }
return m.remember(ctx, queueID, song) return m.remember(ctx, queueID, song, mode)
} }
// remember stores the song as the queue's last fetched song, for AddLatestToQueue. // remember stores the song and how it was picked as the queue's last fetched song,
func (m *MusicService) remember(ctx context.Context, queueID uuid.UUID, song repository.Song) (string, error) { // for AddLatestToQueue.
err := m.q.SetQueueLastFetched(ctx, repository.SetQueueLastFetchedParams{Uuid: queueID, LastFetchedSongUuid: song.Uuid}) 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 { if err != nil {
return "", err return "", err
} }
metrics.SongsRequested.WithLabelValues(modeName).Inc()
return song.Path, nil return song.Path, nil
} }
@@ -212,7 +242,7 @@ func (m *MusicService) RandomSongClassic(ctx context.Context, queueID uuid.UUID)
} }
continue continue
} }
return m.remember(ctx, queueID, row.Song) return m.remember(ctx, queueID, row.Song, PickClassic)
} }
return "", ErrNoSongs return "", ErrNoSongs
} }
@@ -318,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)
+26 -2
View File
@@ -47,7 +47,31 @@ func TestQueueWithPostgres(t *testing.T) {
assert.NotEqual(t, uuid.Nil, song.SoundtrackUuid) assert.NotEqual(t, uuid.Nil, song.SoundtrackUuid)
} }
path, err := m.PreviousSong(ctx, queueID) 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) require.NoError(t, err)
assert.Equal(t, paths[1], path) assert.Equal(t, paths[1], path)
require.NoError(t, m.AddLatestPlayed(ctx, queueID)) require.NoError(t, m.AddLatestPlayed(ctx, queueID))
@@ -82,7 +106,7 @@ func TestQueueWithPostgres(t *testing.T) {
require.NoError(t, q.ClearSongs(ctx)) require.NoError(t, q.ClearSongs(ctx))
played, err = m.PlayedSongs(ctx, queueID) played, err = m.PlayedSongs(ctx, queueID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, played, 3) require.Len(t, played, 4)
assert.NotEmpty(t, played[0].Song) assert.NotEmpty(t, played[0].Song)
_, err = m.Song(ctx, queueID, 0) _, err = m.Song(ctx, queueID, 0)
assert.ErrorIs(t, err, ErrNotFound) 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)
} }
+6
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"`
} }
@@ -74,8 +76,10 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]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),
}) })
} }
@@ -144,7 +148,9 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
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),
}) })
} }
+45 -3
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
@@ -163,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.
@@ -199,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)
@@ -218,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)
@@ -623,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, "")
+35 -1
View File
@@ -401,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)
} }
+36
View File
@@ -17,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
@@ -28,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
@@ -62,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"),
@@ -77,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,
@@ -120,6 +138,24 @@ 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). // parseOrigins parses a comma-separated list of origins (scheme://host[:port], nothing else).
func parseOrigins(value string) ([]string, error) { func parseOrigins(value string) ([]string, error) {
var origins []string var origins []string
+32
View File
@@ -32,6 +32,38 @@ func TestLoadDefaults(t *testing.T) {
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.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) {
@@ -1,3 +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_song;
DROP TABLE queue; DROP TABLE queue;
ALTER TABLE soundtrack DROP COLUMN fingerprint; ALTER TABLE soundtrack DROP COLUMN fingerprint;
@@ -7,8 +7,9 @@ ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
CREATE TABLE queue ( CREATE TABLE queue (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
current_song INT4 NOT NULL DEFAULT -1, current_song INT4 NOT NULL DEFAULT -1,
-- The last random song, added to the queue by /music/addQue. -- The last random song, added to the queue by /music/addQue, and how it was picked.
last_fetched_song_uuid UUID NULL, last_fetched_song_uuid UUID NULL,
last_fetched_mode VARCHAR(10) NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ NULL ended_at TIMESTAMPTZ NULL
); );
@@ -21,6 +22,22 @@ CREATE TABLE queue_song (
song_uuid UUID NOT NULL, song_uuid UUID NOT NULL,
soundtrack_name VARCHAR NOT NULL, soundtrack_name VARCHAR NOT NULL,
song_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(), added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (queue_uuid, song_no) 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;
+5 -5
View File
@@ -16,7 +16,7 @@ UPDATE queue SET ended_at = NOW()
WHERE ended_at IS NULL; WHERE ended_at IS NULL;
-- name: SetQueueLastFetched :exec -- name: SetQueueLastFetched :exec
UPDATE queue SET last_fetched_song_uuid = $2 UPDATE queue SET last_fetched_song_uuid = $2, last_fetched_mode = $3
WHERE uuid = $1; WHERE uuid = $1;
-- name: SetQueueCurrentSong :exec -- name: SetQueueCurrentSong :exec
@@ -27,23 +27,23 @@ WHERE uuid = $1;
-- Appends the queue's last fetched song and makes it the current song. -- 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. -- Does nothing when no song has been fetched since the last call.
WITH added AS ( WITH added AS (
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name) INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name, mode)
SELECT q.uuid, SELECT q.uuid,
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = 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 s.uuid, st.soundtrack_name, s.song_name, COALESCE(q.last_fetched_mode, 'unknown')
FROM queue q FROM queue q
JOIN song s ON s.uuid = q.last_fetched_song_uuid JOIN song s ON s.uuid = q.last_fetched_song_uuid
JOIN soundtrack st ON st.id = s.soundtrack_id JOIN soundtrack st ON st.id = s.soundtrack_id
WHERE q.uuid = $1 WHERE q.uuid = $1
RETURNING queue_uuid, song_no RETURNING queue_uuid, song_no
) )
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL, last_fetched_mode = NULL
FROM added FROM added
WHERE queue.uuid = added.queue_uuid; WHERE queue.uuid = added.queue_uuid;
-- name: ListQueueSongs :many -- name: ListQueueSongs :many
-- The song columns are NULL when the song has been removed from the library since. -- 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, SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, qs.mode,
s.path, s.times_played, s.soundtrack_uuid s.path, s.times_played, s.soundtrack_uuid
FROM queue_song qs FROM queue_song qs
LEFT JOIN song s ON s.uuid = qs.song_uuid LEFT JOIN song s ON s.uuid = qs.song_uuid
+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;
+18
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(
@@ -188,6 +198,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(
@@ -207,3 +219,9 @@ LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL WHERE g.deleted IS NULL
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
ORDER BY g.soundtrack_name; 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;
+9
View File
@@ -15,6 +15,7 @@ type Queue struct {
Uuid uuid.UUID `json:"uuid"` Uuid uuid.UUID `json:"uuid"`
CurrentSong int32 `json:"current_song"` CurrentSong int32 `json:"current_song"`
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"` LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"`
LastFetchedMode *string `json:"last_fetched_mode"`
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"` EndedAt pgtype.Timestamptz `json:"ended_at"`
} }
@@ -25,6 +26,7 @@ type QueueSong struct {
SongUuid uuid.UUID `json:"song_uuid"` SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"` SongName string `json:"song_name"`
Mode string `json:"mode"`
AddedAt pgtype.Timestamptz `json:"added_at"` AddedAt pgtype.Timestamptz `json:"added_at"`
} }
@@ -69,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 {
+12
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
type Querier interface { type Querier interface {
@@ -23,9 +24,13 @@ 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) 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
@@ -71,14 +76,21 @@ 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. // The song columns are NULL when the song has been removed from the library since.
ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error) 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 SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error
SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error
SetSoundtrackDeletionDate(ctx context.Context) error SetSoundtrackDeletionDate(ctx context.Context) error
+13 -8
View File
@@ -13,17 +13,17 @@ import (
const addLastFetchedToQueue = `-- name: AddLastFetchedToQueue :exec const addLastFetchedToQueue = `-- name: AddLastFetchedToQueue :exec
WITH added AS ( WITH added AS (
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name) INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name, mode)
SELECT q.uuid, SELECT q.uuid,
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = 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 s.uuid, st.soundtrack_name, s.song_name, COALESCE(q.last_fetched_mode, 'unknown')
FROM queue q FROM queue q
JOIN song s ON s.uuid = q.last_fetched_song_uuid JOIN song s ON s.uuid = q.last_fetched_song_uuid
JOIN soundtrack st ON st.id = s.soundtrack_id JOIN soundtrack st ON st.id = s.soundtrack_id
WHERE q.uuid = $1 WHERE q.uuid = $1
RETURNING queue_uuid, song_no RETURNING queue_uuid, song_no
) )
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL, last_fetched_mode = NULL
FROM added FROM added
WHERE queue.uuid = added.queue_uuid WHERE queue.uuid = added.queue_uuid
` `
@@ -37,7 +37,7 @@ func (q *Queries) AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID)
const createQueue = `-- name: CreateQueue :one const createQueue = `-- name: CreateQueue :one
INSERT INTO queue DEFAULT VALUES INSERT INTO queue DEFAULT VALUES
RETURNING uuid, current_song, last_fetched_song_uuid, started_at, ended_at RETURNING uuid, current_song, last_fetched_song_uuid, last_fetched_mode, started_at, ended_at
` `
func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) { func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) {
@@ -47,6 +47,7 @@ func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) {
&i.Uuid, &i.Uuid,
&i.CurrentSong, &i.CurrentSong,
&i.LastFetchedSongUuid, &i.LastFetchedSongUuid,
&i.LastFetchedMode,
&i.StartedAt, &i.StartedAt,
&i.EndedAt, &i.EndedAt,
) )
@@ -74,7 +75,7 @@ func (q *Queries) EndQueue(ctx context.Context, argUuid uuid.UUID) error {
} }
const getQueue = `-- name: GetQueue :one const getQueue = `-- name: GetQueue :one
SELECT uuid, current_song, last_fetched_song_uuid, started_at, ended_at SELECT uuid, current_song, last_fetched_song_uuid, last_fetched_mode, started_at, ended_at
FROM queue FROM queue
WHERE uuid = $1 WHERE uuid = $1
` `
@@ -86,6 +87,7 @@ func (q *Queries) GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error
&i.Uuid, &i.Uuid,
&i.CurrentSong, &i.CurrentSong,
&i.LastFetchedSongUuid, &i.LastFetchedSongUuid,
&i.LastFetchedMode,
&i.StartedAt, &i.StartedAt,
&i.EndedAt, &i.EndedAt,
) )
@@ -93,7 +95,7 @@ func (q *Queries) GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error
} }
const listQueueSongs = `-- name: ListQueueSongs :many const listQueueSongs = `-- name: ListQueueSongs :many
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, qs.mode,
s.path, s.times_played, s.soundtrack_uuid s.path, s.times_played, s.soundtrack_uuid
FROM queue_song qs FROM queue_song qs
LEFT JOIN song s ON s.uuid = qs.song_uuid LEFT JOIN song s ON s.uuid = qs.song_uuid
@@ -106,6 +108,7 @@ type ListQueueSongsRow struct {
SongUuid uuid.UUID `json:"song_uuid"` SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"` SongName string `json:"song_name"`
Mode string `json:"mode"`
Path *string `json:"path"` Path *string `json:"path"`
TimesPlayed *int32 `json:"times_played"` TimesPlayed *int32 `json:"times_played"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
@@ -126,6 +129,7 @@ func (q *Queries) ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]Li
&i.SongUuid, &i.SongUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SongName, &i.SongName,
&i.Mode,
&i.Path, &i.Path,
&i.TimesPlayed, &i.TimesPlayed,
&i.SoundtrackUuid, &i.SoundtrackUuid,
@@ -156,16 +160,17 @@ func (q *Queries) SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSo
} }
const setQueueLastFetched = `-- name: SetQueueLastFetched :exec const setQueueLastFetched = `-- name: SetQueueLastFetched :exec
UPDATE queue SET last_fetched_song_uuid = $2 UPDATE queue SET last_fetched_song_uuid = $2, last_fetched_mode = $3
WHERE uuid = $1 WHERE uuid = $1
` `
type SetQueueLastFetchedParams struct { type SetQueueLastFetchedParams struct {
Uuid uuid.UUID `json:"uuid"` Uuid uuid.UUID `json:"uuid"`
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_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 { func (q *Queries) SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error {
_, err := q.db.Exec(ctx, setQueueLastFetched, arg.Uuid, arg.LastFetchedSongUuid) _, err := q.db.Exec(ctx, setQueueLastFetched, arg.Uuid, arg.LastFetchedSongUuid, arg.LastFetchedMode)
return err 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
` `
+56
View File
@@ -10,14 +10,36 @@ 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 const getAllSoundtracksWithSongs = `-- name: GetAllSoundtracksWithSongs :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 +66,8 @@ type GetAllSoundtracksWithSongsRow 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 +87,8 @@ func (q *Queries) GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSound
&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 {
@@ -82,6 +108,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(
@@ -108,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"`
} }
@@ -127,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 {
@@ -204,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(
@@ -231,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"`
} }
@@ -250,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 {
@@ -327,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(
@@ -354,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"`
} }
@@ -373,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 {
@@ -392,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(
@@ -417,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"`
} }
@@ -436,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 {
@@ -455,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(
@@ -481,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"`
} }
@@ -500,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())
}
+3 -1
View File
@@ -146,8 +146,10 @@ func apiError(err error) error {
switch { switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound): case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return huma.Error404NotFound(err.Error()) return huma.Error404NotFound(err.Error())
case errors.Is(err, backend.ErrInvalidName): case errors.Is(err, backend.ErrInvalidName), errors.Is(err, backend.ErrInvalidImage):
return huma.Error400BadRequest(err.Error()) return huma.Error400BadRequest(err.Error())
case errors.Is(err, backend.ErrCoversDisabled):
return huma.Error503ServiceUnavailable(err.Error())
case errors.Is(err, backend.ErrUpstream): case errors.Is(err, backend.ErrUpstream):
return huma.Error502BadGateway(err.Error()) return huma.Error502BadGateway(err.Error())
default: default:
+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)
}
+13 -1
View File
@@ -52,9 +52,21 @@ func streamOpenFile(f *os.File, contentType string, audio bool) (*huma.StreamRes
} }
// streamSong streams the song at path, or returns err mapped to an API error. // 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) { func streamSong(path string, err error) (*huma.StreamResponse, error) {
if err != nil { if err != nil {
return nil, apiError(err) return nil, apiError(err)
} }
return streamFile(path, "audio/mpeg", true) 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
} }
+50
View File
@@ -92,6 +92,19 @@ func TestMusicQueueEndsAfterSync(t *testing.T) {
assert.JSONEq(t, "[]", rec.Body.String()) 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")
@@ -154,6 +167,16 @@ func TestCharacterEndpoints(t *testing.T) {
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)
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") 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"))
@@ -199,7 +222,27 @@ func TestDownloadEndpoints(t *testing.T) {
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"))
// 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").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) {
@@ -311,6 +354,8 @@ func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.Ge
return []repository.GetAllSoundtracksWithSongsRow{{ return []repository.GetAllSoundtracksWithSongsRow{{
SoundtrackUuid: statsSoundtrackUUID, SoundtrackUuid: statsSoundtrackUUID,
SoundtrackName: "Game A", SoundtrackName: "Game A",
AlternativeNames: []string{"GA"},
CoverModified: pgtype.Timestamptz{Time: time.Unix(1730000000, 0), Valid: true},
SoundtrackPlayed: 4, SoundtrackPlayed: 4,
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`), Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
}, { }, {
@@ -352,6 +397,11 @@ func TestStatisticsEndpoints(t *testing.T) {
assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed) assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed)
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName) assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list") 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))
+36
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"music-server/internal/backend" "music-server/internal/backend"
@@ -19,6 +20,20 @@ func (in *uuidInput) id() uuid.UUID {
return uuid.MustParse(in.UUID) return uuid.MustParse(in.UUID)
} }
// AlternativeNames is the body of PUT /soundtracks/{uuid}/alternative-names.
type AlternativeNames struct {
Names []string `json:"names" maxItems:"50" doc:"The new list of alternative names. Replaces the old list; send [] to remove them all."`
}
type alternativeNamesInput struct {
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
Body AlternativeNames
}
func (in *alternativeNamesInput) id() uuid.UUID {
return uuid.MustParse(in.UUID)
}
func (s *Server) registerLibrary(a huma.API) { func (s *Server) registerLibrary(a huma.API) {
l := s.library l := s.library
op := func(id, path, summary, description string, errs ...int) huma.Operation { op := func(id, path, summary, description string, errs ...int) huma.Operation {
@@ -49,6 +64,27 @@ func (s *Server) registerLibrary(a huma.API) {
return ok(l.Song(ctx, in.id())) return ok(l.Song(ctx, in.id()))
}) })
names := op("set-alternative-names", "/soundtracks/{uuid}/alternative-names", "Set alternative names",
"Replaces the soundtrack's alternative names, which search and the cover search also use. "+
"Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
http.StatusNotFound)
names.Method = http.MethodPut
register(s, a, route{op: names},
func(ctx context.Context, in *alternativeNamesInput) (*jsonBody[backend.Soundtrack], error) {
for _, name := range in.Body.Names {
if len(name) > backend.MaxNameLength {
return nil, huma.Error422UnprocessableEntity(
fmt.Sprintf("names can be at most %d bytes long", backend.MaxNameLength))
}
}
soundtrack, err := l.SetAlternativeNames(ctx, in.id(), in.Body.Names)
if err != nil {
return nil, apiError(err)
}
s.soundtracksChanged(ctx)
return ok(soundtrack, nil)
})
file := op("get-song-file", "/songs/{uuid}/file", "Stream song", file := op("get-song-file", "/songs/{uuid}/file", "Stream song",
"Returns the audio file of a song.", http.StatusNotFound) "Returns the audio file of a song.", http.StatusNotFound)
file.Responses = fileResponses("The song (MP3)", audioMPEG) file.Responses = fileResponses("The song (MP3)", audioMPEG)
+46
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"
@@ -84,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)
}
+8
View File
@@ -57,6 +57,12 @@ func (s *Server) registerMisc(a huma.API) {
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(s.characters.List()) 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", image := get("get-character", "/character", "characters", "Character image",
"Returns a character image.", http.StatusBadRequest, http.StatusNotFound) "Returns a character image.", http.StatusBadRequest, http.StatusNotFound)
image.Responses = fileResponses("The image", "image/png", "image/jpeg") image.Responses = fileResponses("The image", "image/png", "image/jpeg")
@@ -96,4 +102,6 @@ func (s *Server) registerMisc(a huma.API) {
} }
download("download-client-windows", "/download/windows", "Windows", ".exe") download("download-client-windows", "/download/windows", "Windows", ".exe")
download("download-client-linux", "/download/linux", "Linux", ".x86_64") 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")
} }
+24 -1
View File
@@ -74,11 +74,14 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
} }
slices.Sort(operations) slices.Sort(operations)
assert.Equal(t, []string{ assert.Equal(t, []string{
"DELETE /api/v1/soundtracks/{uuid}/cover",
"DELETE /api/v1/token", "DELETE /api/v1/token",
"GET /api/v1/character", "GET /api/v1/character",
"GET /api/v1/character/details",
"GET /api/v1/character/list", "GET /api/v1/character/list",
"GET /api/v1/download", "GET /api/v1/download",
"GET /api/v1/download/linux", "GET /api/v1/download/linux",
"GET /api/v1/download/linux-package",
"GET /api/v1/download/list", "GET /api/v1/download/list",
"GET /api/v1/download/windows", "GET /api/v1/download/windows",
"GET /api/v1/health", "GET /api/v1/health",
@@ -101,6 +104,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"GET /api/v1/songs/{uuid}/file", "GET /api/v1/songs/{uuid}/file",
"GET /api/v1/soundtracks", "GET /api/v1/soundtracks",
"GET /api/v1/soundtracks/{uuid}", "GET /api/v1/soundtracks/{uuid}",
"GET /api/v1/soundtracks/{uuid}/cover",
"GET /api/v1/statistics/songs/least-played", "GET /api/v1/statistics/songs/least-played",
"GET /api/v1/statistics/songs/most-played", "GET /api/v1/statistics/songs/most-played",
"GET /api/v1/statistics/soundtracks", "GET /api/v1/statistics/soundtracks",
@@ -118,6 +122,9 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"POST /api/v1/token", "POST /api/v1/token",
"POST /api/v1/token/cleanup", "POST /api/v1/token/cleanup",
"PUT /api/v1/music/played", "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") }, 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, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
@@ -168,7 +175,7 @@ func (v *specValidator) check(method, path, body string, wantStatus int, headers
func TestResponsesMatchOpenAPI(t *testing.T) { func TestResponsesMatchOpenAPI(t *testing.T) {
gitea := newGitea(t, http.StatusOK) gitea := newGitea(t, http.StatusOK)
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }) e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }, withCovers(t))
v := newSpecValidator(t, e) v := newSpecValidator(t, e)
song := e.addSong("Game A", "Song", "0123456789") song := e.addSong("Game A", "Song", "0123456789")
songURL := "/api/v1/songs/" + song.Uuid.String() songURL := "/api/v1/songs/" + song.Uuid.String()
@@ -201,12 +208,27 @@ func TestResponsesMatchOpenAPI(t *testing.T) {
// Library. // Library.
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", 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, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
v.check(http.MethodGet, songURL, "", http.StatusOK) v.check(http.MethodGet, songURL, "", http.StatusOK)
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK) v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
// Characters, downloads, server info. // Characters, downloads, server info.
v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK) 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=Aloy.jpg", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest) 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/character?name=Missing.png", "", http.StatusNotFound)
@@ -214,6 +236,7 @@ func TestResponsesMatchOpenAPI(t *testing.T) {
v.check(http.MethodGet, "/api/v1/download/list", "", 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/windows", "", http.StatusFound)
v.check(http.MethodGet, "/api/v1/download/linux", "", http.StatusNotFound) 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/health", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK)
+5 -1
View File
@@ -6,6 +6,7 @@ import (
"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"
@@ -39,6 +40,8 @@ func (s *Server) routes() (*echo.Echo, huma.API) {
} }
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. // 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. // The API uses Bearer tokens, not cookies, so credentials are not allowed.
@@ -46,7 +49,7 @@ func (s *Server) routes() (*echo.Echo, huma.API) {
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: s.cfg.CORSAllowedOrigins, 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", "Range", middleware.QueueHeader}, AllowHeaders: []string{"Accept", "Authorization", "Cache-Control", "Content-Type", "Range", middleware.QueueHeader},
ExposeHeaders: []string{middleware.QueueHeader, "Content-Range", "Accept-Ranges"}, ExposeHeaders: []string{middleware.QueueHeader, "Content-Range", "Accept-Ranges"},
MaxAge: 300, MaxAge: 300,
})) }))
@@ -60,6 +63,7 @@ func (s *Server) routes() (*echo.Echo, huma.API) {
s.registerSync(a) s.registerSync(a)
s.registerMusic(a) s.registerMusic(a)
s.registerLibrary(a) s.registerLibrary(a)
s.registerCovers(a)
s.registerStatistics(a) s.registerStatistics(a)
// Older links to the spec and docs. // Older links to the spec and docs.
+30
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -223,3 +224,32 @@ func TestCORSWithoutAllowedOrigins(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code, "the request itself works; the browser blocks reading it") 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")) 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")
}
+75 -2
View File
@@ -12,7 +12,9 @@ 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"
) )
@@ -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,6 +88,36 @@ 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.EndAllQueues(ctx); err != nil { if err := s.music.EndAllQueues(ctx); err != nil {
@@ -77,6 +129,9 @@ func (s *Server) afterSync(ctx context.Context) {
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.
@@ -111,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
@@ -126,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()
+119 -3
View File
@@ -193,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 {
@@ -431,7 +518,9 @@ func (s *Store) EndActiveQueues(context.Context) error {
} }
func (s *Store) SetQueueLastFetched(_ context.Context, arg repository.SetQueueLastFetchedParams) error { func (s *Store) SetQueueLastFetched(_ context.Context, arg repository.SetQueueLastFetchedParams) error {
return s.updateQueue(arg.Uuid, func(q *repository.Queue) { q.LastFetchedSongUuid = arg.LastFetchedSongUuid }) 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 { func (s *Store) SetQueueCurrentSong(_ context.Context, arg repository.SetQueueCurrentSongParams) error {
@@ -457,6 +546,10 @@ func (s *Store) AddLastFetchedToQueue(_ context.Context, id uuid.UUID) error {
if st := s.soundtrackIndex(song.SoundtrackID); st >= 0 { if st := s.soundtrackIndex(song.SoundtrackID); st >= 0 {
soundtrackName = s.Soundtracks[st].SoundtrackName soundtrackName = s.Soundtracks[st].SoundtrackName
} }
mode := "unknown"
if queue.LastFetchedMode != nil {
mode = *queue.LastFetchedMode
}
songNo := int32(0) songNo := int32(0)
for _, qs := range s.QueueSongs { for _, qs := range s.QueueSongs {
if qs.QueueUuid == id { if qs.QueueUuid == id {
@@ -465,9 +558,10 @@ func (s *Store) AddLastFetchedToQueue(_ context.Context, id uuid.UUID) error {
} }
s.QueueSongs = append(s.QueueSongs, repository.QueueSong{ s.QueueSongs = append(s.QueueSongs, repository.QueueSong{
QueueUuid: id, SongNo: songNo, SongUuid: song.Uuid, SoundtrackName: soundtrackName, SongName: song.SongName, QueueUuid: id, SongNo: songNo, SongUuid: song.Uuid, SoundtrackName: soundtrackName, SongName: song.SongName,
Mode: mode,
}) })
queue.CurrentSong = songNo queue.CurrentSong = songNo
queue.LastFetchedSongUuid = uuid.Nil queue.LastFetchedSongUuid, queue.LastFetchedMode = uuid.Nil, nil
return nil return nil
} }
@@ -481,7 +575,9 @@ func (s *Store) ListQueueSongs(_ context.Context, id uuid.UUID) ([]repository.Li
if qs.QueueUuid != id { if qs.QueueUuid != id {
continue continue
} }
row := repository.ListQueueSongsRow{SongNo: qs.SongNo, SongUuid: qs.SongUuid, SoundtrackName: qs.SoundtrackName, SongName: qs.SongName} 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 { if i := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == qs.SongUuid }); i >= 0 {
song := s.Songs[i] song := s.Songs[i]
row.Path, row.TimesPlayed, row.SoundtrackUuid = &song.Path, &song.TimesPlayed, song.SoundtrackUuid row.Path, row.TimesPlayed, row.SoundtrackUuid = &song.Path, &song.TimesPlayed, song.SoundtrackUuid
@@ -492,6 +588,26 @@ func (s *Store) ListQueueSongs(_ context.Context, id uuid.UUID) ([]repository.Li
return rows, nil 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. // Statistics queries return empty results; tests that need data use their own fakes.
func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) { func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
+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: