Author SHA1 Message Date
SansanandClaude Opus 5.5 a7e2280969 Upload new soundtracks through the API
CI / integration (pull_request) Successful in 21s
CI / test (pull_request) Successful in 26s
CI / publish (pull_request) Skipped
CI / docker (pull_request) Successful in 43s
A client starts an upload with the soundtrack's name, sends one MP3 per
request, and finishes it. The server then moves the songs into a new folder
in the music folder and syncs only that folder, so the soundtrack is in the
library, lists and search right away.

- POST /api/v1/soundtracks/uploads, GET/DELETE .../uploads/{id},
  PUT .../uploads/{id}/files/{file} and POST .../uploads/{id}/finish.
- Names must work as file names on every system and be free, ignoring case.
  File names are plain: clients flatten disc folders.
- Only MP3 files. Size limits from UPLOAD_MAX_FILE_MB (50) and
  UPLOAD_MAX_TOTAL_MB (2048).
- Refused while a sync runs, and a sync cannot start while an upload is
  finished. Songs equal to an existing soundtrack's are refused, as the sync
  would take them for that soundtrack renamed.
- Unfinished uploads live in MUSIC_PATH/.uploads and are removed after a day.
  The sync now skips every hidden folder.

Closes #33

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

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

Closes #34

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

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

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

Closes #12, closes #32

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

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

Refs #35, #36, #37

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

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

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

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

The deprecated legacy routes without the /api/v1 prefix are removed.
2026-10-07 20:03:03 +02:00
76 changed files with 5718 additions and 474 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/
+8 -2
View File
@@ -3,7 +3,7 @@ ARG FRONTEND_IMAGE=gitea.sanplex.xyz/sansan/musicfrontend:latest
# Stage 1: Build the backend on the build machine and cross-compile for the target platform, # 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 ./
@@ -27,13 +27,19 @@ COPY --from=build /app/main .
COPY --from=frontend /usr/share/nginx/html /frontend COPY --from=frontend /usr/share/nginx/html /frontend
COPY ./songs/ ./songs/ COPY ./songs/ ./songs/
# The music volume must be readable by uid 1000, and writable for the .id file cleanup during sync. # The music volume must be readable by uid 1000, and writable for the .id file cleanup during sync
# and for soundtrack uploads (kept in .uploads until finished).
USER 1000:1000 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).
# Uploads (optional): UPLOAD_MAX_FILE_MB (default 50) and UPLOAD_MAX_TOTAL_MB (default 2048).
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)
}
}
+12
View File
@@ -11,6 +11,18 @@ 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")
// ErrConflict is returned when something with the same name already exists.
ErrConflict = errors.New("already exists")
// ErrSyncing is returned when the music folder is being synced.
ErrSyncing = errors.New("syncing is in progress")
// ErrTooLarge is returned when an upload is over its size limit.
ErrTooLarge = errors.New("too large")
// ErrUploadsUnavailable is returned when the music folder cannot be written to.
ErrUploadsUnavailable = errors.New("uploads are not possible: the music folder cannot be written to")
// ErrUpstream is returned when an external service could not be reached. // ErrUpstream is returned when an external service could not be reached.
ErrUpstream = errors.New("upstream service unavailable") ErrUpstream = errors.New("upstream service unavailable")
) )
+68 -10
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"`
TimesPlayed int32 `json:"times_played"` AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
LastPlayed *time.Time `json:"last_played,omitempty"` TimesPlayed int32 `json:"times_played"`
Added time.Time `json:"added"` LastPlayed *time.Time `json:"last_played,omitempty"`
Added time.Time `json:"added"`
CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."`
} }
// MaxNameLength is the longest alternative name that can be stored, in bytes.
const MaxNameLength = 200
// SoundtrackWithSongList is a soundtrack with all its songs. // SoundtrackWithSongList is a soundtrack with all its songs.
type SoundtrackWithSongList struct { type SoundtrackWithSongList struct {
Soundtrack Soundtrack
@@ -48,14 +55,65 @@ func NewLibraryService(q repository.Querier) *LibraryService {
func toSoundtrack(s repository.Soundtrack) Soundtrack { func toSoundtrack(s repository.Soundtrack) Soundtrack {
return Soundtrack{ return Soundtrack{
Uuid: s.Uuid, Uuid: s.Uuid,
Name: s.SoundtrackName, Name: s.SoundtrackName,
TimesPlayed: s.TimesPlayed, AlternativeNames: nonNil(s.AlternativeNames),
LastPlayed: s.LastPlayed, TimesPlayed: s.TimesPlayed,
Added: s.Added, LastPlayed: s.LastPlayed,
Added: s.Added,
CoverModified: unixSeconds(s.CoverModified),
} }
} }
// nonNil returns names, or an empty list for nil, so the JSON is [] and never null.
func nonNil(names []string) []string {
if names == nil {
return []string{}
}
return names
}
// unixSeconds returns t in Unix seconds, or nil when it is not set.
func unixSeconds(t pgtype.Timestamptz) *int64 {
if !t.Valid {
return nil
}
seconds := t.Time.Unix()
return &seconds
}
// SetAlternativeNames replaces the alternative names of an active soundtrack. The names
// are trimmed, and empty names, duplicates and names equal to the soundtrack's own name
// (ignoring case) are dropped.
func (l *LibraryService) SetAlternativeNames(ctx context.Context, id uuid.UUID, names []string) (Soundtrack, error) {
row, err := l.q.GetSoundtrackByUUID(ctx, id)
if err != nil {
return Soundtrack{}, notFound(err)
}
row, err = l.q.SetAlternativeNames(ctx, repository.SetAlternativeNamesParams{
Uuid: id, Names: cleanAlternativeNames(row.SoundtrackName, names),
})
if err != nil {
return Soundtrack{}, notFound(err)
}
return toSoundtrack(row), nil
}
func cleanAlternativeNames(name string, names []string) []string {
cleaned := []string{}
seen := map[string]bool{strings.ToLower(strings.TrimSpace(name)): true}
for _, n := range names {
n = strings.TrimSpace(n)
key := strings.ToLower(n)
if n == "" || seen[key] {
continue
}
seen[key] = true
cleaned = append(cleaned, n)
}
return cleaned
}
func notFound(err error) error { 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))
}
+193 -85
View File
@@ -12,6 +12,7 @@ import (
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/metrics"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -21,6 +22,24 @@ import (
// maxPickAttempts bounds how many broken songs are skipped before giving up. // maxPickAttempts bounds how many broken songs are skipped before giving up.
const maxPickAttempts = 100 const maxPickAttempts = 100
// PickMode is how a random song was picked. It is stored with queued songs and used as
// the mode label of the song metrics.
type PickMode string
const (
PickRandom PickMode = "random"
PickLow PickMode = "low"
PickClassic PickMode = "classic"
)
func init() {
// Start every mode at 0, so the series exist before the first song.
for _, mode := range []PickMode{PickRandom, PickLow, PickClassic} {
metrics.SongsRequested.WithLabelValues(string(mode))
metrics.SongsPlayed.WithLabelValues(string(mode))
}
}
type SongInfo struct { type SongInfo struct {
SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"` SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"`
Soundtrack string `json:"Soundtrack"` Soundtrack string `json:"Soundtrack"`
@@ -32,23 +51,19 @@ type SongInfo struct {
SongNo int `json:"SongNo"` SongNo int `json:"SongNo"`
} }
// MusicService picks songs and keeps track of the shared play queue. // MusicService picks songs and keeps track of the play queues. Every client has its own queue,
// stored in the database and identified by its UUID.
type MusicService struct { type MusicService struct {
q repository.Querier q repository.Querier
soundCheckPath string soundCheckPath string
cacheMu sync.Mutex cacheMu sync.Mutex
soundtracks []repository.Soundtrack soundtracks []repository.Soundtrack
// Queue state is shared by all clients and intentionally not locked.
queue []repository.Song
currentSong int
lastFetched repository.Song
} }
// NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs. // NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs.
func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService { func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService {
return &MusicService{q: q, soundCheckPath: soundCheckPath, currentSong: -1} return &MusicService{q: q, soundCheckPath: soundCheckPath}
} }
func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) { func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) {
@@ -64,10 +79,8 @@ func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtr
return m.soundtracks, nil return m.soundtracks, nil
} }
// Reset clears the play queue and reloads the soundtrack list. // ReloadSoundtracks reloads the soundtrack list.
func (m *MusicService) Reset(ctx context.Context) error { func (m *MusicService) ReloadSoundtracks(ctx context.Context) error {
m.queue = nil
m.currentSong = -1
m.cacheMu.Lock() m.cacheMu.Lock()
m.soundtracks = nil m.soundtracks = nil
m.cacheMu.Unlock() m.cacheMu.Unlock()
@@ -75,6 +88,36 @@ func (m *MusicService) Reset(ctx context.Context) error {
return err return err
} }
// ResolveQueue returns the queue with the given ID if it is a running queue.
// Otherwise, for example when id is empty or the queue has ended, it starts a new queue.
func (m *MusicService) ResolveQueue(ctx context.Context, id string) (uuid.UUID, error) {
if queueID, err := uuid.Parse(id); err == nil {
queue, err := m.q.GetQueue(ctx, queueID)
if err == nil && !queue.EndedAt.Valid {
return queue.Uuid, nil
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, err
}
}
queue, err := m.q.CreateQueue(ctx)
return queue.Uuid, err
}
// NewQueue ends the given queue and starts a new one.
func (m *MusicService) NewQueue(ctx context.Context, queueID uuid.UUID) (uuid.UUID, error) {
if err := m.q.EndQueue(ctx, queueID); err != nil {
return uuid.Nil, err
}
queue, err := m.q.CreateQueue(ctx)
return queue.Uuid, err
}
// EndAllQueues ends every running queue. Clients get a new queue on their next request.
func (m *MusicService) EndAllQueues(ctx context.Context) error {
return m.q.EndActiveQueues(ctx)
}
// SoundCheckSong returns the path of a random sound check song. // SoundCheckSong returns the path of a random sound check song.
func (m *MusicService) SoundCheckSong() (string, error) { func (m *MusicService) SoundCheckSong() (string, error) {
files, err := os.ReadDir(m.soundCheckPath) files, err := os.ReadDir(m.soundCheckPath)
@@ -88,44 +131,57 @@ func (m *MusicService) SoundCheckSong() (string, error) {
return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil
} }
// AddLatestToQueue appends the last fetched random song to the queue. // AddLatestToQueue appends the last fetched random song to the queue and makes it the current song.
func (m *MusicService) AddLatestToQueue() { func (m *MusicService) AddLatestToQueue(ctx context.Context, queueID uuid.UUID) error {
if m.lastFetched.Path != "" { return m.q.AddLastFetchedToQueue(ctx, queueID)
m.currentSong = len(m.queue)
m.queue = append(m.queue, m.lastFetched)
m.lastFetched = repository.Song{}
}
} }
// AddLatestPlayed increments the play counters of the current song. // AddLatestPlayed increments the play counters of the current song.
func (m *MusicService) AddLatestPlayed(ctx context.Context) error { func (m *MusicService) AddLatestPlayed(ctx context.Context, queueID uuid.UUID) error {
if !m.validIndex(m.currentSong) { queue, songs, err := m.loadQueue(ctx, queueID)
return nil if err != nil {
return err
} }
return m.markPlayed(ctx, m.queue[m.currentSong]) return m.markPlayed(ctx, songs, int(queue.CurrentSong))
} }
// SetPlayed increments the play counters of the song at the given queue position. // SetPlayed increments the play counters of the song at the given queue position.
func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error { func (m *MusicService) SetPlayed(ctx context.Context, queueID uuid.UUID, songNumber int) error {
if !m.validIndex(songNumber) { _, songs, err := m.loadQueue(ctx, queueID)
return nil if err != nil {
return err
} }
return m.markPlayed(ctx, m.queue[songNumber]) return m.markPlayed(ctx, songs, songNumber)
} }
func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error { // markPlayed increments the play counters of songs[songNumber]. Positions outside the queue
// and songs that have been removed from the library are ignored.
func (m *MusicService) markPlayed(ctx context.Context, songs []repository.ListQueueSongsRow, songNumber int) error {
if songNumber < 0 || songNumber >= len(songs) || songs[songNumber].Path == nil {
return nil
}
song := songs[songNumber]
if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil { if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil {
return err return err
} }
return m.q.AddSongPlayedByUUID(ctx, song.Uuid) if err := m.q.AddSongPlayedByUUID(ctx, song.SongUuid); err != nil {
return err
}
metrics.SongsPlayed.WithLabelValues(song.Mode).Inc()
return nil
} }
func (m *MusicService) validIndex(i int) bool { func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) {
return i >= 0 && i < len(m.queue) queue, err := m.q.GetQueue(ctx, queueID)
if err != nil {
return repository.Queue{}, nil, err
}
songs, err := m.q.ListQueueSongs(ctx, queueID)
return queue, songs, err
} }
// RandomSong picks a random song from a random soundtrack. // RandomSong picks a random song from a random soundtrack.
func (m *MusicService) RandomSong(ctx context.Context) (string, error) { func (m *MusicService) RandomSong(ctx context.Context, queueID uuid.UUID) (string, error) {
soundtracks, err := m.allSoundtracks(ctx) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
return "", err return "", err
@@ -134,29 +190,44 @@ func (m *MusicService) RandomSong(ctx context.Context) (string, error) {
for i, s := range soundtracks { for i, s := range soundtracks {
candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1} candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1}
} }
return m.pickAndRemember(ctx, candidates) return m.pickAndRemember(ctx, queueID, candidates, PickRandom)
} }
// RandomSongLowChance picks a random song, favouring soundtracks played less than average. // RandomSongLowChance picks a random song, favouring soundtracks played less than average.
func (m *MusicService) RandomSongLowChance(ctx context.Context) (string, error) { func (m *MusicService) RandomSongLowChance(ctx context.Context, queueID uuid.UUID) (string, error) {
soundtracks, err := m.allSoundtracks(ctx) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
return m.pickAndRemember(ctx, lowChanceWeights(soundtracks)) return m.pickAndRemember(ctx, queueID, lowChanceWeights(soundtracks), PickLow)
} }
func (m *MusicService) pickAndRemember(ctx context.Context, candidates []weightedSoundtrack) (string, error) { func (m *MusicService) pickAndRemember(
ctx context.Context, queueID uuid.UUID, candidates []weightedSoundtrack, mode PickMode,
) (string, error) {
song, err := m.pickSong(ctx, candidates) song, err := m.pickSong(ctx, candidates)
if err != nil { if err != nil {
return "", err return "", err
} }
m.lastFetched = song return m.remember(ctx, queueID, song, mode)
}
// remember stores the song and how it was picked as the queue's last fetched song,
// for AddLatestToQueue.
func (m *MusicService) remember(ctx context.Context, queueID uuid.UUID, song repository.Song, mode PickMode) (string, error) {
modeName := string(mode)
err := m.q.SetQueueLastFetched(ctx, repository.SetQueueLastFetchedParams{
Uuid: queueID, LastFetchedSongUuid: song.Uuid, LastFetchedMode: &modeName,
})
if err != nil {
return "", err
}
metrics.SongsRequested.WithLabelValues(modeName).Inc()
return song.Path, nil return song.Path, nil
} }
// RandomSongClassic picks a random song where every song has the same chance. // RandomSongClassic picks a random song where every song has the same chance.
func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) { func (m *MusicService) RandomSongClassic(ctx context.Context, queueID uuid.UUID) (string, error) {
for range maxPickAttempts { for range maxPickAttempts {
row, err := m.q.GetRandomActiveSong(ctx) row, err := m.q.GetRandomActiveSong(ctx)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -171,73 +242,97 @@ func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) {
} }
continue continue
} }
m.lastFetched = row.Song return m.remember(ctx, queueID, row.Song, PickClassic)
return row.Song.Path, nil
} }
return "", ErrNoSongs return "", ErrNoSongs
} }
// SongInfo returns information about the current song in the queue. // SongInfo returns information about the current song in the queue.
func (m *MusicService) SongInfo(ctx context.Context) SongInfo { func (m *MusicService) SongInfo(ctx context.Context, queueID uuid.UUID) (SongInfo, error) {
if !m.validIndex(m.currentSong) { queue, songs, err := m.loadQueue(ctx, queueID)
return SongInfo{} if err != nil {
return SongInfo{}, err
} }
song := m.queue[m.currentSong] current := int(queue.CurrentSong)
soundtrack := m.soundtrackFor(ctx, song) if current < 0 || current >= len(songs) {
return SongInfo{ return SongInfo{}, nil
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: true,
SongNo: m.currentSong,
} }
return m.songInfo(ctx, songs[current], true), nil
} }
// PlayedSongs returns all songs in the queue. // PlayedSongs returns all songs in the queue.
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo { func (m *MusicService) PlayedSongs(ctx context.Context, queueID uuid.UUID) ([]SongInfo, error) {
songList := []SongInfo{} queue, songs, err := m.loadQueue(ctx, queueID)
for i, song := range m.queue { if err != nil {
soundtrack := m.soundtrackFor(ctx, song) return nil, err
songList = append(songList, SongInfo{
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == m.currentSong,
SongNo: i,
})
} }
return songList songList := []SongInfo{}
for _, song := range songs {
songList = append(songList, m.songInfo(ctx, song, song.SongNo == queue.CurrentSong))
}
return songList, nil
}
// songInfo describes a queued song. Songs removed from the library keep their stored names.
func (m *MusicService) songInfo(ctx context.Context, song repository.ListQueueSongsRow, current bool) SongInfo {
info := SongInfo{
SoundtrackUuid: song.SoundtrackUuid,
Soundtrack: song.SoundtrackName,
SongUuid: song.SongUuid,
Song: song.SongName,
CurrentlyPlaying: current,
SongNo: int(song.SongNo),
}
if song.TimesPlayed != nil {
info.SongPlayed = *song.TimesPlayed
}
if soundtrack, ok := m.soundtrackFor(ctx, song.SoundtrackUuid); ok {
info.Soundtrack = soundtrack.SoundtrackName
info.SoundtrackPlayed = soundtrack.TimesPlayed
}
return info
} }
// Song jumps to the given queue position (clamped to the queue) and returns its path. // Song jumps to the given queue position (clamped to the queue) and returns its path.
func (m *MusicService) Song(songNumber int) (string, error) { func (m *MusicService) Song(ctx context.Context, queueID uuid.UUID, songNumber int) (string, error) {
if len(m.queue) == 0 { _, songs, err := m.loadQueue(ctx, queueID)
return "", ErrQueueEmpty if err != nil {
return "", err
} }
m.currentSong = max(0, min(songNumber, len(m.queue)-1)) return m.jump(ctx, queueID, songs, songNumber)
return m.queue[m.currentSong].Path, nil
} }
// NextSong moves to the next song in the queue, staying on the last one at the end. // NextSong moves to the next song in the queue, staying on the last one at the end.
func (m *MusicService) NextSong() (string, error) { func (m *MusicService) NextSong(ctx context.Context, queueID uuid.UUID) (string, error) {
if len(m.queue) == 0 { queue, songs, err := m.loadQueue(ctx, queueID)
return "", ErrQueueEmpty if err != nil {
return "", err
} }
return m.Song(m.currentSong + 1) return m.jump(ctx, queueID, songs, int(queue.CurrentSong)+1)
} }
// PreviousSong moves to the previous song in the queue, staying on the first one at the start. // PreviousSong moves to the previous song in the queue, staying on the first one at the start.
func (m *MusicService) PreviousSong() (string, error) { func (m *MusicService) PreviousSong(ctx context.Context, queueID uuid.UUID) (string, error) {
if len(m.queue) == 0 { queue, songs, err := m.loadQueue(ctx, queueID)
if err != nil {
return "", err
}
return m.jump(ctx, queueID, songs, int(queue.CurrentSong)-1)
}
func (m *MusicService) jump(ctx context.Context, queueID uuid.UUID, songs []repository.ListQueueSongsRow, songNumber int) (string, error) {
if len(songs) == 0 {
return "", ErrQueueEmpty return "", ErrQueueEmpty
} }
return m.Song(m.currentSong - 1) songNumber = max(0, min(songNumber, len(songs)-1))
err := m.q.SetQueueCurrentSong(ctx, repository.SetQueueCurrentSongParams{Uuid: queueID, CurrentSong: int32(songNumber)})
if err != nil {
return "", err
}
if songs[songNumber].Path == nil {
return "", ErrNotFound
}
return *songs[songNumber].Path, nil
} }
// SoundtrackNames returns all soundtrack names in alphabetical order. // SoundtrackNames returns all soundtrack names in alphabetical order.
@@ -253,6 +348,19 @@ func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
return names, nil return names, nil
} }
// SearchSoundtracks returns every soundtrack's name and alternative names, for search.
func (m *MusicService) SearchSoundtracks(ctx context.Context) ([]SearchSoundtrack, error) {
soundtracks, err := m.allSoundtracks(ctx)
if err != nil {
return nil, err
}
result := make([]SearchSoundtrack, len(soundtracks))
for i, s := range soundtracks {
result[i] = SearchSoundtrack{Name: s.SoundtrackName, AlternativeNames: s.AlternativeNames}
}
return result, nil
}
// SoundtrackNamesRandom returns all soundtrack names in random order. // SoundtrackNamesRandom returns all soundtrack names in random order.
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) { func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
names, err := m.SoundtrackNames(ctx) names, err := m.SoundtrackNames(ctx)
@@ -352,16 +460,16 @@ func songIsPlayable(song repository.Song, soundtrackPath string) bool {
return err == nil && !info.IsDir() return err == nil && !info.IsDir()
} }
func (m *MusicService) soundtrackFor(ctx context.Context, song repository.Song) repository.Soundtrack { func (m *MusicService) soundtrackFor(ctx context.Context, soundtrackUUID uuid.UUID) (repository.Soundtrack, bool) {
soundtracks, err := m.allSoundtracks(ctx) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err)) logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err))
return repository.Soundtrack{} return repository.Soundtrack{}, false
} }
for _, soundtrack := range soundtracks { for _, soundtrack := range soundtracks {
if soundtrack.Uuid == song.SoundtrackUuid { if soundtrack.Uuid == soundtrackUUID {
return soundtrack return soundtrack, true
} }
} }
return repository.Soundtrack{} return repository.Soundtrack{}, false
} }
+180 -44
View File
@@ -33,30 +33,55 @@ func addSoundtrackWithSongs(t *testing.T, store *fakedb.Store, musicDir, name st
return st return st
} }
// newQueue starts a queue for the tests.
func newQueue(t *testing.T, m *MusicService) uuid.UUID {
t.Helper()
id, err := m.ResolveQueue(context.Background(), "")
require.NoError(t, err)
return id
}
// queueSong fetches a random song and adds it to the queue.
func queueSong(t *testing.T, m *MusicService, queueID uuid.UUID) string {
t.Helper()
path, err := m.RandomSong(context.Background(), queueID)
require.NoError(t, err)
require.NoError(t, m.AddLatestToQueue(context.Background(), queueID))
return path
}
func TestMusicServiceEmptyQueue(t *testing.T) { func TestMusicServiceEmptyQueue(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) m := NewMusicService(fakedb.New(), t.TempDir())
ctx := context.Background()
q := newQueue(t, m)
_, err := m.NextSong() _, err := m.NextSong(ctx, q)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
_, err = m.PreviousSong() _, err = m.PreviousSong(ctx, q)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
_, err = m.Song(0) _, err = m.Song(ctx, q, 0)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
assert.Equal(t, SongInfo{}, m.SongInfo(context.Background())) info, err := m.SongInfo(ctx, q)
assert.Empty(t, m.PlayedSongs(context.Background())) require.NoError(t, err)
assert.NoError(t, m.AddLatestPlayed(context.Background())) assert.Equal(t, SongInfo{}, info)
assert.NoError(t, m.SetPlayed(context.Background(), -1)) played, err := m.PlayedSongs(ctx, q)
require.NoError(t, err)
assert.Empty(t, played)
assert.NotNil(t, played, "an empty queue is [] in JSON")
assert.NoError(t, m.AddLatestPlayed(ctx, q))
assert.NoError(t, m.SetPlayed(ctx, q, -1))
} }
func TestMusicServiceNoSoundtracks(t *testing.T) { func TestMusicServiceNoSoundtracks(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) m := NewMusicService(fakedb.New(), t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
_, err := m.RandomSong(ctx) _, err := m.RandomSong(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
_, err = m.RandomSongLowChance(ctx) _, err = m.RandomSongLowChance(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
_, err = m.RandomSongClassic(ctx) _, err = m.RandomSongClassic(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
} }
@@ -65,7 +90,7 @@ func TestMusicServiceSoundtracksWithoutSongs(t *testing.T) {
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"}) store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
_, err := m.RandomSong(context.Background()) _, err := m.RandomSong(context.Background(), newQueue(t, m))
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
} }
@@ -75,70 +100,182 @@ func TestMusicServiceQueue(t *testing.T) {
addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2") addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2")
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
first, err := m.RandomSong(ctx) first := queueSong(t, m, q)
require.NoError(t, err) second := queueSong(t, m, q)
m.AddLatestToQueue()
second, err := m.RandomSong(ctx)
require.NoError(t, err)
m.AddLatestToQueue()
info := m.SongInfo(ctx) info, err := m.SongInfo(ctx, q)
require.NoError(t, err)
assert.Equal(t, "Game A", info.Soundtrack) assert.Equal(t, "Game A", info.Soundtrack)
soundtrack, _ := store.Soundtrack("Game A") soundtrack, _ := store.Soundtrack("Game A")
assert.Equal(t, soundtrack.Uuid, info.SoundtrackUuid) assert.Equal(t, soundtrack.Uuid, info.SoundtrackUuid)
assert.Contains(t, []string{first, second}, songPathByUUID(store, info.SongUuid)) assert.Equal(t, second, songPathByUUID(store, info.SongUuid))
assert.Equal(t, 1, info.SongNo) assert.Equal(t, 1, info.SongNo)
assert.True(t, info.CurrentlyPlaying) assert.True(t, info.CurrentlyPlaying)
path, err := m.PreviousSong() path, err := m.PreviousSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, first, path) assert.Equal(t, first, path)
path, err = m.PreviousSong() path, err = m.PreviousSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, first, path, "previous stays on the first song") assert.Equal(t, first, path, "previous stays on the first song")
path, err = m.NextSong() path, err = m.NextSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, second, path) assert.Equal(t, second, path)
path, err = m.NextSong() path, err = m.NextSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, second, path, "next stays on the last song") assert.Equal(t, second, path, "next stays on the last song")
path, err = m.Song(99) path, err = m.Song(ctx, q, 99)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, second, path, "song number is clamped") assert.Equal(t, second, path, "song number is clamped")
played := m.PlayedSongs(ctx) played, err := m.PlayedSongs(ctx, q)
require.NoError(t, err)
require.Len(t, played, 2) require.Len(t, played, 2)
assert.False(t, played[0].CurrentlyPlaying) assert.False(t, played[0].CurrentlyPlaying)
assert.True(t, played[1].CurrentlyPlaying) assert.True(t, played[1].CurrentlyPlaying)
}
require.NoError(t, m.Reset(ctx)) func TestMusicServiceQueuesAreSeparate(t *testing.T) {
_, err = m.NextSong() store := fakedb.New()
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir())
ctx := context.Background()
q1, q2 := newQueue(t, m), newQueue(t, m)
require.NotEqual(t, q1, q2)
queueSong(t, m, q1)
queueSong(t, m, q1)
queueSong(t, m, q2)
played, err := m.PlayedSongs(ctx, q1)
require.NoError(t, err)
assert.Len(t, played, 2)
played, err = m.PlayedSongs(ctx, q2)
require.NoError(t, err)
assert.Len(t, played, 1)
}
func TestMusicServiceQueueSurvivesRestart(t *testing.T) {
store := fakedb.New()
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir())
q := newQueue(t, m)
path := queueSong(t, m, q)
restarted := NewMusicService(store, t.TempDir())
id, err := restarted.ResolveQueue(context.Background(), q.String())
require.NoError(t, err)
assert.Equal(t, q, id)
current, err := restarted.Song(context.Background(), q, 0)
require.NoError(t, err)
assert.Equal(t, path, current)
}
func TestMusicServiceNewQueue(t *testing.T) {
store := fakedb.New()
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir())
ctx := context.Background()
old := newQueue(t, m)
queueSong(t, m, old)
q, err := m.NewQueue(ctx, old)
require.NoError(t, err)
assert.NotEqual(t, old, q)
_, err = m.NextSong(ctx, q)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
resolved, err := m.ResolveQueue(ctx, old.String())
require.NoError(t, err)
assert.NotEqual(t, old, resolved, "an ended queue is not used again")
played, err := m.PlayedSongs(ctx, old)
require.NoError(t, err)
assert.Len(t, played, 1, "ended queues are kept")
}
func TestMusicServiceEndAllQueues(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir())
ctx := context.Background()
q1, q2 := newQueue(t, m), newQueue(t, m)
require.NoError(t, m.EndAllQueues(ctx))
for _, q := range []uuid.UUID{q1, q2} {
id, err := m.ResolveQueue(ctx, q.String())
require.NoError(t, err)
assert.NotEqual(t, q, id)
}
}
func TestMusicServiceResolveQueue(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir())
ctx := context.Background()
q := newQueue(t, m)
id, err := m.ResolveQueue(ctx, q.String())
require.NoError(t, err)
assert.Equal(t, q, id)
for _, header := range []string{"", "not-a-uuid", uuid.NewString()} {
id, err := m.ResolveQueue(ctx, header)
require.NoError(t, err)
assert.NotEqual(t, uuid.Nil, id, header)
assert.NotEqual(t, q, id, header)
}
}
func TestMusicServiceRemovedSongInQueue(t *testing.T) {
store := fakedb.New()
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir())
ctx := context.Background()
q := newQueue(t, m)
queueSong(t, m, q)
require.NoError(t, store.ClearSongs(ctx))
_, err := m.Song(ctx, q, 0)
assert.ErrorIs(t, err, ErrNotFound)
info, err := m.SongInfo(ctx, q)
require.NoError(t, err)
assert.Equal(t, "Game A", info.Soundtrack, "the stored names are kept")
assert.Equal(t, "Song 1", info.Song)
assert.NoError(t, m.AddLatestPlayed(ctx, q), "removed songs are not counted")
} }
func TestMusicServiceAddLatestToQueueWithoutFetch(t *testing.T) { func TestMusicServiceAddLatestToQueueWithoutFetch(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) m := NewMusicService(fakedb.New(), t.TempDir())
m.AddLatestToQueue() q := newQueue(t, m)
_, err := m.NextSong() require.NoError(t, m.AddLatestToQueue(context.Background(), q))
_, err := m.NextSong(context.Background(), q)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
} }
func TestMusicServiceAddLatestToQueueOnlyOnce(t *testing.T) {
store := fakedb.New()
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir())
q := newQueue(t, m)
queueSong(t, m, q)
require.NoError(t, m.AddLatestToQueue(context.Background(), q))
played, err := m.PlayedSongs(context.Background(), q)
require.NoError(t, err)
assert.Len(t, played, 1)
}
func TestMusicServiceMarkPlayed(t *testing.T) { func TestMusicServiceMarkPlayed(t *testing.T) {
store := fakedb.New() store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
queueSong(t, m, q)
_, err := m.RandomSong(ctx) require.NoError(t, m.AddLatestPlayed(ctx, q))
require.NoError(t, err) require.NoError(t, m.SetPlayed(ctx, q, 0))
m.AddLatestToQueue() require.NoError(t, m.SetPlayed(ctx, q, 5), "out of range is ignored")
require.NoError(t, m.AddLatestPlayed(ctx))
require.NoError(t, m.SetPlayed(ctx, 0))
require.NoError(t, m.SetPlayed(ctx, 5), "out of range is ignored")
updated, _ := store.Soundtrack("Game A") updated, _ := store.Soundtrack("Game A")
assert.Equal(t, int32(2), updated.TimesPlayed) assert.Equal(t, int32(2), updated.TimesPlayed)
@@ -155,9 +292,10 @@ func TestMusicServiceRemovesBrokenSongs(t *testing.T) {
writeFile(t, st.Path+wav, "wav") writeFile(t, st.Path+wav, "wav")
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
q := newQueue(t, m)
for range 20 { for range 20 {
path, err := m.RandomSong(context.Background()) path, err := m.RandomSong(context.Background(), q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, st.Path+"Good.mp3", path) assert.Equal(t, st.Path+"Good.mp3", path)
} }
@@ -172,9 +310,10 @@ func TestMusicServiceRandomSongClassic(t *testing.T) {
missing := "Missing.mp3" missing := "Missing.mp3"
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
q := newQueue(t, m)
for range 20 { for range 20 {
path, err := m.RandomSongClassic(context.Background()) path, err := m.RandomSongClassic(context.Background(), q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, st.Path+"Good.mp3", path) assert.Equal(t, st.Path+"Good.mp3", path)
} }
@@ -244,12 +383,9 @@ func TestMarkPlayedUsesSongUUID(t *testing.T) {
writeFile(t, st.Path+fileName, "copy") writeFile(t, st.Path+fileName, "copy")
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Song", Path: st.Path + fileName, FileName: &fileName}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Song", Path: st.Path + fileName, FileName: &fileName})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() q := newQueue(t, m)
queueSong(t, m, q)
_, err := m.RandomSong(ctx) require.NoError(t, m.AddLatestPlayed(context.Background(), q))
require.NoError(t, err)
m.AddLatestToQueue()
require.NoError(t, m.AddLatestPlayed(ctx))
songs := store.SongsOf(st.ID) songs := store.SongsOf(st.ID)
require.Len(t, songs, 2) require.Len(t, songs, 2)
+113
View File
@@ -0,0 +1,113 @@
//go:build integration
package backend
import (
"context"
"testing"
"music-server/internal/db/repository"
"music-server/internal/testutil/pgtest"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestQueueWithPostgres checks the queue queries against a real database.
func TestQueueWithPostgres(t *testing.T) {
ctx := context.Background()
database := pgtest.NewMigratedDatabase(t)
q := repository.New(database.Pool)
s := NewSyncService(q, copyTestMusic(t), nil)
require.True(t, s.Start(false))
s.Wait()
m := NewMusicService(q, t.TempDir())
queueID, err := m.ResolveQueue(ctx, "")
require.NoError(t, err)
require.NoError(t, m.AddLatestToQueue(ctx, queueID), "nothing fetched yet")
var paths []string
for range 3 {
path, err := m.RandomSong(ctx, queueID)
require.NoError(t, err)
require.NoError(t, m.AddLatestToQueue(ctx, queueID))
paths = append(paths, path)
}
require.NoError(t, m.AddLatestToQueue(ctx, queueID), "a fetched song is only added once")
played, err := m.PlayedSongs(ctx, queueID)
require.NoError(t, err)
require.Len(t, played, 3)
for i, song := range played {
assert.Equal(t, i, song.SongNo)
assert.Equal(t, i == 2, song.CurrentlyPlaying, "the last added song is current")
assert.NotEmpty(t, song.Soundtrack)
assert.NotEqual(t, uuid.Nil, song.SoundtrackUuid)
}
var modes []string
rows, err := database.Pool.Query(ctx, "SELECT mode FROM queue_song WHERE queue_uuid = $1 ORDER BY song_no", queueID)
require.NoError(t, err)
for rows.Next() {
var mode string
require.NoError(t, rows.Scan(&mode))
modes = append(modes, mode)
}
require.NoError(t, rows.Err())
assert.Equal(t, []string{"random", "random", "random"}, modes)
_, err = m.RandomSongClassic(ctx, queueID)
require.NoError(t, err)
require.NoError(t, m.AddLatestToQueue(ctx, queueID))
var classic string
require.NoError(t, database.Pool.QueryRow(ctx, "SELECT mode FROM queue_song WHERE queue_uuid = $1 AND song_no = 3", queueID).Scan(&classic))
assert.Equal(t, "classic", classic)
played, err = m.PlayedSongs(ctx, queueID)
require.NoError(t, err)
require.Len(t, played, 4)
path, err := m.Song(ctx, queueID, 2)
require.NoError(t, err)
assert.Equal(t, paths[2], path)
path, err = m.PreviousSong(ctx, queueID)
require.NoError(t, err)
assert.Equal(t, paths[1], path)
require.NoError(t, m.AddLatestPlayed(ctx, queueID))
info, err := m.SongInfo(ctx, queueID)
require.NoError(t, err)
assert.Equal(t, 1, info.SongNo)
assert.Equal(t, int32(1), info.SongPlayed)
// A new service, as after a restart, sees the same queue.
restarted := NewMusicService(q, t.TempDir())
same, err := restarted.ResolveQueue(ctx, queueID.String())
require.NoError(t, err)
assert.Equal(t, queueID, same)
newID, err := m.NewQueue(ctx, queueID)
require.NoError(t, err)
assert.NotEqual(t, queueID, newID)
resolved, err := m.ResolveQueue(ctx, queueID.String())
require.NoError(t, err)
assert.NotEqual(t, queueID, resolved, "an ended queue is not used again")
other, err := m.ResolveQueue(ctx, "")
require.NoError(t, err)
require.NoError(t, m.EndAllQueues(ctx))
var active int
require.NoError(t, database.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM queue WHERE ended_at IS NULL").Scan(&active))
assert.Zero(t, active)
_, err = m.ResolveQueue(ctx, other.String())
require.NoError(t, err)
// Ended queues keep their songs, also after the songs are removed from the library.
require.NoError(t, q.ClearSongs(ctx))
played, err = m.PlayedSongs(ctx, queueID)
require.NoError(t, err)
require.Len(t, played, 4)
assert.NotEmpty(t, played[0].Song)
_, err = m.Song(ctx, queueID, 0)
assert.ErrorIs(t, err, ErrNotFound)
}
+20
View File
@@ -52,6 +52,26 @@ func TestReleaseService(t *testing.T) {
assert.ErrorIs(t, err, ErrNotFound) assert.ErrorIs(t, err, ErrNotFound)
} }
func TestReleaseServiceLinuxBinaryAndPackage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(giteaRelease{Id: 1, Name: "v1.9.0", Assets: []giteaAsset{
{Name: "MusicPlayer_1.9.0.exe", DownloadUrl: "https://example.com/exe"},
{Name: "MusicPlayer_1.9.0_linux.tar.gz", DownloadUrl: "https://example.com/package"},
{Name: "MusicPlayer_1.9.0.x86_64", DownloadUrl: "https://example.com/binary"},
}})
}))
t.Cleanup(server.Close)
r := NewReleaseService(server.URL)
ctx := context.Background()
binary, err := r.AssetURL(ctx, ".x86_64")
require.NoError(t, err)
assert.Equal(t, "https://example.com/binary", binary)
pkg, err := r.AssetURL(ctx, "_linux.tar.gz")
require.NoError(t, err)
assert.Equal(t, "https://example.com/package", pkg)
}
func TestReleaseServiceCachesRelease(t *testing.T) { func TestReleaseServiceCachesRelease(t *testing.T) {
server, calls := newGitea(t, http.StatusOK) server, calls := newGitea(t, http.StatusOK)
r := NewReleaseService(server.URL) r := NewReleaseService(server.URL)
+26 -10
View File
@@ -6,23 +6,40 @@ import (
"sync" "sync"
) )
// SoundtrackData holds precomputed search data for a soundtrack // SearchSoundtrack is a soundtrack's name and alternative names, as loaded for search.
type SoundtrackData struct { type SearchSoundtrack struct {
Name string
AlternativeNames []string
}
// SearchName holds a name prepared for matching.
type SearchName struct {
Original string Original string
Lowercase string Lowercase string
Cleaned string Cleaned string
} }
// SearchCache holds precomputed search data for all soundtrack names. func newSearchName(name string) SearchName {
return SearchName{Original: name, Lowercase: strings.ToLower(name), Cleaned: CleanSearchTerm(name)}
}
// SoundtrackData holds precomputed search data for a soundtrack: its name and its
// alternative names.
type SoundtrackData struct {
SearchName
Alternatives []SearchName
}
// SearchCache holds precomputed search data for all soundtracks.
type SearchCache struct { type SearchCache struct {
load func(context.Context) ([]string, error) load func(context.Context) ([]SearchSoundtrack, error)
mu sync.Mutex mu sync.Mutex
cache []SoundtrackData cache []SoundtrackData
} }
// NewSearchCache creates a SearchCache that gets the soundtrack names from load. // NewSearchCache creates a SearchCache that gets the soundtracks from load.
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache { func NewSearchCache(load func(context.Context) ([]SearchSoundtrack, error)) *SearchCache {
return &SearchCache{load: load} return &SearchCache{load: load}
} }
@@ -52,10 +69,9 @@ func (s *SearchCache) reload(ctx context.Context) error {
} }
cache := make([]SoundtrackData, len(soundtracks)) cache := make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks { for i, soundtrack := range soundtracks {
cache[i] = SoundtrackData{ cache[i] = SoundtrackData{SearchName: newSearchName(soundtrack.Name)}
Original: soundtrack, for _, name := range soundtrack.AlternativeNames {
Lowercase: strings.ToLower(soundtrack), cache[i].Alternatives = append(cache[i].Alternatives, newSearchName(name))
Cleaned: CleanSearchTerm(soundtrack),
} }
} }
s.cache = cache s.cache = cache
+9 -6
View File
@@ -40,28 +40,31 @@ func TestMatchesSubsequence(t *testing.T) {
} }
func TestSearchCache(t *testing.T) { func TestSearchCache(t *testing.T) {
names := []string{"Final Fantasy VII"} soundtracks := []SearchSoundtrack{{Name: "Final Fantasy VII", AlternativeNames: []string{"FF 7"}}}
calls := 0 calls := 0
cache := NewSearchCache(func(context.Context) ([]string, error) { cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) {
calls++ calls++
return names, nil return soundtracks, nil
}) })
ctx := context.Background() ctx := context.Background()
data, err := cache.Get(ctx) data, err := cache.Get(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []SoundtrackData{{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"}}, data) assert.Equal(t, []SoundtrackData{{
SearchName: SearchName{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"},
Alternatives: []SearchName{{Original: "FF 7", Lowercase: "ff 7", Cleaned: "ff7"}},
}}, data)
_, _ = cache.Get(ctx) _, _ = cache.Get(ctx)
assert.Equal(t, 1, calls, "data is cached") assert.Equal(t, 1, calls, "data is cached")
names = []string{"Chrono Trigger"} soundtracks = []SearchSoundtrack{{Name: "Chrono Trigger"}}
require.NoError(t, cache.Reload(ctx)) require.NoError(t, cache.Reload(ctx))
data, _ = cache.Get(ctx) data, _ = cache.Get(ctx)
assert.Equal(t, "Chrono Trigger", data[0].Original) assert.Equal(t, "Chrono Trigger", data[0].Original)
} }
func TestSearchCacheLoadError(t *testing.T) { func TestSearchCacheLoadError(t *testing.T) {
cache := NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") }) cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) { return nil, errors.New("db down") })
_, err := cache.Get(context.Background()) _, err := cache.Get(context.Background())
assert.Error(t, err) assert.Error(t, err)
} }
+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),
}) })
} }
+80 -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"
@@ -34,8 +35,15 @@ const (
brokenWorkers = 50 brokenWorkers = 50
) )
// foldersToSkip are directories in the music path that are never synced. // foldersToSkip are directories in the music path that are never synced, besides hidden
var foldersToSkip = map[string]struct{}{".sync": {}, "characters": {}, "dist": {}, "old": {}} // ones like .sync and .uploads.
var foldersToSkip = map[string]struct{}{"characters": {}, "dist": {}, "old": {}}
// skipFolder reports whether a directory in the music path is not a soundtrack.
func skipFolder(name string) bool {
_, skip := foldersToSkip[name]
return skip || strings.HasPrefix(name, ".")
}
type SyncResponse struct { type SyncResponse struct {
SoundtracksAdded []string `json:"soundtracks_added"` SoundtracksAdded []string `json:"soundtracks_added"`
@@ -116,6 +124,37 @@ func (s *SyncService) Start(full bool) bool {
return true return true
} }
// SyncFolder runs prepare and then syncs the soundtrack folder name, while no other sync
// can run. It returns ErrSyncing when a sync is running. prepare can put the folder in
// place; when it fails, nothing is synced. onComplete is not called, as only one
// soundtrack changes.
func (s *SyncService) SyncFolder(ctx context.Context, name string, prepare func() error) (repository.Soundtrack, error) {
if !s.running.CompareAndSwap(false, true) {
return repository.Soundtrack{}, ErrSyncing
}
defer s.running.Store(false)
existing, err := s.q.GetAllSoundtracksIncludingDeleted(ctx)
if err != nil {
return repository.Soundtrack{}, err
}
if err := prepare(); err != nil {
return repository.Soundtrack{}, err
}
run := &syncRun{q: s.q, musicPath: s.musicPath, existing: existing, progress: func() {}}
run.syncSoundtrack(ctx, name)
if len(run.errs) > 0 {
return repository.Soundtrack{}, fmt.Errorf("syncing %q: %s", name, strings.Join(run.errs, "; "))
}
if len(run.foundIDs) != 1 {
return repository.Soundtrack{}, fmt.Errorf("syncing %q found %d soundtracks", name, len(run.foundIDs))
}
if err := s.UpdateLibraryMetrics(ctx); err != nil {
logging.GetLogger().Error("Failed to count the library", zap.Error(err))
}
return s.q.GetSoundtrackById(ctx, run.foundIDs[0])
}
// Wait blocks until the running sync, if any, has finished. // Wait blocks until the running sync, if any, has finished.
func (s *SyncService) Wait() { func (s *SyncService) Wait() {
s.wg.Wait() s.wg.Wait()
@@ -199,6 +238,7 @@ func (s *SyncService) run(ctx context.Context, full bool) {
run := &syncRun{q: s.q, musicPath: s.musicPath, full: full, progress: func() { s.foldersSynced.Add(1) }} 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 +258,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)
@@ -238,7 +314,7 @@ func (r *syncRun) execute(ctx context.Context, setTotal func(int)) SyncResponse
for _, dir := range directories { for _, dir := range directories {
g.Go(func() error { g.Go(func() error {
defer r.progress() defer r.progress()
if _, skip := foldersToSkip[dir.Name()]; dir.IsDir() && !skip { if dir.IsDir() && !skipFolder(dir.Name()) {
r.syncSoundtrack(ctx, dir.Name()) r.syncSoundtrack(ctx, dir.Name())
} }
return nil return nil
@@ -623,6 +699,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, "")
+31
View File
@@ -403,3 +403,34 @@ 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, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second))
} }
func TestSyncKeepsAlternativeNamesAndCover(t *testing.T) {
f := newSyncFixture(t)
f.write("Final Fantasy 7/01.mp3", "a1")
f.run(false)
st := f.soundtrack("Final Fantasy 7")
_, err := f.store.SetAlternativeNames(context.Background(),
repository.SetAlternativeNamesParams{Uuid: st.Uuid, Names: []string{"FF7"}})
require.NoError(t, err)
file, source := "cover.jpg", "upload"
require.NoError(t, f.store.SetCover(context.Background(),
repository.SetCoverParams{Uuid: st.Uuid, File: &file, Source: &source, Manual: true}))
// Renamed: the same row is kept.
require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy 7"), filepath.Join(f.dir, "Final Fantasy VII")))
f.run(false)
renamed := f.soundtrack("Final Fantasy VII")
assert.Equal(t, st.Uuid, renamed.Uuid)
assert.Equal(t, []string{"FF7"}, renamed.AlternativeNames)
assert.Equal(t, &file, renamed.CoverFile)
// Removed and added again.
require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy VII"), filepath.Join(t.TempDir(), "away")))
f.run(false)
require.NoError(t, os.MkdirAll(filepath.Join(f.dir, "Final Fantasy VII"), 0o755))
f.write("Final Fantasy VII/01.mp3", "a1")
f.run(false)
readded := f.soundtrack("Final Fantasy VII")
assert.Nil(t, readded.Deleted)
assert.Equal(t, []string{"FF7"}, readded.AlternativeNames)
}
+412
View File
@@ -0,0 +1,412 @@
package backend
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/google/uuid"
"go.uber.org/zap"
)
const (
// uploadsFolder is where unfinished uploads are kept, inside the music folder. The
// sync skips it, like every hidden folder.
uploadsFolder = ".uploads"
// uploadMaxAge is how long an unfinished upload is kept.
uploadMaxAge = 24 * time.Hour
// maxUploadNameLength is the longest soundtrack or file name, in bytes.
maxUploadNameLength = 200
)
// Upload is an unfinished soundtrack upload.
type Upload struct {
ID uuid.UUID `json:"id"`
Name string `json:"name" doc:"Name of the new soundtrack, also its folder name"`
Started time.Time `json:"started"`
Files []UploadFile `json:"files" doc:"The songs received so far, sorted by name"`
TotalSize int64 `json:"total_size" doc:"Size of all songs received so far, in bytes"`
}
// UploadFile is a song received for an upload.
type UploadFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
// uploadInfo is stored next to an upload's folder, as <id>.json.
type uploadInfo struct {
Name string `json:"name"`
Started time.Time `json:"started"`
}
// UploadService adds new soundtracks from uploaded songs. An upload is started with a name,
// gets one song per request, and is finished by moving its folder into the music folder
// and syncing it.
type UploadService struct {
q repository.Querier
musicPath string
sync *SyncService
library *LibraryService
// MaxFileSize and MaxTotalSize limit a song and a whole upload, in bytes.
MaxFileSize int64
MaxTotalSize int64
now func() time.Time
// mu keeps requests for the same upload, and the name checks, from interleaving.
mu sync.Mutex
}
// NewUploadService creates an UploadService for the music folder.
func NewUploadService(q repository.Querier, musicPath string, syncService *SyncService, library *LibraryService,
maxFileSize, maxTotalSize int64) *UploadService {
return &UploadService{
q: q, musicPath: musicPath, sync: syncService, library: library,
MaxFileSize: maxFileSize, MaxTotalSize: maxTotalSize, now: time.Now,
}
}
func (u *UploadService) uploadsDir() string {
return filepath.Join(u.musicPath, uploadsFolder)
}
func (u *UploadService) dir(id uuid.UUID) string {
return filepath.Join(u.uploadsDir(), id.String())
}
func (u *UploadService) infoPath(id uuid.UUID) string {
return filepath.Join(u.uploadsDir(), id.String()+".json")
}
// Start begins an upload of a new soundtrack called name. The name must be usable as a
// folder name, and no soundtrack, folder or other upload may have it.
func (u *UploadService) Start(ctx context.Context, name string) (Upload, error) {
name = strings.TrimSpace(name)
if err := checkUploadName(name, "soundtrack"); err != nil {
return Upload{}, err
}
if skipFolder(name) {
return Upload{}, fmt.Errorf("%w: %q is used for other things in the music folder", ErrInvalidName, name)
}
if u.sync.IsSyncing() {
return Upload{}, ErrSyncing
}
u.mu.Lock()
defer u.mu.Unlock()
u.removeOld()
if err := os.MkdirAll(u.uploadsDir(), 0o755); err != nil {
logging.GetLogger().Error("Failed to create the uploads folder", zap.Error(err))
return Upload{}, ErrUploadsUnavailable
}
if err := u.checkNameFree(ctx, name, uuid.Nil); err != nil {
return Upload{}, err
}
upload := Upload{ID: uuid.New(), Name: name, Started: u.now(), Files: []UploadFile{}}
if err := os.Mkdir(u.dir(upload.ID), 0o755); err != nil {
return Upload{}, err
}
info, err := json.Marshal(uploadInfo{Name: name, Started: upload.Started})
if err == nil {
err = os.WriteFile(u.infoPath(upload.ID), info, 0o644)
}
if err != nil {
_ = os.RemoveAll(u.dir(upload.ID))
return Upload{}, err
}
logging.GetLogger().Info("Soundtrack upload started", zap.String("name", name), zap.Stringer("id", upload.ID))
return upload, nil
}
// checkNameFree returns ErrConflict when an active soundtrack, a folder in the music folder
// or an upload other than except already has name, ignoring case. Call with mu held.
func (u *UploadService) checkNameFree(ctx context.Context, name string, except uuid.UUID) error {
soundtracks, err := u.q.FindAllSoundtracks(ctx)
if err != nil {
return err
}
for _, s := range soundtracks {
if strings.EqualFold(s.SoundtrackName, name) {
return fmt.Errorf("%w: a soundtrack called %q", ErrConflict, s.SoundtrackName)
}
}
entries, err := os.ReadDir(u.musicPath)
if err != nil {
return err
}
for _, entry := range entries {
if strings.EqualFold(entry.Name(), name) {
return fmt.Errorf("%w: a folder called %q in the music folder", ErrConflict, entry.Name())
}
}
uploads, err := u.list()
if err != nil {
return err
}
for _, other := range uploads {
if other.id != except && strings.EqualFold(other.info.Name, name) {
return fmt.Errorf("%w: an unfinished upload of %q", ErrConflict, other.info.Name)
}
}
return nil
}
// checkUploadName checks that name can be used as a file or folder name on every system.
func checkUploadName(name, what string) error {
switch {
case name == "":
return fmt.Errorf("%w: the %s name is empty", ErrInvalidName, what)
case len(name) > maxUploadNameLength:
return fmt.Errorf("%w: the %s name is longer than %d bytes", ErrInvalidName, what, maxUploadNameLength)
case !utf8.ValidString(name):
return fmt.Errorf("%w: the %s name is not valid UTF-8", ErrInvalidName, what)
case strings.HasPrefix(name, "."):
return fmt.Errorf("%w: the %s name cannot start with a dot", ErrInvalidName, what)
case strings.TrimRight(name, ". ") != name:
return fmt.Errorf("%w: the %s name cannot end with a dot or a space", ErrInvalidName, what)
case strings.ContainsAny(name, `/\:*?"<>|`):
return fmt.Errorf(`%w: the %s name cannot contain / \ : * ? " < > |`, ErrInvalidName, what)
case strings.ContainsFunc(name, unicode.IsControl):
return fmt.Errorf("%w: the %s name cannot contain control characters", ErrInvalidName, what)
}
return nil
}
type storedUpload struct {
id uuid.UUID
info uploadInfo
}
// list returns all unfinished uploads. Call with mu held.
func (u *UploadService) list() ([]storedUpload, error) {
entries, err := os.ReadDir(u.uploadsDir())
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var uploads []storedUpload
for _, entry := range entries {
idText, ok := strings.CutSuffix(entry.Name(), ".json")
id, err := uuid.Parse(idText)
if !ok || err != nil {
continue
}
info, err := u.readInfo(id)
if err == nil {
uploads = append(uploads, storedUpload{id: id, info: info})
}
}
return uploads, nil
}
func (u *UploadService) readInfo(id uuid.UUID) (uploadInfo, error) {
data, err := os.ReadFile(u.infoPath(id))
if errors.Is(err, fs.ErrNotExist) {
return uploadInfo{}, ErrNotFound
}
if err != nil {
return uploadInfo{}, err
}
var info uploadInfo
if err := json.Unmarshal(data, &info); err != nil {
return uploadInfo{}, err
}
return info, nil
}
// Get returns an unfinished upload with the songs received so far.
func (u *UploadService) Get(id uuid.UUID) (Upload, error) {
u.mu.Lock()
defer u.mu.Unlock()
return u.get(id)
}
func (u *UploadService) get(id uuid.UUID) (Upload, error) {
info, err := u.readInfo(id)
if err != nil {
return Upload{}, err
}
upload := Upload{ID: id, Name: info.Name, Started: info.Started, Files: []UploadFile{}}
entries, err := os.ReadDir(u.dir(id))
if err != nil {
return Upload{}, err
}
for _, entry := range entries {
if !isSong(entry) {
continue
}
fileInfo, err := entry.Info()
if err != nil {
return Upload{}, err
}
upload.Files = append(upload.Files, UploadFile{Name: entry.Name(), Size: fileInfo.Size()})
upload.TotalSize += fileInfo.Size()
}
return upload, nil
}
// PutFile stores data as the song fileName of an upload, replacing a song of the same name.
// Only MP3 files are accepted, like the sync.
func (u *UploadService) PutFile(id uuid.UUID, fileName string, data []byte) (UploadFile, error) {
if err := checkUploadName(fileName, "file"); err != nil {
return UploadFile{}, err
}
if !strings.EqualFold(filepath.Ext(fileName), ".mp3") {
return UploadFile{}, fmt.Errorf("%w: only .mp3 files can be uploaded", ErrInvalidName)
}
if int64(len(data)) > u.MaxFileSize {
return UploadFile{}, fmt.Errorf("%w: a song can be at most %d MB", ErrTooLarge, u.MaxFileSize>>20)
}
if !isMP3(data) {
return UploadFile{}, fmt.Errorf("%w: %q is not an MP3 file", ErrInvalidName, fileName)
}
u.mu.Lock()
defer u.mu.Unlock()
upload, err := u.get(id)
if err != nil {
return UploadFile{}, err
}
total := upload.TotalSize + int64(len(data))
for _, f := range upload.Files {
if f.Name == fileName {
total -= f.Size // replaced
}
}
if total > u.MaxTotalSize {
return UploadFile{}, fmt.Errorf("%w: an upload can be at most %d MB", ErrTooLarge, u.MaxTotalSize>>20)
}
tmp, err := os.CreateTemp(u.dir(id), ".part-*")
if err != nil {
return UploadFile{}, err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return UploadFile{}, err
}
if err := tmp.Close(); err != nil {
return UploadFile{}, err
}
if err := os.Rename(tmp.Name(), filepath.Join(u.dir(id), fileName)); err != nil {
return UploadFile{}, err
}
return UploadFile{Name: fileName, Size: int64(len(data))}, nil
}
// isMP3 reports whether data starts like an MP3 file: an ID3 tag or an MPEG audio frame.
func isMP3(data []byte) bool {
if bytes.HasPrefix(data, []byte("ID3")) {
return true
}
return len(data) >= 2 && data[0] == 0xFF && data[1]&0xE0 == 0xE0
}
// Finish moves the upload's folder into the music folder, adds the soundtrack to the
// library and returns it with its songs.
func (u *UploadService) Finish(ctx context.Context, id uuid.UUID) (SoundtrackWithSongList, error) {
u.mu.Lock()
defer u.mu.Unlock()
upload, err := u.get(id)
if err != nil {
return SoundtrackWithSongList{}, err
}
if len(upload.Files) == 0 {
return SoundtrackWithSongList{}, fmt.Errorf("%w: the upload has no songs", ErrInvalidName)
}
target := filepath.Join(u.musicPath, upload.Name)
soundtrack, err := u.sync.SyncFolder(ctx, upload.Name, func() error {
if err := u.checkNameFree(ctx, upload.Name, upload.ID); err != nil {
return err
}
if err := u.checkNotDuplicate(ctx, upload); err != nil {
return err
}
return os.Rename(u.dir(id), target)
})
if err != nil {
return SoundtrackWithSongList{}, err
}
if err := os.Remove(u.infoPath(id)); err != nil {
logging.GetLogger().Warn("Failed to remove upload info", zap.Error(err))
}
logging.GetLogger().Info("Soundtrack uploaded", zap.String("name", upload.Name),
zap.Int("songs", len(upload.Files)), zap.Int64("bytes", upload.TotalSize))
return u.library.Soundtrack(ctx, soundtrack.Uuid)
}
// checkNotDuplicate refuses an upload with the same songs as an active soundtrack. The sync
// would otherwise take it for that soundtrack renamed.
func (u *UploadService) checkNotDuplicate(ctx context.Context, upload Upload) error {
existing, err := u.q.GetAllSoundtracksIncludingDeleted(ctx)
if err != nil {
return err
}
hash, err := dirHash(u.dir(upload.ID) + "/")
if err != nil {
return err
}
if status, match := classify(existing, upload.Name, hash); status == TitleChanged && match.Deleted == nil {
return fmt.Errorf("%w: soundtrack %q has the same songs", ErrConflict, match.SoundtrackName)
}
return nil
}
// Cancel deletes an unfinished upload and its songs.
func (u *UploadService) Cancel(id uuid.UUID) error {
u.mu.Lock()
defer u.mu.Unlock()
if _, err := u.readInfo(id); err != nil {
return err
}
return u.remove(id)
}
func (u *UploadService) remove(id uuid.UUID) error {
if err := os.RemoveAll(u.dir(id)); err != nil {
return err
}
return os.Remove(u.infoPath(id))
}
// RemoveOld deletes uploads that were started more than a day ago and never finished.
func (u *UploadService) RemoveOld() {
u.mu.Lock()
defer u.mu.Unlock()
u.removeOld()
}
func (u *UploadService) removeOld() {
uploads, err := u.list()
if err != nil {
logging.GetLogger().Warn("Failed to list uploads", zap.Error(err))
return
}
for _, upload := range uploads {
if u.now().Sub(upload.info.Started) > uploadMaxAge {
if err := u.remove(upload.id); err != nil {
logging.GetLogger().Warn("Failed to remove an old upload", zap.Error(err))
continue
}
logging.GetLogger().Info("Removed unfinished upload", zap.String("name", upload.info.Name))
}
}
}
@@ -0,0 +1,77 @@
//go:build integration
package backend
import (
"context"
"path/filepath"
"testing"
"music-server/internal/db/repository"
"music-server/internal/testutil/pgtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestUploadWithPostgres uploads songs and checks the soundtrack in a real database.
func TestUploadWithPostgres(t *testing.T) {
ctx := context.Background()
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
musicDir := t.TempDir()
writeFile(t, filepath.Join(musicDir, "Original", "01 Song.mp3"), string(mp3("original one")))
writeFile(t, filepath.Join(musicDir, "Original", "02 Song.mp3"), string(mp3("original two")))
s := NewSyncService(q, musicDir, nil)
require.True(t, s.Start(false))
s.Wait()
require.Empty(t, s.Result().CatchedErrors)
library := NewLibraryService(q)
uploads := NewUploadService(q, musicDir, s, library, 50<<20, 500<<20)
upload := func(name string, songs map[string]string) Upload {
u, err := uploads.Start(ctx, name)
require.NoError(t, err)
for file, content := range songs {
_, err := uploads.PutFile(u.ID, file, mp3(content))
require.NoError(t, err)
}
return u
}
// The songs of an existing soundtrack under a new name are refused.
duplicate := upload("Copy", map[string]string{"01 Song.mp3": "original one", "02 Song.mp3": "original two"})
_, err := uploads.Finish(ctx, duplicate.ID)
assert.ErrorIs(t, err, ErrConflict)
require.NoError(t, uploads.Cancel(duplicate.ID))
// The name of an existing soundtrack is refused.
_, err = uploads.Start(ctx, "original")
assert.ErrorIs(t, err, ErrConflict)
added := upload("Uploaded Soundtrack", map[string]string{"1-01 First.mp3": "first", "1-02 Second.mp3": "second"})
soundtrack, err := uploads.Finish(ctx, added.ID)
require.NoError(t, err)
assert.Equal(t, "Uploaded Soundtrack", soundtrack.Name)
require.Len(t, soundtrack.Songs, 2)
assert.Equal(t, "1-01 First", soundtrack.Songs[0].Name)
path, err := library.SongPath(ctx, soundtrack.Songs[0].Uuid)
require.NoError(t, err)
assert.FileExists(t, path)
count, err := q.CountLibrary(ctx)
require.NoError(t, err)
assert.Equal(t, int64(2), count.Soundtracks)
assert.Equal(t, int64(4), count.Songs)
// A sync afterwards changes nothing.
require.True(t, s.Start(false))
s.Wait()
result := s.Result()
assert.Empty(t, result.CatchedErrors)
assert.Empty(t, result.SoundtracksAdded)
assert.Empty(t, result.SoundtracksChangedTitle)
assert.Empty(t, result.SoundtracksRemoved)
again, err := library.Soundtrack(ctx, soundtrack.Uuid)
require.NoError(t, err)
assert.Len(t, again.Songs, 2)
}
+270
View File
@@ -0,0 +1,270 @@
package backend
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mp3 returns data that starts like an MP3 file, made unique by content.
func mp3(content string) []byte {
return append([]byte("ID3"), content...)
}
type uploadFixture struct {
t *testing.T
musicDir string
store *fakedb.Store
sync *SyncService
uploads *UploadService
}
func newUploadFixture(t *testing.T) *uploadFixture {
musicDir := t.TempDir()
store := fakedb.New()
syncService := NewSyncService(store, musicDir, nil)
return &uploadFixture{
t: t, musicDir: musicDir, store: store, sync: syncService,
uploads: NewUploadService(store, musicDir, syncService, NewLibraryService(store), 1<<20, 3<<20),
}
}
func (f *uploadFixture) start(name string) Upload {
f.t.Helper()
upload, err := f.uploads.Start(context.Background(), name)
require.NoError(f.t, err)
return upload
}
func (f *uploadFixture) put(id uuid.UUID, fileName, content string) {
f.t.Helper()
_, err := f.uploads.PutFile(id, fileName, mp3(content))
require.NoError(f.t, err)
}
func TestUploadSoundtrack(t *testing.T) {
f := newUploadFixture(t)
ctx := context.Background()
upload := f.start(" Chrono Trigger ")
assert.Equal(t, "Chrono Trigger", upload.Name, "the name is trimmed")
f.put(upload.ID, "02 Corridors of Time.mp3", "b")
f.put(upload.ID, "01 Wind Scene.MP3", "a")
f.put(upload.ID, "02 Corridors of Time.mp3", "b2") // sent again
got, err := f.uploads.Get(upload.ID)
require.NoError(t, err)
assert.Equal(t, []UploadFile{{Name: "01 Wind Scene.MP3", Size: 4}, {Name: "02 Corridors of Time.mp3", Size: 5}}, got.Files)
assert.Equal(t, int64(9), got.TotalSize)
_, err = f.store.GetSoundtrackById(ctx, 1)
assert.Error(t, err, "nothing is in the library before finishing")
soundtrack, err := f.uploads.Finish(ctx, upload.ID)
require.NoError(t, err)
assert.Equal(t, "Chrono Trigger", soundtrack.Name)
require.Len(t, soundtrack.Songs, 2)
assert.Equal(t, "01 Wind Scene", soundtrack.Songs[0].Name)
assert.FileExists(t, filepath.Join(f.musicDir, "Chrono Trigger", "01 Wind Scene.MP3"))
entries, err := os.ReadDir(filepath.Join(f.musicDir, uploadsFolder))
require.NoError(t, err)
assert.Empty(t, entries, "nothing is left of the upload")
_, err = f.uploads.Get(upload.ID)
assert.ErrorIs(t, err, ErrNotFound)
// The next sync sees nothing new.
require.True(t, f.sync.Start(false))
f.sync.Wait()
result := f.sync.Result()
assert.Empty(t, result.SoundtracksAdded)
assert.Empty(t, result.CatchedErrors)
}
func TestUploadNames(t *testing.T) {
f := newUploadFixture(t)
ctx := context.Background()
f.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Kingdom Hearts"})
writeFile(t, filepath.Join(f.musicDir, "Folder Only", "x.txt"), "x")
f.start("Pending")
for name, want := range map[string]error{
"": ErrInvalidName,
" ": ErrInvalidName,
"a/b": ErrInvalidName,
`a\b`: ErrInvalidName,
"..": ErrInvalidName,
".hidden": ErrInvalidName,
"ends with dot.": ErrInvalidName,
"what?": ErrInvalidName,
"tab\tname": ErrInvalidName,
"characters": ErrInvalidName,
strings.Repeat("a", 201): ErrInvalidName,
"kingdom hearts": ErrConflict,
"FOLDER ONLY": ErrConflict,
"pending": ErrConflict,
} {
_, err := f.uploads.Start(ctx, name)
assert.ErrorIs(t, err, want, "%q", name)
}
_, err := f.uploads.Start(ctx, "Ys: Origin")
assert.ErrorIs(t, err, ErrInvalidName, "':' is not allowed on Windows")
_, err = f.uploads.Start(ctx, "Pokémon HeartGold & SoulSilver (DS)")
assert.NoError(t, err)
}
func TestUploadFileChecks(t *testing.T) {
f := newUploadFixture(t)
upload := f.start("Game")
for fileName, want := range map[string]error{
"song.wav": ErrInvalidName,
"song": ErrInvalidName,
"Disc 1/01.mp3": ErrInvalidName,
"../escape.mp3": ErrInvalidName,
".hidden.mp3": ErrInvalidName,
} {
_, err := f.uploads.PutFile(upload.ID, fileName, mp3("x"))
assert.ErrorIs(t, err, want, fileName)
}
_, err := f.uploads.PutFile(upload.ID, "text.mp3", []byte("just text"))
assert.ErrorIs(t, err, ErrInvalidName, "not an MP3 file")
_, err = f.uploads.PutFile(upload.ID, "frame.mp3", []byte{0xFF, 0xFB, 0x90, 0x00})
assert.NoError(t, err, "an MP3 without an ID3 tag starts with a frame")
_, err = f.uploads.PutFile(uuid.New(), "song.mp3", mp3("x"))
assert.ErrorIs(t, err, ErrNotFound)
_, err = f.uploads.PutFile(upload.ID, "big.mp3", mp3(strings.Repeat("x", 1<<20)))
assert.ErrorIs(t, err, ErrTooLarge, "over the size of one song")
for i := range 3 {
f.put(upload.ID, string(rune('a'+i))+".mp3", strings.Repeat("x", 1<<20-10))
}
_, err = f.uploads.PutFile(upload.ID, "d.mp3", mp3(strings.Repeat("x", 1000)))
assert.ErrorIs(t, err, ErrTooLarge, "over the size of the whole upload")
_, err = f.uploads.PutFile(upload.ID, "a.mp3", mp3("small"))
assert.NoError(t, err, "replacing a song counts its new size only")
}
func TestUploadFinishChecks(t *testing.T) {
f := newUploadFixture(t)
ctx := context.Background()
empty := f.start("Empty")
_, err := f.uploads.Finish(ctx, empty.ID)
assert.ErrorIs(t, err, ErrInvalidName, "an upload without songs")
_, err = f.uploads.Finish(ctx, uuid.New())
assert.ErrorIs(t, err, ErrNotFound)
// A folder of the same name appeared meanwhile, e.g. from ResilioSync.
clash := f.start("Clash")
f.put(clash.ID, "01.mp3", "c")
writeFile(t, filepath.Join(f.musicDir, "Clash", "01.mp3"), "other")
_, err = f.uploads.Finish(ctx, clash.ID)
assert.ErrorIs(t, err, ErrConflict)
assert.FileExists(t, filepath.Join(f.musicDir, uploadsFolder, clash.ID.String(), "01.mp3"), "the upload is kept")
// The same songs as a soundtrack that is already in the library.
original := f.start("Original")
f.put(original.ID, "01.mp3", "same")
_, err = f.uploads.Finish(ctx, original.ID)
require.NoError(t, err)
duplicate := f.start("Copy")
f.put(duplicate.ID, "01.mp3", "same")
_, err = f.uploads.Finish(ctx, duplicate.ID)
assert.ErrorIs(t, err, ErrConflict)
assert.Contains(t, err.Error(), "Original")
assert.NoDirExists(t, filepath.Join(f.musicDir, "Copy"))
}
func TestUploadWhileSyncing(t *testing.T) {
f := newUploadFixture(t)
release := make(chan struct{})
f.sync = NewSyncService(f.store, f.musicDir, func(context.Context) { <-release })
f.uploads.sync = f.sync
upload := f.start("Game")
f.put(upload.ID, "01.mp3", "a")
require.True(t, f.sync.Start(false))
_, err := f.uploads.Start(context.Background(), "Other")
assert.ErrorIs(t, err, ErrSyncing)
_, err = f.uploads.Finish(context.Background(), upload.ID)
assert.ErrorIs(t, err, ErrSyncing)
assert.NoDirExists(t, filepath.Join(f.musicDir, "Game"), "nothing was moved")
close(release)
f.sync.Wait()
_, err = f.uploads.Finish(context.Background(), upload.ID)
assert.NoError(t, err)
}
func TestUploadFinishBlocksASync(t *testing.T) {
f := newUploadFixture(t)
upload := f.start("Game")
f.put(upload.ID, "01.mp3", "a")
started := make(chan bool, 1)
_, err := f.sync.SyncFolder(context.Background(), "Game", func() error {
started <- f.sync.Start(false)
return os.Rename(f.uploads.dir(upload.ID), filepath.Join(f.musicDir, "Game"))
})
require.NoError(t, err)
assert.False(t, <-started, "a sync cannot start while a folder is being added")
}
func TestUploadCancelAndCleanup(t *testing.T) {
f := newUploadFixture(t)
cancelled := f.start("Cancelled")
f.put(cancelled.ID, "01.mp3", "a")
require.NoError(t, f.uploads.Cancel(cancelled.ID))
assert.NoDirExists(t, f.uploads.dir(cancelled.ID))
assert.ErrorIs(t, f.uploads.Cancel(cancelled.ID), ErrNotFound)
old := f.start("Old")
f.put(old.ID, "01.mp3", "a")
recent := f.start("Recent")
f.uploads.now = func() time.Time { return time.Now().Add(uploadMaxAge - time.Minute) }
f.uploads.RemoveOld()
_, err := f.uploads.Get(old.ID)
assert.NoError(t, err, "not a day old yet")
f.uploads.now = func() time.Time { return time.Now().Add(uploadMaxAge + time.Minute) }
f.uploads.RemoveOld()
_, err = f.uploads.Get(old.ID)
assert.ErrorIs(t, err, ErrNotFound)
_, err = f.uploads.Get(recent.ID)
assert.ErrorIs(t, err, ErrNotFound)
// A new upload of the same name works after the old one is gone.
f.uploads.now = time.Now
f.start("Old")
}
func TestSyncSkipsUploadsAndHiddenFolders(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a")
f.write(".uploads/123/01.mp3", "b")
f.write(".sync/x/01.mp3", "c")
result := f.run(false)
assert.Equal(t, []string{"Game A"}, result.SoundtracksAdded)
}
func TestUploadsUnavailableWhenMusicFolderIsReadOnly(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root can write to read-only folders")
}
f := newUploadFixture(t)
require.NoError(t, os.Chmod(f.musicDir, 0o555))
t.Cleanup(func() { os.Chmod(f.musicDir, 0o755) })
_, err := f.uploads.Start(context.Background(), "Game")
assert.ErrorIs(t, err, ErrUploadsUnavailable)
}
+99
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -16,6 +17,9 @@ const DefaultReleasesURL = "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicP
// Config holds all settings read from the environment. // Config holds all settings read from the environment.
type Config struct { type Config struct {
Port int Port int
// MetricsPort serves Prometheus metrics on a port of its own, so /metrics is never
// reachable through the public port. 0 means metrics are not served.
MetricsPort int
DBHost string DBHost string
DBPort string DBPort string
@@ -27,6 +31,19 @@ type Config struct {
CharactersPath string CharactersPath string
SoundCheckPath string SoundCheckPath string
FrontendPath string FrontendPath string
// UploadMaxFileSize and UploadMaxTotalSize limit uploaded songs, in bytes: one song and
// all songs of one uploaded soundtrack.
UploadMaxFileSize int64
UploadMaxTotalSize int64
// CoversPath is the folder for soundtrack covers. Empty turns covers off.
CoversPath string
// SteamGridDBAPIKey turns on the background search for covers.
SteamGridDBAPIKey string
// SteamGridDBURL and AniListURL replace the real cover APIs; only tests set them.
SteamGridDBURL string
AniListURL string
LogLevel string LogLevel string
LogJSON bool LogJSON bool
@@ -38,6 +55,10 @@ type Config struct {
// TrustedProxies are CIDR ranges whose X-Forwarded-For header is trusted when resolving client IPs. // TrustedProxies are CIDR ranges whose X-Forwarded-For header is trusted when resolving client IPs.
TrustedProxies []*net.IPNet TrustedProxies []*net.IPNet
// CORSAllowedOrigins are the browser origins, like https://music.example.com, that may call the API
// from another site. Empty means only same-origin requests, which is enough for the bundled frontend.
CORSAllowedOrigins []string
ReleasesURL string ReleasesURL string
} }
@@ -57,6 +78,8 @@ func load(getenv func(string) string) (Config, error) {
CharactersPath: getenv("CHARACTERS_PATH"), CharactersPath: getenv("CHARACTERS_PATH"),
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"), SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"), FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
CoversPath: getenv("COVERS_PATH"),
SteamGridDBAPIKey: getenv("STEAMGRIDDB_API_KEY"),
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"), LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
LogJSON: getenv("LOG_JSON") == "true", LogJSON: getenv("LOG_JSON") == "true",
APIPassword: getenv("API_PASSWORD"), APIPassword: getenv("API_PASSWORD"),
@@ -72,6 +95,20 @@ func load(getenv func(string) string) (Config, error) {
} }
cfg.Port = port cfg.Port = port
cfg.UploadMaxFileSize, err = parseMegabytes(getenv("UPLOAD_MAX_FILE_MB"), DefaultUploadMaxFileMB)
if err != nil {
errs = append(errs, fmt.Errorf("UPLOAD_MAX_FILE_MB: %w", err))
}
cfg.UploadMaxTotalSize, err = parseMegabytes(getenv("UPLOAD_MAX_TOTAL_MB"), DefaultUploadMaxTotalMB)
if err != nil {
errs = append(errs, fmt.Errorf("UPLOAD_MAX_TOTAL_MB: %w", err))
}
cfg.MetricsPort, err = parseMetricsPort(getenv("METRICS_PORT"))
if err != nil {
errs = append(errs, fmt.Errorf("METRICS_PORT: %w", err))
}
for name, value := range map[string]string{ for name, value := range map[string]string{
"DB_HOST": cfg.DBHost, "DB_HOST": cfg.DBHost,
"DB_PORT": cfg.DBPort, "DB_PORT": cfg.DBPort,
@@ -91,6 +128,11 @@ func load(getenv func(string) string) (Config, error) {
errs = append(errs, fmt.Errorf("TRUSTED_PROXIES: %w", err)) errs = append(errs, fmt.Errorf("TRUSTED_PROXIES: %w", err))
} }
cfg.CORSAllowedOrigins, err = parseOrigins(getenv("CORS_ALLOWED_ORIGINS"))
if err != nil {
errs = append(errs, fmt.Errorf("CORS_ALLOWED_ORIGINS: %w", err))
}
return cfg, errors.Join(errs...) return cfg, errors.Join(errs...)
} }
@@ -110,6 +152,63 @@ func parseCIDRs(value string) ([]*net.IPNet, error) {
return nets, nil return nets, nil
} }
// Upload size limits in MB, used when UPLOAD_MAX_FILE_MB and UPLOAD_MAX_TOTAL_MB are not set.
const (
DefaultUploadMaxFileMB = 50
DefaultUploadMaxTotalMB = 2048
)
// parseMegabytes reads a positive number of megabytes and returns it in bytes.
func parseMegabytes(value string, defaultMB int64) (int64, error) {
if value == "" {
return defaultMB << 20, nil
}
mb, err := strconv.ParseInt(value, 10, 64)
if err != nil || mb < 1 || mb > 1<<20 {
return 0, fmt.Errorf("%q is not a number of megabytes", value)
}
return mb << 20, nil
}
// DefaultMetricsPort is used when METRICS_PORT is not set.
const DefaultMetricsPort = 2112
// parseMetricsPort reads METRICS_PORT: empty for the default, "off" for no metrics, or a port.
func parseMetricsPort(value string) (int, error) {
switch value {
case "":
return DefaultMetricsPort, nil
case "off":
return 0, nil
}
port, err := strconv.Atoi(value)
if err != nil || port < 1 || port > 65535 {
return 0, fmt.Errorf("%q is not a port or \"off\"", value)
}
return port, nil
}
// parseOrigins parses a comma-separated list of origins (scheme://host[:port], nothing else).
func parseOrigins(value string) ([]string, error) {
var origins []string
for part := range strings.SplitSeq(value, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
u, err := url.Parse(part)
if err != nil {
return nil, err
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path != "" || u.RawQuery != "" ||
u.Fragment != "" || u.User != nil || strings.Contains(u.Host, "*") {
return nil, fmt.Errorf("%q is not an origin like https://music.example.com", part)
}
origins = append(origins, u.Scheme+"://"+u.Host)
}
return origins, nil
}
func withDefault(value, fallback string) string { func withDefault(value, fallback string) string {
if value == "" { if value == "" {
return fallback return fallback
+69
View File
@@ -31,6 +31,60 @@ func TestLoadDefaults(t *testing.T) {
assert.Equal(t, "songs", cfg.SoundCheckPath) assert.Equal(t, "songs", cfg.SoundCheckPath)
assert.Equal(t, DefaultReleasesURL, cfg.ReleasesURL) assert.Equal(t, DefaultReleasesURL, cfg.ReleasesURL)
assert.Empty(t, cfg.TrustedProxies) assert.Empty(t, cfg.TrustedProxies)
assert.Empty(t, cfg.CORSAllowedOrigins)
assert.Equal(t, DefaultMetricsPort, cfg.MetricsPort)
assert.Empty(t, cfg.CoversPath, "covers are off by default")
assert.Empty(t, cfg.SteamGridDBAPIKey)
}
func TestLoadUploadLimits(t *testing.T) {
cfg, err := load(env(validEnv()))
require.NoError(t, err)
assert.Equal(t, int64(DefaultUploadMaxFileMB<<20), cfg.UploadMaxFileSize)
assert.Equal(t, int64(DefaultUploadMaxTotalMB<<20), cfg.UploadMaxTotalSize)
values := validEnv()
values["UPLOAD_MAX_FILE_MB"] = "100"
values["UPLOAD_MAX_TOTAL_MB"] = "500"
cfg, err = load(env(values))
require.NoError(t, err)
assert.Equal(t, int64(100<<20), cfg.UploadMaxFileSize)
assert.Equal(t, int64(500<<20), cfg.UploadMaxTotalSize)
for _, invalid := range []string{"0", "-5", "lots", "1.5"} {
values["UPLOAD_MAX_FILE_MB"] = invalid
_, err = load(env(values))
assert.ErrorContains(t, err, "UPLOAD_MAX_FILE_MB", invalid)
}
}
func TestLoadCovers(t *testing.T) {
values := validEnv()
values["COVERS_PATH"] = "/covers"
values["STEAMGRIDDB_API_KEY"] = "key"
cfg, err := load(env(values))
require.NoError(t, err)
assert.Equal(t, "/covers", cfg.CoversPath)
assert.Equal(t, "key", cfg.SteamGridDBAPIKey)
}
func TestLoadMetricsPort(t *testing.T) {
values := validEnv()
values["METRICS_PORT"] = "9100"
cfg, err := load(env(values))
require.NoError(t, err)
assert.Equal(t, 9100, cfg.MetricsPort)
values["METRICS_PORT"] = "off"
cfg, err = load(env(values))
require.NoError(t, err)
assert.Zero(t, cfg.MetricsPort)
for _, invalid := range []string{"abc", "0", "70000", "-1", "OFF"} {
values["METRICS_PORT"] = invalid
_, err = load(env(values))
assert.ErrorContains(t, err, "METRICS_PORT", invalid)
}
} }
func TestLoadMissingRequired(t *testing.T) { func TestLoadMissingRequired(t *testing.T) {
@@ -56,6 +110,21 @@ func TestLoadTrustedProxies(t *testing.T) {
assert.ErrorContains(t, err, "TRUSTED_PROXIES") assert.ErrorContains(t, err, "TRUSTED_PROXIES")
} }
func TestLoadCORSAllowedOrigins(t *testing.T) {
values := validEnv()
values["CORS_ALLOWED_ORIGINS"] = "https://music.example.com, http://localhost:5173,"
cfg, err := load(env(values))
require.NoError(t, err)
assert.Equal(t, []string{"https://music.example.com", "http://localhost:5173"}, cfg.CORSAllowedOrigins)
for _, invalid := range []string{"music.example.com", "ftp://example.com", "https://example.com/app",
"https://*.example.com", "https://*", "*", "https://example.com?x=1", "https://user@example.com"} {
values["CORS_ALLOWED_ORIGINS"] = invalid
_, err = load(env(values))
assert.ErrorContains(t, err, "CORS_ALLOWED_ORIGINS", invalid)
}
}
func TestLoadInvalidPort(t *testing.T) { func TestLoadInvalidPort(t *testing.T) {
values := validEnv() values := validEnv()
values["PORT"] = "abc" values["PORT"] = "abc"
+3 -1
View File
@@ -65,7 +65,7 @@ func TestMigrationsKeepData(t *testing.T) {
assert.Error(t, err, "game table is renamed") assert.Error(t, err, "game table is renamed")
} }
func TestFingerprintMigrationDown(t *testing.T) { func TestFingerprintAndQueueMigrationDown(t *testing.T) {
database := openDatabase(t, pgtest.NewDatabase(t)) database := openDatabase(t, pgtest.NewDatabase(t))
m, closeFn, err := database.NewMigrator() m, closeFn, err := database.NewMigrator()
require.NoError(t, err) require.NoError(t, err)
@@ -75,6 +75,8 @@ func TestFingerprintMigrationDown(t *testing.T) {
require.NoError(t, m.Steps(-1)) require.NoError(t, m.Steps(-1))
assert.Equal(t, 0, count(t, database, assert.Equal(t, 0, count(t, database,
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'soundtrack' AND column_name = 'fingerprint'")) "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'soundtrack' AND column_name = 'fingerprint'"))
assert.Equal(t, 0, count(t, database,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN ('queue', 'queue_song')"))
require.NoError(t, m.Up()) require.NoError(t, m.Up())
} }
@@ -0,0 +1,11 @@
ALTER TABLE soundtrack
DROP COLUMN alternative_names,
DROP COLUMN cover_file,
DROP COLUMN cover_source,
DROP COLUMN cover_external_id,
DROP COLUMN cover_manual,
DROP COLUMN cover_modified,
DROP COLUMN cover_checked;
DROP TABLE queue_song;
DROP TABLE queue;
ALTER TABLE soundtrack DROP COLUMN fingerprint;
@@ -0,0 +1,43 @@
-- Cheap fingerprint (file names, sizes and modification times) used to skip
-- content hashing of soundtracks that have not changed since the last sync.
ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
-- Play queues. Every client has its own queue, identified by the queue's UUID.
-- Ended queues are kept as history.
CREATE TABLE queue (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
current_song INT4 NOT NULL DEFAULT -1,
-- The last random song, added to the queue by /music/addQue, and how it was picked.
last_fetched_song_uuid UUID NULL,
last_fetched_mode VARCHAR(10) NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ NULL
);
CREATE INDEX idx_queue_active ON queue(uuid) WHERE ended_at IS NULL;
-- song_uuid has no foreign key: a sync can delete songs. The names are kept for the history.
CREATE TABLE queue_song (
queue_uuid UUID NOT NULL REFERENCES queue(uuid) ON DELETE CASCADE,
song_no INT4 NOT NULL,
song_uuid UUID NOT NULL,
soundtrack_name VARCHAR NOT NULL,
song_name VARCHAR NOT NULL,
-- How the song was picked: random, low or classic.
mode VARCHAR(10) NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (queue_uuid, song_no)
);
-- Other names a soundtrack is known by, for search and the cover search.
ALTER TABLE soundtrack ADD COLUMN alternative_names TEXT[] NOT NULL DEFAULT '{}';
-- Cover art. cover_file is the image's file name in COVERS_PATH, NULL when there is no
-- cover. Manual covers (uploaded or picked) are never replaced by the background job.
-- cover_checked is when the job last searched, so soundtracks without a match are not
-- searched again for a while.
ALTER TABLE soundtrack ADD COLUMN cover_file TEXT NULL;
ALTER TABLE soundtrack ADD COLUMN cover_source VARCHAR(20) NULL;
ALTER TABLE soundtrack ADD COLUMN cover_external_id TEXT NULL;
ALTER TABLE soundtrack ADD COLUMN cover_manual BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE soundtrack ADD COLUMN cover_modified TIMESTAMPTZ NULL;
ALTER TABLE soundtrack ADD COLUMN cover_checked TIMESTAMPTZ NULL;
@@ -1 +0,0 @@
ALTER TABLE soundtrack DROP COLUMN fingerprint;
@@ -1,3 +0,0 @@
-- Cheap fingerprint (file names, sizes and modification times) used to skip
-- content hashing of soundtracks that have not changed since the last sync.
ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
+51
View File
@@ -0,0 +1,51 @@
-- name: CreateQueue :one
INSERT INTO queue DEFAULT VALUES
RETURNING *;
-- name: GetQueue :one
SELECT *
FROM queue
WHERE uuid = $1;
-- name: EndQueue :exec
UPDATE queue SET ended_at = NOW()
WHERE uuid = $1 AND ended_at IS NULL;
-- name: EndActiveQueues :exec
UPDATE queue SET ended_at = NOW()
WHERE ended_at IS NULL;
-- name: SetQueueLastFetched :exec
UPDATE queue SET last_fetched_song_uuid = $2, last_fetched_mode = $3
WHERE uuid = $1;
-- name: SetQueueCurrentSong :exec
UPDATE queue SET current_song = $2
WHERE uuid = $1;
-- name: AddLastFetchedToQueue :exec
-- Appends the queue's last fetched song and makes it the current song.
-- Does nothing when no song has been fetched since the last call.
WITH added AS (
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name, mode)
SELECT q.uuid,
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid),
s.uuid, st.soundtrack_name, s.song_name, COALESCE(q.last_fetched_mode, 'unknown')
FROM queue q
JOIN song s ON s.uuid = q.last_fetched_song_uuid
JOIN soundtrack st ON st.id = s.soundtrack_id
WHERE q.uuid = $1
RETURNING queue_uuid, song_no
)
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL, last_fetched_mode = NULL
FROM added
WHERE queue.uuid = added.queue_uuid;
-- name: ListQueueSongs :many
-- The song columns are NULL when the song has been removed from the library since.
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, qs.mode,
s.path, s.times_played, s.soundtrack_uuid
FROM queue_song qs
LEFT JOIN song s ON s.uuid = qs.song_uuid
WHERE qs.queue_uuid = $1
ORDER BY qs.song_no;
+41
View File
@@ -65,3 +65,44 @@ SELECT uuid FROM soundtrack WHERE id = $1;
-- name: AddSoundtrackPlayedByUUID :exec -- name: AddSoundtrackPlayedByUUID :exec
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1; UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1;
-- name: SetAlternativeNames :one
-- Replaces the alternative names. A soundtrack without a cover is searched again with them.
UPDATE soundtrack
SET alternative_names = sqlc.arg(names)::text[],
cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END
WHERE uuid = sqlc.arg(uuid) AND deleted IS NULL
RETURNING *;
-- name: ListSoundtracksNeedingCover :many
-- Active soundtracks without a cover that the job has not searched since checked_before.
SELECT uuid, soundtrack_name, alternative_names
FROM soundtrack
WHERE deleted IS NULL
AND cover_file IS NULL
AND NOT cover_manual
AND (cover_checked IS NULL OR cover_checked < sqlc.arg(checked_before))
ORDER BY soundtrack_name;
-- name: SetCover :exec
UPDATE soundtrack
SET cover_file = sqlc.arg(file),
cover_source = sqlc.arg(source),
cover_external_id = sqlc.arg(external_id),
cover_manual = sqlc.arg(manual),
cover_modified = now(),
cover_checked = now()
WHERE uuid = sqlc.arg(uuid);
-- name: MarkCoverChecked :exec
UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1;
-- name: ClearCover :exec
-- Removes the cover, so the job searches for one again.
UPDATE soundtrack
SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false,
cover_modified = NULL, cover_checked = NULL
WHERE uuid = $1;
-- name: ListCoverFiles :many
SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL;
+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;
+38 -12
View File
@@ -11,6 +11,25 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
type Queue struct {
Uuid uuid.UUID `json:"uuid"`
CurrentSong int32 `json:"current_song"`
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"`
LastFetchedMode *string `json:"last_fetched_mode"`
StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type QueueSong struct {
QueueUuid uuid.UUID `json:"queue_uuid"`
SongNo int32 `json:"song_no"`
SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Mode string `json:"mode"`
AddedAt pgtype.Timestamptz `json:"added_at"`
}
type Session struct { type Session struct {
Token string `json:"token"` Token string `json:"token"`
IpAddress string `json:"ip_address"` IpAddress string `json:"ip_address"`
@@ -40,18 +59,25 @@ type SongList struct {
} }
type Soundtrack struct { type Soundtrack struct {
ID int32 `json:"id"` ID int32 `json:"id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
Added time.Time `json:"added"` Added time.Time `json:"added"`
Deleted *time.Time `json:"deleted"` Deleted *time.Time `json:"deleted"`
LastChanged *time.Time `json:"last_changed"` LastChanged *time.Time `json:"last_changed"`
Path string `json:"path"` Path string `json:"path"`
TimesPlayed int32 `json:"times_played"` TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played"` LastPlayed *time.Time `json:"last_played"`
NumberOfSongs int32 `json:"number_of_songs"` NumberOfSongs int32 `json:"number_of_songs"`
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 {
+23
View File
@@ -9,10 +9,14 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
type Querier interface { type Querier interface {
AddHashToSong(ctx context.Context, arg AddHashToSongParams) error AddHashToSong(ctx context.Context, arg AddHashToSongParams) error
// Appends the queue's last fetched song and makes it the current song.
// Does nothing when no song has been fetched since the last call.
AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID) error
AddSong(ctx context.Context, arg AddSongParams) error AddSong(ctx context.Context, arg AddSongParams) error
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
@@ -20,12 +24,19 @@ type Querier interface {
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error) CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
CheckSongWithHash(ctx context.Context, hash string) (int64, error) CheckSongWithHash(ctx context.Context, hash string) (int64, error)
// Removes the cover, so the job searches for one again.
ClearCover(ctx context.Context, argUuid uuid.UUID) error
ClearSongs(ctx context.Context) error ClearSongs(ctx context.Context) error
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
ClearSoundtracks(ctx context.Context) error ClearSoundtracks(ctx context.Context) error
// Soundtracks and songs in the library, without deleted soundtracks.
CountLibrary(ctx context.Context) (CountLibraryRow, error)
CreateQueue(ctx context.Context) (Queue, error)
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteExpiredSessions(ctx context.Context) error DeleteExpiredSessions(ctx context.Context) error
DeleteSession(ctx context.Context, token string) error DeleteSession(ctx context.Context, token string) error
EndActiveQueues(ctx context.Context) error
EndQueue(ctx context.Context, argUuid uuid.UUID) error
FetchAllSongs(ctx context.Context) ([]Song, error) FetchAllSongs(ctx context.Context) ([]Song, error)
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
@@ -48,6 +59,7 @@ type Querier interface {
GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error)
// Oldest played soundtracks (least recently played, but has been played at least once) // Oldest played soundtracks (least recently played, but has been played at least once)
GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error)
GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error)
GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error) GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error)
GetSession(ctx context.Context, token string) (Session, error) GetSession(ctx context.Context, token string) (Session, error)
GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error)
@@ -64,12 +76,23 @@ type Querier interface {
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error
ListCoverFiles(ctx context.Context) ([]string, error)
// The song columns are NULL when the song has been removed from the library since.
ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error)
ListSessions(ctx context.Context) ([]Session, error) ListSessions(ctx context.Context) ([]Session, error)
// Active soundtracks without a cover that the job has not searched since checked_before.
ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error)
MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
ResetSoundtrackIdSeq(ctx context.Context) (int64, error) ResetSoundtrackIdSeq(ctx context.Context) (int64, error)
// Replaces the alternative names. A soundtrack without a cover is searched again with them.
SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error)
SetCover(ctx context.Context, arg SetCoverParams) error
SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error
SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error
SetSoundtrackDeletionDate(ctx context.Context) error SetSoundtrackDeletionDate(ctx context.Context) error
SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error
UpdateSong(ctx context.Context, arg UpdateSongParams) error UpdateSong(ctx context.Context, arg UpdateSongParams) error
+176
View File
@@ -0,0 +1,176 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: queue.sql
package repository
import (
"context"
"github.com/google/uuid"
)
const addLastFetchedToQueue = `-- name: AddLastFetchedToQueue :exec
WITH added AS (
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name, mode)
SELECT q.uuid,
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid),
s.uuid, st.soundtrack_name, s.song_name, COALESCE(q.last_fetched_mode, 'unknown')
FROM queue q
JOIN song s ON s.uuid = q.last_fetched_song_uuid
JOIN soundtrack st ON st.id = s.soundtrack_id
WHERE q.uuid = $1
RETURNING queue_uuid, song_no
)
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL, last_fetched_mode = NULL
FROM added
WHERE queue.uuid = added.queue_uuid
`
// Appends the queue's last fetched song and makes it the current song.
// Does nothing when no song has been fetched since the last call.
func (q *Queries) AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, addLastFetchedToQueue, argUuid)
return err
}
const createQueue = `-- name: CreateQueue :one
INSERT INTO queue DEFAULT VALUES
RETURNING uuid, current_song, last_fetched_song_uuid, last_fetched_mode, started_at, ended_at
`
func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) {
row := q.db.QueryRow(ctx, createQueue)
var i Queue
err := row.Scan(
&i.Uuid,
&i.CurrentSong,
&i.LastFetchedSongUuid,
&i.LastFetchedMode,
&i.StartedAt,
&i.EndedAt,
)
return i, err
}
const endActiveQueues = `-- name: EndActiveQueues :exec
UPDATE queue SET ended_at = NOW()
WHERE ended_at IS NULL
`
func (q *Queries) EndActiveQueues(ctx context.Context) error {
_, err := q.db.Exec(ctx, endActiveQueues)
return err
}
const endQueue = `-- name: EndQueue :exec
UPDATE queue SET ended_at = NOW()
WHERE uuid = $1 AND ended_at IS NULL
`
func (q *Queries) EndQueue(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, endQueue, argUuid)
return err
}
const getQueue = `-- name: GetQueue :one
SELECT uuid, current_song, last_fetched_song_uuid, last_fetched_mode, started_at, ended_at
FROM queue
WHERE uuid = $1
`
func (q *Queries) GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error) {
row := q.db.QueryRow(ctx, getQueue, argUuid)
var i Queue
err := row.Scan(
&i.Uuid,
&i.CurrentSong,
&i.LastFetchedSongUuid,
&i.LastFetchedMode,
&i.StartedAt,
&i.EndedAt,
)
return i, err
}
const listQueueSongs = `-- name: ListQueueSongs :many
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, qs.mode,
s.path, s.times_played, s.soundtrack_uuid
FROM queue_song qs
LEFT JOIN song s ON s.uuid = qs.song_uuid
WHERE qs.queue_uuid = $1
ORDER BY qs.song_no
`
type ListQueueSongsRow struct {
SongNo int32 `json:"song_no"`
SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Mode string `json:"mode"`
Path *string `json:"path"`
TimesPlayed *int32 `json:"times_played"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
}
// The song columns are NULL when the song has been removed from the library since.
func (q *Queries) ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error) {
rows, err := q.db.Query(ctx, listQueueSongs, queueUuid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListQueueSongsRow
for rows.Next() {
var i ListQueueSongsRow
if err := rows.Scan(
&i.SongNo,
&i.SongUuid,
&i.SoundtrackName,
&i.SongName,
&i.Mode,
&i.Path,
&i.TimesPlayed,
&i.SoundtrackUuid,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setQueueCurrentSong = `-- name: SetQueueCurrentSong :exec
UPDATE queue SET current_song = $2
WHERE uuid = $1
`
type SetQueueCurrentSongParams struct {
Uuid uuid.UUID `json:"uuid"`
CurrentSong int32 `json:"current_song"`
}
func (q *Queries) SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error {
_, err := q.db.Exec(ctx, setQueueCurrentSong, arg.Uuid, arg.CurrentSong)
return err
}
const setQueueLastFetched = `-- name: SetQueueLastFetched :exec
UPDATE queue SET last_fetched_song_uuid = $2, last_fetched_mode = $3
WHERE uuid = $1
`
type SetQueueLastFetchedParams struct {
Uuid uuid.UUID `json:"uuid"`
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"`
LastFetchedMode *string `json:"last_fetched_mode"`
}
func (q *Queries) SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error {
_, err := q.db.Exec(ctx, setQueueLastFetched, arg.Uuid, arg.LastFetchedSongUuid, arg.LastFetchedMode)
return err
}
+187 -4
View File
@@ -9,6 +9,7 @@ import (
"context" "context"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
@@ -29,6 +30,19 @@ func (q *Queries) AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UU
return err return err
} }
const clearCover = `-- name: ClearCover :exec
UPDATE soundtrack
SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false,
cover_modified = NULL, cover_checked = NULL
WHERE uuid = $1
`
// Removes the cover, so the job searches for one again.
func (q *Queries) ClearCover(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, clearCover, argUuid)
return err
}
const clearSoundtracks = `-- name: ClearSoundtracks :exec const clearSoundtracks = `-- name: ClearSoundtracks :exec
DELETE FROM soundtrack DELETE FROM soundtrack
` `
@@ -39,7 +53,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
} }
const findAllSoundtracks = `-- name: FindAllSoundtracks :many const findAllSoundtracks = `-- name: FindAllSoundtracks :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
FROM soundtrack FROM soundtrack
WHERE deleted IS NULL WHERE deleted IS NULL
ORDER BY soundtrack_name ORDER BY soundtrack_name
@@ -67,6 +81,13 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -79,7 +100,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
} }
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
FROM soundtrack FROM soundtrack
ORDER BY soundtrack_name ORDER BY soundtrack_name
` `
@@ -106,6 +127,13 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -129,7 +157,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
} }
const getSoundtrackById = `-- name: GetSoundtrackById :one const getSoundtrackById = `-- name: GetSoundtrackById :one
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
FROM soundtrack FROM soundtrack
WHERE id = $1 WHERE id = $1
AND deleted IS NULL AND deleted IS NULL
@@ -151,12 +179,19 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
) )
return i, err return i, err
} }
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
FROM soundtrack FROM soundtrack
WHERE uuid = $1 WHERE uuid = $1
AND deleted IS NULL AND deleted IS NULL
@@ -178,6 +213,13 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
) )
return i, err return i, err
} }
@@ -253,6 +295,76 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
return err return err
} }
const listCoverFiles = `-- name: ListCoverFiles :many
SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL
`
func (q *Queries) ListCoverFiles(ctx context.Context) ([]string, error) {
rows, err := q.db.Query(ctx, listCoverFiles)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var cover_file string
if err := rows.Scan(&cover_file); err != nil {
return nil, err
}
items = append(items, cover_file)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSoundtracksNeedingCover = `-- name: ListSoundtracksNeedingCover :many
SELECT uuid, soundtrack_name, alternative_names
FROM soundtrack
WHERE deleted IS NULL
AND cover_file IS NULL
AND NOT cover_manual
AND (cover_checked IS NULL OR cover_checked < $1)
ORDER BY soundtrack_name
`
type ListSoundtracksNeedingCoverRow struct {
Uuid uuid.UUID `json:"uuid"`
SoundtrackName string `json:"soundtrack_name"`
AlternativeNames []string `json:"alternative_names"`
}
// Active soundtracks without a cover that the job has not searched since checked_before.
func (q *Queries) ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error) {
rows, err := q.db.Query(ctx, listSoundtracksNeedingCover, checkedBefore)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSoundtracksNeedingCoverRow
for rows.Next() {
var i ListSoundtracksNeedingCoverRow
if err := rows.Scan(&i.Uuid, &i.SoundtrackName, &i.AlternativeNames); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markCoverChecked = `-- name: MarkCoverChecked :exec
UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1
`
func (q *Queries) MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, markCoverChecked, argUuid)
return err
}
const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[])) UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
` `
@@ -282,6 +394,77 @@ func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) {
return setval, err return setval, err
} }
const setAlternativeNames = `-- name: SetAlternativeNames :one
UPDATE soundtrack
SET alternative_names = $1::text[],
cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END
WHERE uuid = $2 AND deleted IS NULL
RETURNING id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
`
type SetAlternativeNamesParams struct {
Names []string `json:"names"`
Uuid uuid.UUID `json:"uuid"`
}
// Replaces the alternative names. A soundtrack without a cover is searched again with them.
func (q *Queries) SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error) {
row := q.db.QueryRow(ctx, setAlternativeNames, arg.Names, arg.Uuid)
var i Soundtrack
err := row.Scan(
&i.ID,
&i.SoundtrackName,
&i.Added,
&i.Deleted,
&i.LastChanged,
&i.Path,
&i.TimesPlayed,
&i.LastPlayed,
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
)
return i, err
}
const setCover = `-- name: SetCover :exec
UPDATE soundtrack
SET cover_file = $1,
cover_source = $2,
cover_external_id = $3,
cover_manual = $4,
cover_modified = now(),
cover_checked = now()
WHERE uuid = $5
`
type SetCoverParams struct {
File *string `json:"file"`
Source *string `json:"source"`
ExternalID *string `json:"external_id"`
Manual bool `json:"manual"`
Uuid uuid.UUID `json:"uuid"`
}
func (q *Queries) SetCover(ctx context.Context, arg SetCoverParams) error {
_, err := q.db.Exec(ctx, setCover,
arg.File,
arg.Source,
arg.ExternalID,
arg.Manual,
arg.Uuid,
)
return err
}
const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
` `
+92 -36
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(
@@ -40,12 +62,14 @@ ORDER BY g.soundtrack_name
` `
type GetAllSoundtracksWithSongsRow struct { type GetAllSoundtracksWithSongsRow 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"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Every soundtrack with its songs, by name // Every soundtrack with its songs, by name
@@ -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(
@@ -104,12 +132,14 @@ LIMIT $1
` `
type GetLastPlayedSoundtracksRow struct { type GetLastPlayedSoundtracksRow 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"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Last played soundtracks (most recently played) // Last played soundtracks (most recently played)
@@ -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(
@@ -227,12 +261,14 @@ LIMIT $1
` `
type GetLeastPlayedSoundtracksWithSongsRow struct { type GetLeastPlayedSoundtracksWithSongsRow 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"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Least played soundtracks with their songs // Least played soundtracks with their 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(
@@ -350,12 +390,14 @@ LIMIT $1
` `
type GetMostPlayedSoundtracksWithSongsRow struct { type GetMostPlayedSoundtracksWithSongsRow 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"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Most played soundtracks with their songs // Most played soundtracks with their 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(
@@ -413,12 +459,14 @@ ORDER BY g.soundtrack_name
` `
type GetNeverPlayedSoundtracksRow struct { type GetNeverPlayedSoundtracksRow 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"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
Added time.Time `json:"added"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
Added time.Time `json:"added"`
Songs []byte `json:"songs"`
} }
// Soundtracks that have never been played (times_played = 0) // Soundtracks that have never been played (times_played = 0)
@@ -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(
@@ -477,12 +529,14 @@ LIMIT $1
` `
type GetOldestPlayedSoundtracksRow struct { type GetOldestPlayedSoundtracksRow 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"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Oldest played soundtracks (least recently played, but has been played at least once) // Oldest played soundtracks (least recently played, but has been played at least once)
@@ -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())
}
+56 -36
View File
@@ -8,6 +8,7 @@ import (
"music-server/internal/backend" "music-server/internal/backend"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho" "github.com/danielgtaylor/huma/v2/adapters/humaecho"
@@ -50,47 +51,42 @@ func init() {
} }
} }
// apis holds the documented /api/v1 API and the hidden, deprecated legacy routes. func newAPI(e *echo.Echo) huma.API {
type apis struct {
v1 huma.API
legacy huma.API
}
func newAPIs(e *echo.Echo) apis {
config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version) config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version)
// No $schema fields or Link headers in responses. // No $schema fields or Link headers in responses.
config.CreateHooks = nil config.CreateHooks = nil
config.DocsRenderer = huma.DocsRendererScalar config.DocsRenderer = huma.DocsRendererScalar
config.Info.Description = "API for the MusicServer application.\n\n" + config.Info.Description = "API for the MusicServer application.\n\n" +
"Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" + "Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" +
"The same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token " + "Every client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; " +
"and are not documented here. They will be removed." "send it back in the same header to keep using that queue. Without it, or when the queue has ended " +
"(after a reset or a sync), a new queue is started."
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"}, bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"},
} }
return humaecho.New(e, config)
legacyConfig := huma.DefaultConfig("MusicServer legacy API", backend.GetLatestVersion().Version)
legacyConfig.CreateHooks = nil
legacyConfig.OpenAPIPath = ""
legacyConfig.DocsPath = ""
legacyConfig.SchemasPath = ""
return apis{v1: humaecho.New(e, config), legacy: humaecho.New(e, legacyConfig)}
} }
// route describes an endpoint registered on /api/v1 and, optionally, as a legacy route. // route describes an endpoint registered on /api/v1.
type route struct { type route struct {
op huma.Operation op huma.Operation
// public endpoints need no token. // public endpoints need no token.
public bool public bool
// legacyPath is the deprecated path without /api/v1 (empty: no legacy route).
legacyPath string
// locked endpoints answer 423 while a sync is running. // locked endpoints answer 423 while a sync is running.
locked bool locked bool
// queue endpoints work on the client's play queue, see middleware.Queue.
queue bool
} }
// register adds the route's operation to the v1 API and, if it has one, its hidden legacy route. var queueHeaderParam = &huma.Param{
func register[I, O any](s *Server, a apis, r route, handler func(context.Context, *I) (*O, error)) { Name: middleware.QueueHeader,
In: "header",
Description: "ID of the client's play queue, from an earlier response. Omit it to start a new queue.",
Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"},
}
// register adds the route's operation to the API.
func register[I, O any](s *Server, a huma.API, r route, handler func(context.Context, *I) (*O, error)) {
op := r.op op := r.op
op.Path = apiPrefix + r.op.Path op.Path = apiPrefix + r.op.Path
if !r.public { if !r.public {
@@ -98,23 +94,39 @@ func register[I, O any](s *Server, a apis, r route, handler func(context.Context
op.Errors = append(op.Errors, http.StatusUnauthorized) op.Errors = append(op.Errors, http.StatusUnauthorized)
} }
if r.locked { if r.locked {
op.Middlewares = append(op.Middlewares, s.syncLock(a.v1)) op.Middlewares = append(op.Middlewares, s.syncLock(a))
op.Errors = append(op.Errors, http.StatusLocked) op.Errors = append(op.Errors, http.StatusLocked)
} }
huma.Register(a.v1, op, handler) if r.queue {
op.Middlewares = append(op.Middlewares, middleware.Queue(a, s.music.ResolveQueue))
op.Parameters = append(op.Parameters, queueHeaderParam)
}
huma.Register(a, op, handler)
if r.queue {
documentQueueHeader(a, op)
}
}
if r.legacyPath == "" { // documentQueueHeader adds the X-Queue-Id header to the operation's success responses.
return func documentQueueHeader(a huma.API, op huma.Operation) {
path := a.OpenAPI().Paths[op.Path]
registered := map[string]*huma.Operation{
http.MethodGet: path.Get, http.MethodPut: path.Put, http.MethodPost: path.Post, http.MethodDelete: path.Delete,
}[op.Method]
for status, resp := range registered.Responses {
if !strings.HasPrefix(status, "2") {
continue
}
if resp.Headers == nil {
resp.Headers = map[string]*huma.Param{}
}
if _, ok := resp.Headers[middleware.QueueHeader]; !ok {
resp.Headers[middleware.QueueHeader] = &huma.Param{
Description: "ID of the client's play queue. Send it with the following requests.",
Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"},
}
}
} }
legacy := r.op
legacy.OperationID = "legacy-" + r.op.OperationID
legacy.Path = r.legacyPath
legacy.Hidden = true
legacy.Deprecated = true
if r.locked {
legacy.Middlewares = append(legacy.Middlewares, s.syncLock(a.legacy))
}
huma.Register(a.legacy, legacy, handler)
} }
// syncLock answers 423 Locked while a sync is running. // syncLock answers 423 Locked while a sync is running.
@@ -134,8 +146,16 @@ 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), errors.Is(err, backend.ErrUploadsUnavailable):
return huma.Error503ServiceUnavailable(err.Error())
case errors.Is(err, backend.ErrConflict):
return huma.Error409Conflict(err.Error())
case errors.Is(err, backend.ErrSyncing):
return huma.Error423Locked(err.Error())
case errors.Is(err, backend.ErrTooLarge):
return huma.Error413RequestEntityTooLarge(err.Error())
case errors.Is(err, backend.ErrUpstream): 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
} }
+125 -28
View File
@@ -23,32 +23,93 @@ func TestMusicRandomAndQueue(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "0123456789") e.addSong("Game A", "Song", "0123456789")
rec := e.do(http.MethodGet, "/music/rand") rec := e.do(http.MethodGet, "/api/v1/music/rand")
require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type")) assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
assert.Equal(t, "0123456789", rec.Body.String()) assert.Equal(t, "0123456789", rec.Body.String())
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addPlayed").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addPlayed").Code)
var info backend.SongInfo var info backend.SongInfo
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info)) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info))
assert.Equal(t, "Game A", info.Soundtrack) assert.Equal(t, "Game A", info.Soundtrack)
assert.Equal(t, "Song", info.Song) assert.Equal(t, "Song", info.Song)
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/api/v1/music?song=0"} { for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music?song=0"} {
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
} }
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/api/v1/music/played?song=0").Code)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/music/played?song=x").Code) assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/api/v1/music/played?song=x").Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/reset").Code)
}
func TestMusicQueueID(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "x")
rec := e.do(http.MethodGet, "/api/v1/music/rand")
require.Equal(t, http.StatusOK, rec.Code)
first := rec.Header().Get("X-Queue-Id")
require.NotEmpty(t, first, "a request without a queue ID gets a new queue")
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
rec = e.do(http.MethodGet, "/api/v1/music/list")
assert.Equal(t, first, rec.Header().Get("X-Queue-Id"), "the sent queue is kept")
assert.Contains(t, rec.Body.String(), `"Song":"Song"`)
// Another client has its own queue.
other := e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", "")
assert.NotEqual(t, first, other.Header().Get("X-Queue-Id"))
assert.JSONEq(t, "[]", other.Body.String())
e.queueID = first
rec = e.do(http.MethodGet, "/api/v1/music/reset")
require.Equal(t, http.StatusOK, rec.Code)
second := rec.Header().Get("X-Queue-Id")
assert.NotEqual(t, first, second, "reset starts a new queue")
assert.JSONEq(t, "[]", e.do(http.MethodGet, "/api/v1/music/list").Body.String())
// The old ID has ended, so it gets a new queue too.
rec = e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", first)
assert.NotEqual(t, first, rec.Header().Get("X-Queue-Id"))
assert.Empty(t, e.do(http.MethodGet, "/api/v1/music/all", "X-Queue-Id", "").Header().Get("X-Queue-Id"),
"endpoints that do not use the queue do not start one")
}
func TestMusicQueueEndsAfterSync(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "x")
e.do(http.MethodGet, "/api/v1/music/rand")
e.do(http.MethodGet, "/api/v1/music/addQue")
before := e.queueID
require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
e.server.sync.Wait()
rec := e.do(http.MethodGet, "/api/v1/music/list")
assert.NotEqual(t, before, rec.Header().Get("X-Queue-Id"))
assert.JSONEq(t, "[]", rec.Body.String())
}
func TestMusicSongsAreNotCached(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "x")
e.do(http.MethodGet, "/api/v1/music/rand")
e.do(http.MethodGet, "/api/v1/music/addQue")
for _, path := range []string{"/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic",
"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/soundTest"} {
rec := e.do(http.MethodGet, path)
require.Equal(t, http.StatusOK, rec.Code, path)
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), path)
}
} }
func TestMusicRangeRequest(t *testing.T) { func TestMusicRangeRequest(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "0123456789") e.addSong("Game A", "Song", "0123456789")
rec := e.do(http.MethodGet, "/music/rand", "Range", "bytes=2-5") rec := e.do(http.MethodGet, "/api/v1/music/rand", "Range", "bytes=2-5")
assert.Equal(t, http.StatusPartialContent, rec.Code) assert.Equal(t, http.StatusPartialContent, rec.Code)
assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range")) assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range"))
assert.Equal(t, "2345", rec.Body.String()) assert.Equal(t, "2345", rec.Body.String())
@@ -57,21 +118,21 @@ func TestMusicRangeRequest(t *testing.T) {
func TestMusicEmptyStates(t *testing.T) { func TestMusicEmptyStates(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} { for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic"} {
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
} }
// Invalid or missing parameters fail validation. // Invalid or missing parameters fail validation.
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music?song=abc").Code) assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music?song=abc").Code)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music").Code) assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music").Code)
rec := e.do(http.MethodGet, "/music/list") rec := e.do(http.MethodGet, "/api/v1/music/list")
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, "[]", rec.Body.String()) assert.JSONEq(t, "[]", rec.Body.String())
} }
func TestMusicSoundCheck(t *testing.T) { func TestMusicSoundCheck(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
rec := e.do(http.MethodGet, "/music/soundTest") rec := e.do(http.MethodGet, "/api/v1/music/soundTest")
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "sound check", rec.Body.String()) assert.Equal(t, "sound check", rec.Body.String())
} }
@@ -82,31 +143,41 @@ func TestMusicSoundtrackLists(t *testing.T) {
e.addSong("A Game", "Song", "a") e.addSong("A Game", "Song", "a")
var ordered []string var ordered []string
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all/order").Body.Bytes(), &ordered)) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all/order").Body.Bytes(), &ordered))
assert.Equal(t, []string{"A Game", "B Game"}, ordered) assert.Equal(t, []string{"A Game", "B Game"}, ordered)
var random []string var random []string
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all").Body.Bytes(), &random)) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all").Body.Bytes(), &random))
assert.ElementsMatch(t, ordered, random) assert.ElementsMatch(t, ordered, random)
} }
func TestMusicDatabaseError(t *testing.T) { func TestMusicDatabaseError(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.store.Err = errors.New("db down") e.store.Err = errors.New("db down")
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/rand").Code) assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/rand").Code)
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/all/order").Code) assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/all/order").Code)
} }
func TestCharacterEndpoints(t *testing.T) { func TestCharacterEndpoints(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
var list []string var list []string
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/characters").Body.Bytes(), &list)) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list))
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list)) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list))
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
rec := e.do(http.MethodGet, "/character?name=Adol.png") var details []struct {
Name string `json:"name"`
Modified int64 `json:"modified"`
}
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/details").Body.Bytes(), &details))
require.Len(t, details, 2)
assert.Equal(t, "Adol.png", details[0].Name)
assert.Equal(t, "Aloy.jpg", details[1].Name)
assert.Positive(t, details[0].Modified)
rec := e.do(http.MethodGet, "/api/v1/character?name=Adol.png")
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
@@ -114,14 +185,14 @@ func TestCharacterEndpoints(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type"))
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/character?name=Missing.png").Code) assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/character?name=Missing.png").Code)
} }
func TestCharacterPathTraversal(t *testing.T) { func TestCharacterPathTraversal(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/character?name=").Code, "name is required") assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/character?name=").Code, "name is required")
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} { for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} {
rec := e.do(http.MethodGet, "/character?name="+name) rec := e.do(http.MethodGet, "/api/v1/character?name="+name)
assert.Equal(t, http.StatusBadRequest, rec.Code, name) assert.Equal(t, http.StatusBadRequest, rec.Code, name)
assert.NotContains(t, rec.Body.String(), "secret", name) assert.NotContains(t, rec.Body.String(), "secret", name)
} }
@@ -140,23 +211,43 @@ func TestDownloadEndpoints(t *testing.T) {
url := newGitea(t, http.StatusOK) url := newGitea(t, http.StatusOK)
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url }) e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url })
rec := e.do(http.MethodGet, "/download") rec := e.do(http.MethodGet, "/api/v1/download")
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `"v2.0.0"`, rec.Body.String()) assert.JSONEq(t, `"v2.0.0"`, rec.Body.String())
rec = e.do(http.MethodGet, "/api/v1/download/list") rec = e.do(http.MethodGet, "/api/v1/download/list")
assert.JSONEq(t, `["Player.exe"]`, rec.Body.String()) assert.JSONEq(t, `["Player.exe"]`, rec.Body.String())
rec = e.do(http.MethodGet, "/download/windows") rec = e.do(http.MethodGet, "/api/v1/download/windows")
assert.Equal(t, http.StatusFound, rec.Code) assert.Equal(t, http.StatusFound, rec.Code)
assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location")) assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location"))
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/download/linux").Code) // A release without Linux files, like those before 1.9.
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/download/linux").Code)
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/download/linux-package").Code)
}
func TestDownloadLinuxBinaryAndPackage(t *testing.T) {
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"id":1,"name":"v1.9.0","assets":[` +
`{"name":"MusicPlayer_1.9.0_linux.tar.gz","browser_download_url":"https://example.com/package"},` +
`{"name":"MusicPlayer_1.9.0.x86_64","browser_download_url":"https://example.com/binary"}]}`))
}))
t.Cleanup(gitea.Close)
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea.URL })
rec := e.do(http.MethodGet, "/api/v1/download/linux")
assert.Equal(t, http.StatusFound, rec.Code)
assert.Equal(t, "https://example.com/binary", rec.Header().Get("Location"), "the bare binary, for updating in place")
rec = e.do(http.MethodGet, "/api/v1/download/linux-package")
assert.Equal(t, http.StatusFound, rec.Code)
assert.Equal(t, "https://example.com/package", rec.Header().Get("Location"))
} }
func TestDownloadUpstreamDown(t *testing.T) { func TestDownloadUpstreamDown(t *testing.T) {
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) }) e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) })
for _, path := range []string{"/download", "/download/list", "/download/windows"} { for _, path := range []string{"/api/v1/download", "/api/v1/download/list", "/api/v1/download/windows"} {
assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path) assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path)
} }
} }
@@ -191,7 +282,6 @@ func TestTokenAuth(t *testing.T) {
token := e.createToken("secret") token := e.createToken("secret")
auth := []string{"Authorization", "Bearer " + token} auth := []string{"Authorization", "Bearer " + token}
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", auth...).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/version").Code, "legacy routes need no token")
assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code)
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
@@ -264,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}]`),
}, { }, {
@@ -305,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))
+11
View File
@@ -14,6 +14,7 @@ import (
"music-server/internal/config" "music-server/internal/config"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware"
"music-server/internal/testutil/fakedb" "music-server/internal/testutil/fakedb"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -48,6 +49,8 @@ type testEnv struct {
db *fakeDatabase db *fakeDatabase
server *Server server *Server
handler http.Handler handler http.Handler
// queueID is sent as X-Queue-Id and updated from responses, like a client keeping its queue.
queueID string
} }
func writeFile(t *testing.T, path, content string) { func writeFile(t *testing.T, path, content string) {
@@ -67,6 +70,8 @@ func newTestEnv(t *testing.T, configure ...func(*config.Config)) *testEnv {
FrontendPath: filepath.Join(base, "frontend"), FrontendPath: filepath.Join(base, "frontend"),
DisableSessionAuth: true, DisableSessionAuth: true,
ReleasesURL: "http://127.0.0.1:1", ReleasesURL: "http://127.0.0.1:1",
UploadMaxFileSize: 1 << 20,
UploadMaxTotalSize: 3 << 20,
} }
for _, c := range configure { for _, c := range configure {
c(&cfg) c(&cfg)
@@ -111,10 +116,16 @@ func (e *testEnv) doBody(method, path, body string, headers ...string) *httptest
if body != "" { if body != "" {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
if e.queueID != "" {
req.Header.Set(middleware.QueueHeader, e.queueID)
}
for i := 0; i+1 < len(headers); i += 2 { for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1]) req.Header.Set(headers[i], headers[i+1])
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
e.handler.ServeHTTP(rec, req) e.handler.ServeHTTP(rec, req)
if id := rec.Header().Get(middleware.QueueHeader); id != "" {
e.queueID = id
}
return rec return rec
} }
+37 -1
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,7 +20,21 @@ func (in *uuidInput) id() uuid.UUID {
return uuid.MustParse(in.UUID) return uuid.MustParse(in.UUID)
} }
func (s *Server) registerLibrary(a apis) { // AlternativeNames is the body of PUT /soundtracks/{uuid}/alternative-names.
type AlternativeNames struct {
Names []string `json:"names" maxItems:"50" doc:"The new list of alternative names. Replaces the old list; send [] to remove them all."`
}
type alternativeNamesInput struct {
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
Body AlternativeNames
}
func (in *alternativeNamesInput) id() uuid.UUID {
return uuid.MustParse(in.UUID)
}
func (s *Server) registerLibrary(a huma.API) {
l := s.library 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 {
return huma.Operation{ return huma.Operation{
@@ -49,6 +64,27 @@ func (s *Server) registerLibrary(a apis) {
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)
+49 -4
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"
@@ -55,7 +57,6 @@ func TestLibraryEndpointErrors(t *testing.T) {
func TestLibraryEndpointsRequireToken(t *testing.T) { func TestLibraryEndpointsRequireToken(t *testing.T) {
e := newTestEnv(t, withAuth("secret")) e := newTestEnv(t, withAuth("secret"))
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/soundtracks").Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/soundtracks").Code)
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/soundtracks").Code, "not available on legacy routes")
} }
func TestSongFileLockedDuringSync(t *testing.T) { func TestSongFileLockedDuringSync(t *testing.T) {
@@ -72,11 +73,11 @@ func TestSongFileLockedDuringSync(t *testing.T) {
func TestQueueResponsesIncludeUUIDs(t *testing.T) { func TestQueueResponsesIncludeUUIDs(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
song := e.addSong("Game A", "Song", "x") song := e.addSong("Game A", "Song", "x")
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/rand").Code) require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/rand").Code)
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code) require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
var info map[string]any var info map[string]any
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info)) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info))
assert.Equal(t, song.Uuid.String(), info["SongUuid"]) assert.Equal(t, song.Uuid.String(), info["SongUuid"])
assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"]) assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"])
@@ -85,3 +86,47 @@ func TestQueueResponsesIncludeUUIDs(t *testing.T) {
require.Len(t, list, 1) require.Len(t, list, 1)
assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"]) assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"])
} }
func TestSetAlternativeNames(t *testing.T) {
e := newTestEnv(t)
song := e.addSong("Final Fantasy VII", "Prelude", "x")
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names"
rec := e.doBody(http.MethodPut, path, `{"names":[" FF7 ","ff7","Final Fantasy VII"]}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var soundtrack backend.Soundtrack
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtrack))
assert.Equal(t, []string{"FF7"}, soundtrack.AlternativeNames)
rec = e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String())
assert.Contains(t, rec.Body.String(), `"alternative_names":["FF7"]`)
// The search page finds it by the new name right away.
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader("search_term=ff7"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
e.handler.ServeHTTP(rec, req)
assert.Contains(t, rec.Body.String(), "Final Fantasy VII")
assert.Contains(t, rec.Body.String(), "Also known as FF7")
}
func TestSetAlternativeNamesValidation(t *testing.T) {
e := newTestEnv(t)
song := e.addSong("Game A", "Song", "x")
path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names"
long := strings.Repeat("a", backend.MaxNameLength+1)
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{"names":["`+long+`"]}`).Code)
many := `{"names":["` + strings.Repeat(`x","`, 50) + `x"]}`
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, many).Code, "at most 50 names")
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{}`).Code, "names is required")
assert.Equal(t, http.StatusUnprocessableEntity,
e.doBody(http.MethodPut, "/api/v1/soundtracks/not-a-uuid/alternative-names", `{"names":[]}`).Code)
}
func TestSetAlternativeNamesRequiresToken(t *testing.T) {
e := newTestEnv(t, withAuth("secret"))
song := e.addSong("Game A", "Song", "x")
rec := e.doBody(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names", `{"names":["A"]}`)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
-10
View File
@@ -1,10 +0,0 @@
package middleware
import "github.com/danielgtaylor/huma/v2"
// Deprecation marks responses from legacy routes as deprecated in favour of /api/v1.
func Deprecation(ctx huma.Context, next func(huma.Context)) {
ctx.AppendHeader("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
ctx.AppendHeader("Deprecation", "true")
next(ctx)
}
+38
View File
@@ -0,0 +1,38 @@
package middleware
import (
"context"
"net/http"
"music-server/internal/logging"
"github.com/danielgtaylor/huma/v2"
"github.com/google/uuid"
"go.uber.org/zap"
)
// QueueHeader carries the client's queue ID in requests and responses.
const QueueHeader = "X-Queue-Id"
type queueKey struct{}
// QueueID returns the queue ID resolved by Queue.
func QueueID(ctx context.Context) uuid.UUID {
id, _ := ctx.Value(queueKey{}).(uuid.UUID)
return id
}
// Queue resolves the queue ID in the X-Queue-Id header with resolve, which starts a new queue
// when the header is missing or the queue has ended. The ID is returned in the same header.
func Queue(api huma.API, resolve func(context.Context, string) (uuid.UUID, error)) Middleware {
return func(ctx huma.Context, next func(huma.Context)) {
id, err := resolve(ctx.Context(), ctx.Header(QueueHeader))
if err != nil {
logging.GetLogger().Error("Failed to resolve queue", zap.Error(err))
_ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "Internal Server Error")
return
}
ctx.SetHeader(QueueHeader, id.String())
next(huma.WithValue(ctx, queueKey{}, id))
}
}
+17 -9
View File
@@ -22,7 +22,7 @@ type healthOutput struct {
Body map[string]string Body map[string]string
} }
func (s *Server) registerMisc(a apis) { func (s *Server) registerMisc(a huma.API) {
get := func(id, path, tag, summary, description string, errs ...int) huma.Operation { get := func(id, path, tag, summary, description string, errs ...int) huma.Operation {
return huma.Operation{ return huma.Operation{
OperationID: id, OperationID: id,
@@ -37,30 +37,36 @@ func (s *Server) registerMisc(a apis) {
register(s, a, route{op: get("get-health", "/health", "server", "Health check", register(s, a, route{op: get("get-health", "/health", "server", "Health check",
"Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error."), "Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error."),
public: true, legacyPath: "/health"}, public: true},
func(ctx context.Context, _ *struct{}) (*healthOutput, error) { func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
return &healthOutput{Body: s.db.Health(ctx)}, nil return &healthOutput{Body: s.db.Health(ctx)}, nil
}) })
register(s, a, route{op: get("get-version", "/version", "server", "Latest server version", register(s, a, route{op: get("get-version", "/version", "server", "Latest server version",
"Returns the server version and its changelog."), legacyPath: "/version"}, "Returns the server version and its changelog.")},
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) {
return ok(backend.GetLatestVersion(), nil) return ok(backend.GetLatestVersion(), nil)
}) })
register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history", register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history",
"Returns all server versions with their changelogs, newest first."), legacyPath: "/version/history"}, "Returns all server versions with their changelogs, newest first.")},
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) {
return ok(backend.GetVersionHistory(), nil) return ok(backend.GetVersionHistory(), nil)
}) })
register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters", register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters",
"Returns the file names of all character images."), legacyPath: "/characters"}, "Returns the file names of all character images.")},
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")
register(s, a, route{op: image, legacyPath: "/character"}, register(s, a, route{op: image},
func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) { func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) {
f, err := s.characters.Open(in.Name) f, err := s.characters.Open(in.Name)
if err != nil { if err != nil {
@@ -71,12 +77,12 @@ func (s *Server) registerMisc(a apis) {
r := s.releases r := s.releases
register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version", register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version",
"Returns the name of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download"}, "Returns the name of the latest MusicPlayer release.", http.StatusBadGateway)},
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
return ok(r.LatestVersion(ctx)) return ok(r.LatestVersion(ctx))
}) })
register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets", register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets",
"Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download/list"}, "Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway)},
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(r.AssetNames(ctx)) return ok(r.AssetNames(ctx))
}) })
@@ -85,7 +91,7 @@ func (s *Server) registerMisc(a apis) {
"Redirects to the download URL of the latest MusicPlayer release for "+platform+".", "Redirects to the download URL of the latest MusicPlayer release for "+platform+".",
http.StatusNotFound, http.StatusBadGateway) http.StatusNotFound, http.StatusBadGateway)
op.DefaultStatus = http.StatusFound op.DefaultStatus = http.StatusFound
register(s, a, route{op: op, legacyPath: path}, register(s, a, route{op: op},
func(ctx context.Context, _ *struct{}) (*redirectOutput, error) { func(ctx context.Context, _ *struct{}) (*redirectOutput, error) {
url, err := r.AssetURL(ctx, suffix) url, err := r.AssetURL(ctx, suffix)
if err != nil { if err != nil {
@@ -96,4 +102,6 @@ func (s *Server) registerMisc(a apis) {
} }
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")
} }
+53 -36
View File
@@ -6,6 +6,7 @@ import (
"music-server/internal/backend" "music-server/internal/backend"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"go.uber.org/zap" "go.uber.org/zap"
@@ -15,9 +16,14 @@ type songNumberInput struct {
Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"` Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"`
} }
// resetOutput returns the ID of the new queue.
type resetOutput struct {
QueueID string `header:"X-Queue-Id" doc:"ID of the new queue"`
}
const audioMPEG = "audio/mpeg" const audioMPEG = "audio/mpeg"
func (s *Server) registerMusic(a apis) { func (s *Server) registerMusic(a huma.API) {
m := s.music m := s.music
audio := func(id, path, summary, description string, errs ...int) route { audio := func(id, path, summary, description string, errs ...int) route {
return route{ return route{
@@ -31,8 +37,7 @@ func (s *Server) registerMusic(a apis) {
Responses: fileResponses("The song (MP3)", audioMPEG), Responses: fileResponses("The song (MP3)", audioMPEG),
Errors: errs, Errors: errs,
}, },
legacyPath: path, locked: true,
locked: true,
} }
} }
status := func(id, method, path, summary, description string) route { status := func(id, method, path, summary, description string) route {
@@ -46,8 +51,8 @@ func (s *Server) registerMusic(a apis) {
Tags: []string{"music"}, Tags: []string{"music"},
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
}, },
legacyPath: path, locked: true,
locked: true, queue: true,
} }
} }
list := func(id, path, summary, description string, locked bool) route { list := func(id, path, summary, description string, locked bool) route {
@@ -60,54 +65,59 @@ func (s *Server) registerMusic(a apis) {
Description: description, Description: description,
Tags: []string{"music"}, Tags: []string{"music"},
}, },
legacyPath: path, locked: locked,
locked: locked,
} }
} }
// queued marks a route that works on the client's play queue.
queued := func(r route) route {
r.queue = true
return r
}
queue := middleware.QueueID
register(s, a, audio("get-queue-song", "/music", "Play a song from the queue", register(s, a, queued(audio("get-queue-song", "/music", "Play a song from the queue",
"Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound), "Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound)),
func(ctx context.Context, in *songNumberInput) (*huma.StreamResponse, error) { func(ctx context.Context, in *songNumberInput) (*huma.StreamResponse, error) {
return streamSong(m.Song(in.Song)) return streamSong(m.Song(ctx, queue(ctx), in.Song))
}) })
register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song", register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song",
"Returns a random song for checking the volume.", http.StatusNotFound), "Returns a random song for checking the volume.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.SoundCheckSong()) return streamSong(m.SoundCheckSong())
}) })
register(s, a, audio("get-random-song", "/music/rand", "Random song", register(s, a, queued(audio("get-random-song", "/music/rand", "Random song",
"Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound), "Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound)),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSong(ctx)) return streamSong(m.RandomSong(ctx, queue(ctx)))
}) })
register(s, a, audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played", register(s, a, queued(audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played",
"Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound), "Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound)),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSongLowChance(ctx)) return streamSong(m.RandomSongLowChance(ctx, queue(ctx)))
}) })
register(s, a, audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal", register(s, a, queued(audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal",
"Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound), "Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound)),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSongClassic(ctx)) return streamSong(m.RandomSongClassic(ctx, queue(ctx)))
}) })
register(s, a, audio("get-next-song", "/music/next", "Next song in the queue", register(s, a, queued(audio("get-next-song", "/music/next", "Next song in the queue",
"Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound), "Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound)),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.NextSong()) return streamSong(m.NextSong(ctx, queue(ctx)))
}) })
register(s, a, audio("get-previous-song", "/music/previous", "Previous song in the queue", register(s, a, queued(audio("get-previous-song", "/music/previous", "Previous song in the queue",
"Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound), "Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound)),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.PreviousSong()) return streamSong(m.PreviousSong(ctx, queue(ctx)))
}) })
register(s, a, list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false), register(s, a, queued(list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false)),
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.SongInfo], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[backend.SongInfo], error) {
return ok(m.SongInfo(ctx), nil) return ok(m.SongInfo(ctx, queue(ctx)))
}) })
register(s, a, list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false), register(s, a, queued(list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false)),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SongInfo], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SongInfo], error) {
return ok(m.PlayedSongs(ctx), nil) return ok(m.PlayedSongs(ctx, queue(ctx)))
}) })
randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(m.SoundtrackNamesRandom(ctx)) return ok(m.SoundtrackNamesRandom(ctx))
@@ -123,23 +133,30 @@ func (s *Server) registerMusic(a apis) {
}) })
register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue", register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue",
"Clears the queue and reloads the soundtrack list."), "Ends the queue, starts a new one and reloads the soundtrack list. "+
func(ctx context.Context, _ *struct{}) (*noBody, error) { "The new queue's ID is returned in the X-Queue-Id header; send it with the following requests."),
if err := m.Reset(ctx); err != nil { func(ctx context.Context, _ *struct{}) (*resetOutput, error) {
id, err := m.NewQueue(ctx, queue(ctx))
if err != nil {
return nil, apiError(err) return nil, apiError(err)
} }
return &noBody{}, nil if err := m.ReloadSoundtracks(ctx); err != nil {
return nil, apiError(err)
}
return &resetOutput{QueueID: id.String()}, nil
}) })
register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue", register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue",
"Adds the song returned by the last random song request to the end of the queue."), "Adds the song returned by the last random song request to the end of the queue."),
func(ctx context.Context, _ *struct{}) (*noBody, error) { func(ctx context.Context, _ *struct{}) (*noBody, error) {
m.AddLatestToQueue() if err := m.AddLatestToQueue(ctx, queue(ctx)); err != nil {
return nil, apiError(err)
}
return &noBody{}, nil return &noBody{}, nil
}) })
register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played", register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played",
"Increments the play counters of the current song and its soundtrack."), "Increments the play counters of the current song and its soundtrack."),
func(ctx context.Context, _ *struct{}) (*noBody, error) { func(ctx context.Context, _ *struct{}) (*noBody, error) {
if err := m.AddLatestPlayed(ctx); err != nil { if err := m.AddLatestPlayed(ctx, queue(ctx)); err != nil {
return nil, apiError(err) return nil, apiError(err)
} }
return &noBody{}, nil return &noBody{}, nil
@@ -148,7 +165,7 @@ func (s *Server) registerMusic(a apis) {
"Increments the play counters of the song at the given queue position. Positions outside the queue are ignored."), "Increments the play counters of the song at the given queue position. Positions outside the queue are ignored."),
func(ctx context.Context, in *songNumberInput) (*noBody, error) { func(ctx context.Context, in *songNumberInput) (*noBody, error) {
logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song)) logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song))
if err := m.SetPlayed(ctx, in.Song); err != nil { if err := m.SetPlayed(ctx, queue(ctx), in.Song); err != nil {
return nil, apiError(err) return nil, apiError(err)
} }
return &noBody{}, nil return &noBody{}, nil
+46 -2
View File
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
"music-server/internal/backend"
"music-server/internal/config" "music-server/internal/config"
"github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3"
@@ -74,11 +75,15 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
} }
slices.Sort(operations) slices.Sort(operations)
assert.Equal(t, []string{ assert.Equal(t, []string{
"DELETE /api/v1/soundtracks/uploads/{id}",
"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",
@@ -100,7 +105,9 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"GET /api/v1/songs/{uuid}", "GET /api/v1/songs/{uuid}",
"GET /api/v1/songs/{uuid}/file", "GET /api/v1/songs/{uuid}/file",
"GET /api/v1/soundtracks", "GET /api/v1/soundtracks",
"GET /api/v1/soundtracks/uploads/{id}",
"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",
@@ -113,12 +120,18 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"GET /api/v1/sync/progress", "GET /api/v1/sync/progress",
"GET /api/v1/version", "GET /api/v1/version",
"GET /api/v1/version/history", "GET /api/v1/version/history",
"POST /api/v1/soundtracks/uploads",
"POST /api/v1/soundtracks/uploads/{id}/finish",
"POST /api/v1/sync", "POST /api/v1/sync",
"POST /api/v1/sync/full", "POST /api/v1/sync/full",
"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",
}, operations, "/sync/reset is hidden and legacy routes are not documented") "PUT /api/v1/soundtracks/uploads/{id}/files/{file}",
"PUT /api/v1/soundtracks/{uuid}/alternative-names",
"PUT /api/v1/soundtracks/{uuid}/cover",
"PUT /api/v1/soundtracks/{uuid}/cover/source",
}, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented")
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type) assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme) assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme)
@@ -168,7 +181,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 +214,42 @@ 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)
// Uploads.
started := v.check(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Uploaded"}`, http.StatusCreated)
var upload backend.Upload
require.NoError(t, json.Unmarshal(started.Body.Bytes(), &upload))
up := "/api/v1/soundtracks/uploads/" + upload.ID.String()
v.check(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Uploaded"}`, http.StatusConflict)
v.check(http.MethodPut, up+"/files/01.mp3", "ID3 song", http.StatusOK, "Content-Type", "audio/mpeg")
v.check(http.MethodPut, up+"/files/02.wav", "ID3 song", http.StatusBadRequest, "Content-Type", "audio/mpeg")
v.check(http.MethodGet, up, "", http.StatusOK)
v.check(http.MethodPost, up+"/finish", "", http.StatusOK)
v.check(http.MethodGet, up, "", http.StatusNotFound)
cancelled := v.check(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Cancelled"}`, http.StatusCreated)
require.NoError(t, json.Unmarshal(cancelled.Body.Bytes(), &upload))
v.check(http.MethodDelete, "/api/v1/soundtracks/uploads/"+upload.ID.String(), "", http.StatusNoContent)
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound) v.check(http.MethodGet, "/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 +257,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)
+21 -13
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"
@@ -24,10 +25,10 @@ func (s *Server) RegisterRoutes() http.Handler {
// the handlers are registered but never called. // the handlers are registered but never called.
func OpenAPISpec() *huma.OpenAPI { func OpenAPISpec() *huma.OpenAPI {
_, a := (&Server{}).routes() _, a := (&Server{}).routes()
return a.v1.OpenAPI() return a.OpenAPI()
} }
func (s *Server) routes() (*echo.Echo, apis) { func (s *Server) routes() (*echo.Echo, huma.API) {
e := echo.New() e := echo.New()
e.HTTPErrorHandler = httpErrorHandler e.HTTPErrorHandler = httpErrorHandler
if len(s.cfg.TrustedProxies) > 0 { if len(s.cfg.TrustedProxies) > 0 {
@@ -39,24 +40,31 @@ func (s *Server) routes() (*echo.Echo, apis) {
} }
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())
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ // Without allowed origins no CORS headers are sent, so only same-origin browser requests work.
AllowOrigins: []string{"https://*", "http://*"}, // The API uses Bearer tokens, not cookies, so credentials are not allowed.
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, if len(s.cfg.CORSAllowedOrigins) > 0 {
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowCredentials: true, AllowOrigins: s.cfg.CORSAllowedOrigins,
MaxAge: 300, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
})) AllowHeaders: []string{"Accept", "Authorization", "Cache-Control", "Content-Type", "Range", middleware.QueueHeader},
ExposeHeaders: []string{middleware.QueueHeader, "Content-Range", "Accept-Ranges"},
MaxAge: 300,
}))
}
// The API: /api/v1 is documented at /docs and /openapi.json; legacy routes are hidden. // The API: /api/v1 is documented at /docs and /openapi.json.
a := newAPIs(e) a := newAPI(e)
a.v1.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a.v1, s.q, s.cfg.DisableSessionAuth)) a.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a, s.q, s.cfg.DisableSessionAuth))
a.legacy.UseMiddleware(middleware.ClientInfo, middleware.Deprecation)
s.registerToken(a) s.registerToken(a)
s.registerMisc(a) s.registerMisc(a)
s.registerSync(a) s.registerSync(a)
s.registerMusic(a) s.registerMusic(a)
s.registerLibrary(a) s.registerLibrary(a)
s.registerCovers(a)
s.registerUploads(a)
s.registerStatistics(a) s.registerStatistics(a)
// Older links to the spec and docs. // Older links to the spec and docs.
+86 -46
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"
@@ -28,64 +29,56 @@ func (e *testEnv) blockSync() (release func()) {
func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) { func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "a") e.addSong("Game A", "Song", "a")
for _, prefix := range []string{"", "/api/v1"} { for _, path := range []string{"/api/v1/sync", "/api/v1/sync/full", "/api/v1/sync/reset"} {
for _, path := range []string{"/sync", "/sync/full", "/sync/reset"} { // GET falls through to the frontend's static files, which answer 404.
// GET falls through to the frontend's static files, which answer 404. assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path)
}
} }
assert.False(t, e.server.sync.IsSyncing(), "no sync was started") assert.False(t, e.server.sync.IsSyncing(), "no sync was started")
assert.Len(t, e.store.Songs, 1, "nothing was deleted") assert.Len(t, e.store.Songs, 1, "nothing was deleted")
} }
func TestSyncStartAndLock(t *testing.T) { func TestSyncStartAndLock(t *testing.T) {
for _, prefix := range []string{"", "/api/v1"} { e := newTestEnv(t)
t.Run("prefix="+prefix, func(t *testing.T) { e.addSong("Game A", "Song", "a")
e := newTestEnv(t) release := e.blockSync()
e.addSong("Game A", "Song", "a")
release := e.blockSync()
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, prefix+"/sync").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/full").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/reset").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/music/rand").Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/info").Code, "info is available during sync") assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/info").Code, "info is available during sync")
progress := e.do(http.MethodGet, prefix+"/sync/progress") progress := e.do(http.MethodGet, "/api/v1/sync/progress")
assert.Equal(t, http.StatusOK, progress.Code) assert.Equal(t, http.StatusOK, progress.Code)
assert.Contains(t, progress.Body.String(), `"progress"`) assert.Contains(t, progress.Body.String(), `"progress"`)
release() release()
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/all").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/all").Code)
result := e.do(http.MethodGet, prefix+"/sync/progress") result := e.do(http.MethodGet, "/api/v1/sync/progress")
assert.Contains(t, result.Body.String(), `"total_time"`) assert.Contains(t, result.Body.String(), `"total_time"`)
})
}
} }
func TestSyncResetEndpoint(t *testing.T) { func TestSyncResetEndpoint(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "a") e.addSong("Game A", "Song", "a")
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/sync/reset").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync/reset").Code)
assert.Empty(t, e.store.Soundtracks) assert.Empty(t, e.store.Soundtracks)
assert.Empty(t, e.store.Songs) assert.Empty(t, e.store.Songs)
} }
func TestLegacyRoutesAreDeprecated(t *testing.T) { func TestLegacyRoutesAreRemoved(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
rec := e.do(http.MethodGet, "/version") for _, path := range []string{"/version", "/health", "/music/list", "/music/rand", "/characters", "/sync/progress"} {
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
assert.Equal(t, "true", rec.Header().Get("Deprecation")) }
assert.Equal(t, http.StatusMethodNotAllowed, e.do(http.MethodPost, "/sync").Code)
rec = e.do(http.MethodGet, "/api/v1/version") assert.False(t, e.server.sync.IsSyncing())
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Deprecation"))
} }
func TestVersionEndpoints(t *testing.T) { func TestVersionEndpoints(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
var latest backend.VersionData var latest backend.VersionData
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/version").Body.Bytes(), &latest)) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/version").Body.Bytes(), &latest))
assert.Equal(t, backend.GetLatestVersion(), latest) assert.Equal(t, backend.GetLatestVersion(), latest)
var history []backend.VersionData var history []backend.VersionData
@@ -95,28 +88,19 @@ func TestVersionEndpoints(t *testing.T) {
func TestHealthEndpoint(t *testing.T) { func TestHealthEndpoint(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
rec := e.do(http.MethodGet, "/health") rec := e.do(http.MethodGet, "/api/v1/health")
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
} }
func TestSyncAliasesAreRemoved(t *testing.T) { func TestSyncAliasesAreRemoved(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
for _, prefix := range []string{"", "/api/v1"} { for _, path := range []string{"/api/v1/sync/new", "/api/v1/sync/quick", "/api/v1/sync/new/full"} {
for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} { assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, path).Code, path)
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path)
}
} }
assert.False(t, e.server.sync.IsSyncing()) assert.False(t, e.server.sync.IsSyncing())
} }
func TestV1HealthEndpoint(t *testing.T) {
e := newTestEnv(t)
rec := e.do(http.MethodGet, "/api/v1/health")
assert.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
}
func TestDocsAndSpecLinks(t *testing.T) { func TestDocsAndSpecLinks(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code)
@@ -213,3 +197,59 @@ func TestTrustedProxies(t *testing.T) {
assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1").Code) assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1").Code)
assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.2").Code) assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.2").Code)
} }
func TestCORSAllowedOrigins(t *testing.T) {
e := newTestEnv(t, func(c *config.Config) { c.CORSAllowedOrigins = []string{"https://music.example.com"} })
rec := e.do(http.MethodGet, "/api/v1/music/list", "Origin", "https://music.example.com")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "https://music.example.com", rec.Header().Get("Access-Control-Allow-Origin"))
assert.Contains(t, rec.Header().Get("Access-Control-Expose-Headers"), "X-Queue-Id")
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"))
preflight := e.do(http.MethodOptions, "/api/v1/music/list", "Origin", "https://music.example.com",
"Access-Control-Request-Method", "GET", "Access-Control-Request-Headers", "authorization,x-queue-id")
assert.Equal(t, http.StatusNoContent, preflight.Code)
assert.Equal(t, "https://music.example.com", preflight.Header().Get("Access-Control-Allow-Origin"))
assert.Contains(t, preflight.Header().Get("Access-Control-Allow-Headers"), "X-Queue-Id")
assert.Contains(t, preflight.Header().Get("Access-Control-Allow-Headers"), "Authorization")
other := e.do(http.MethodGet, "/api/v1/music/list", "Origin", "https://evil.example.com")
assert.Empty(t, other.Header().Get("Access-Control-Allow-Origin"), "other origins get no CORS headers")
}
func TestCORSWithoutAllowedOrigins(t *testing.T) {
e := newTestEnv(t)
rec := e.do(http.MethodGet, "/api/v1/version", "Origin", "https://music.example.com")
assert.Equal(t, http.StatusOK, rec.Code, "the request itself works; the browser blocks reading it")
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
}
func TestMetricsAreNotServedOnTheMainPort(t *testing.T) {
e := newTestEnv(t, func(c *config.Config) { c.MetricsPort = 2112 })
require.NotNil(t, e.server.metrics)
for _, path := range []string{"/metrics", "/api/v1/metrics"} {
rec := e.do(http.MethodGet, path)
assert.Equal(t, http.StatusNotFound, rec.Code, path)
assert.NotContains(t, rec.Body.String(), "go_goroutines", path)
}
}
func TestMetricsOff(t *testing.T) {
assert.Nil(t, newTestEnv(t).server.metrics, "MetricsPort 0 means no metrics server")
}
func TestRequestMetricsUseRoutePatterns(t *testing.T) {
e := newTestEnv(t, func(c *config.Config) { c.MetricsPort = 2112 })
song := e.addSong("Game A", "Song", "x")
e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String())
e.do(http.MethodGet, "/no/such/page?secret=1")
rec := httptest.NewRecorder()
e.server.metrics.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := rec.Body.String()
assert.Contains(t, body, `http_requests_total{code="200",method="GET",route="/api/v1/songs/:uuid"}`)
assert.NotContains(t, body, song.Uuid.String(), "IDs never become labels")
assert.NotContains(t, body, "secret", "query strings never become labels")
assert.NotContains(t, body, "/no/such/page", "unknown URLs count under the frontend's /* route")
}
+83 -4
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"
) )
@@ -32,8 +34,16 @@ type Server struct {
characters *backend.CharacterService characters *backend.CharacterService
releases *backend.ReleaseService releases *backend.ReleaseService
stats *backend.StatisticsService stats *backend.StatisticsService
uploads *backend.UploadService
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 +63,23 @@ 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)
s.uploads = backend.NewUploadService(q, cfg.MusicPath, s.sync, s.library, cfg.UploadMaxFileSize, cfg.UploadMaxTotalSize)
if cfg.CoversPath != "" {
s.covers, err = backend.NewCoverService(q, cfg.CoversPath, backend.CoverOptions{
SteamGridDBKey: cfg.SteamGridDBAPIKey,
SteamGridDBURL: cfg.SteamGridDBURL,
AniListURL: cfg.AniListURL,
})
if err != nil {
return nil, fmt.Errorf("covers path: %w", err)
}
}
if cfg.MetricsPort > 0 {
s.metrics = metrics.NewServer(cfg.MetricsPort)
}
s.httpServer = &http.Server{ s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port), Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: s.RegisterRoutes(), Handler: s.RegisterRoutes(),
@@ -66,14 +90,50 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
return s, nil return s, nil
} }
// startCoverSearch runs the background cover search until Shutdown. It does nothing when
// covers are off or there is no SteamGridDB API key.
func (s *Server) startCoverSearch() {
if s.covers == nil || !s.covers.SearchesAutomatically() {
logging.GetLogger().Info("Automatic cover search is off",
zap.Bool("covers_path_set", s.covers != nil))
return
}
ctx, cancel := context.WithCancel(context.Background())
s.stopCovers, s.coversDone = cancel, make(chan struct{})
go func() {
defer close(s.coversDone)
s.covers.Run(ctx)
}()
}
// soundtracksChanged reloads the cached soundtrack data after soundtracks were edited, and
// lets the cover search try the new names.
func (s *Server) soundtracksChanged(ctx context.Context) {
if err := s.music.ReloadSoundtracks(ctx); err != nil {
logging.GetLogger().Error("Failed to reload soundtracks", zap.Error(err))
}
if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache", zap.Error(err))
}
if s.covers != nil {
s.covers.Trigger()
}
}
// afterSync refreshes cached data once a sync has finished. // afterSync refreshes cached data once a sync has finished.
func (s *Server) afterSync(ctx context.Context) { func (s *Server) afterSync(ctx context.Context) {
if err := s.music.Reset(ctx); err != nil { if err := s.music.EndAllQueues(ctx); err != nil {
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err)) logging.GetLogger().Error("Failed to end the queues after sync", zap.Error(err))
}
if err := s.music.ReloadSoundtracks(ctx); err != nil {
logging.GetLogger().Error("Failed to reload soundtracks after sync", zap.Error(err))
} }
if err := s.search.Reload(ctx); err != nil { if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err)) logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
} }
if s.covers != nil {
s.covers.Trigger()
}
} }
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server. // NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
@@ -108,11 +168,23 @@ 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))
s.uploads.RemoveOld()
if err := s.sync.UpdateLibraryMetrics(ctx); err != nil {
logger.Error("Failed to count the library", zap.Error(err))
}
return s return s
} }
// ListenAndServe starts the HTTP server. It returns nil after a graceful shutdown. // ListenAndServe starts the HTTP server and the metrics server. It returns nil after a
// graceful shutdown. The server keeps running without metrics if their port cannot be opened.
func (s *Server) ListenAndServe() error { func (s *Server) ListenAndServe() error {
if s.metrics != nil {
if err := s.metrics.Start(); err != nil {
logging.GetLogger().Error("Failed to start the metrics server", zap.Error(err))
}
}
s.startCoverSearch()
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr)) logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err return err
@@ -123,6 +195,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()
+1 -1
View File
@@ -19,7 +19,7 @@ func (in *limitInput) capped() int32 {
return int32(min(in.Limit, maxStatsLimit)) return int32(min(in.Limit, maxStatsLimit))
} }
func (s *Server) registerStatistics(a apis) { func (s *Server) registerStatistics(a huma.API) {
st := s.stats st := s.stats
op := func(id, path, summary, description string) route { op := func(id, path, summary, description string) route {
return route{op: huma.Operation{ return route{op: huma.Operation{
+4 -5
View File
@@ -11,8 +11,8 @@ import (
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
) )
func (s *Server) registerSync(a apis) { func (s *Server) registerSync(a huma.API) {
registry := a.v1.OpenAPI().Components.Schemas registry := a.OpenAPI().Components.Schemas
progress := huma.Operation{ progress := huma.Operation{
OperationID: "get-sync-progress", OperationID: "get-sync-progress",
Method: http.MethodGet, Method: http.MethodGet,
@@ -30,7 +30,7 @@ func (s *Server) registerSync(a apis) {
}, },
}, },
} }
register(s, a, route{op: progress, legacyPath: "/sync/progress"}, register(s, a, route{op: progress},
func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) {
if s.sync.IsSyncing() { if s.sync.IsSyncing() {
return ok[any](s.sync.Progress(), nil) return ok[any](s.sync.Progress(), nil)
@@ -49,7 +49,6 @@ func (s *Server) registerSync(a apis) {
Tags: []string{"sync"}, Tags: []string{"sync"},
Errors: []int{http.StatusLocked}, Errors: []int{http.StatusLocked},
}, },
legacyPath: path,
}, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { }, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
message := "Start syncing soundtracks" message := "Start syncing soundtracks"
if full { if full {
@@ -78,7 +77,7 @@ func (s *Server) registerSync(a apis) {
Tags: []string{"sync"}, Tags: []string{"sync"},
Hidden: true, Hidden: true,
} }
register(s, a, route{op: reset, legacyPath: "/sync/reset", locked: true}, register(s, a, route{op: reset, locked: true},
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
logging.GetLogger().Info("Resetting soundtracks database") logging.GetLogger().Info("Resetting soundtracks database")
return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx)) return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx))
+2 -2
View File
@@ -55,7 +55,7 @@ func (s *Server) passwordValid(password string) bool {
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1 return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1
} }
func (s *Server) registerToken(a apis) { func (s *Server) registerToken(a huma.API) {
create := huma.Operation{ create := huma.Operation{
OperationID: "create-token", OperationID: "create-token",
Method: http.MethodPost, Method: http.MethodPost,
@@ -65,7 +65,7 @@ func (s *Server) registerToken(a apis) {
"Requires the API password when the server has one. Limited to 10 requests per minute per client.", "Requires the API password when the server has one. Limited to 10 requests per minute per client.",
Tags: []string{"auth"}, Tags: []string{"auth"},
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests}, Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
Middlewares: huma.Middlewares{middleware.RateLimit(a.v1, 10, time.Minute)}, Middlewares: huma.Middlewares{middleware.RateLimit(a, 10, time.Minute)},
} }
register(s, a, route{op: create, public: true}, register(s, a, route{op: create, public: true},
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) { func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
+120
View File
@@ -0,0 +1,120 @@
package server
import (
"context"
"net/http"
"net/url"
"strings"
"music-server/internal/backend"
"github.com/danielgtaylor/huma/v2"
"github.com/google/uuid"
)
// UploadRequest is the body of POST /soundtracks/uploads.
type UploadRequest struct {
Name string `json:"name" minLength:"1" maxLength:"200" doc:"Name of the new soundtrack, also its folder name in the music folder"`
}
type startUploadInput struct {
Body UploadRequest
}
type uploadIDInput struct {
ID string `path:"id" format:"uuid" doc:"ID of the upload, from POST /soundtracks/uploads"`
}
func (in *uploadIDInput) id() uuid.UUID {
return uuid.MustParse(in.ID)
}
type uploadFileInput struct {
ID string `path:"id" format:"uuid" doc:"ID of the upload, from POST /soundtracks/uploads"`
File string `path:"file" doc:"File name of the song, e.g. \"01 Title.mp3\". Plain names only: put songs from disc folders in one folder, e.g. \"1-01 Title.mp3\"."`
RawBody []byte `contentType:"audio/mpeg" doc:"The MP3 file"`
}
type createdUpload struct {
Status int
Body backend.Upload
}
func (s *Server) registerUploads(a huma.API) {
u := s.uploads
op := func(id, method, path, summary, description string, errs ...int) route {
return route{
op: huma.Operation{
OperationID: id,
Method: method,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"uploads"},
Errors: errs,
},
// Uploads and syncs never run at the same time.
locked: true,
}
}
start := op("start-upload", http.MethodPost, "/soundtracks/uploads", "Start uploading a soundtrack",
"Starts adding a new soundtrack. Send its songs with PUT /soundtracks/uploads/{id}/files/{file}, then "+
"POST /soundtracks/uploads/{id}/finish. The name must be usable as a folder name, and no soundtrack, "+
"folder or other upload may have it, ignoring case. Unfinished uploads are removed after a day.",
http.StatusBadRequest, http.StatusConflict, http.StatusServiceUnavailable)
start.op.DefaultStatus = http.StatusCreated
register(s, a, start, func(ctx context.Context, in *startUploadInput) (*createdUpload, error) {
upload, err := u.Start(ctx, in.Body.Name)
if err != nil {
return nil, apiError(err)
}
return &createdUpload{Status: http.StatusCreated, Body: upload}, nil
})
register(s, a, op("get-upload", http.MethodGet, "/soundtracks/uploads/{id}", "Get an upload",
"Returns an unfinished upload with the songs received so far, for example to resume it.", http.StatusNotFound),
func(ctx context.Context, in *uploadIDInput) (*jsonBody[backend.Upload], error) {
return ok(u.Get(in.id()))
})
file := op("upload-song", http.MethodPut, "/soundtracks/uploads/{id}/files/{file}", "Upload a song",
"Stores one song of the upload, replacing a song of the same name, so a failed song can be sent again. "+
"Only MP3 files are accepted. The size limits are set on the server with UPLOAD_MAX_FILE_MB and UPLOAD_MAX_TOTAL_MB.",
http.StatusBadRequest, http.StatusNotFound, http.StatusRequestEntityTooLarge)
file.op.MaxBodyBytes = s.cfg.UploadMaxFileSize
register(s, a, file, func(ctx context.Context, in *uploadFileInput) (*jsonBody[backend.UploadFile], error) {
id, err := uuid.Parse(in.ID)
if err != nil {
return nil, huma.Error422UnprocessableEntity("invalid upload ID")
}
// With an encoded slash (%2F) in the path, Echo leaves the file name encoded.
if decoded, err := url.PathUnescape(in.File); err == nil && strings.ContainsAny(decoded, `/\`) {
return nil, huma.Error400BadRequest("plain file names only: put songs from disc folders in one folder, e.g. \"1-01 Title.mp3\"")
}
return ok(u.PutFile(id, in.File, in.RawBody))
})
register(s, a, op("finish-upload", http.MethodPost, "/soundtracks/uploads/{id}/finish", "Finish an upload",
"Moves the songs into a new folder in the music folder, adds the soundtrack to the library and returns it. "+
"Refused when a soundtrack with the same songs exists.",
http.StatusBadRequest, http.StatusNotFound, http.StatusConflict),
func(ctx context.Context, in *uploadIDInput) (*jsonBody[backend.SoundtrackWithSongList], error) {
soundtrack, err := u.Finish(ctx, in.id())
if err != nil {
return nil, apiError(err)
}
s.soundtracksChanged(ctx)
return ok(soundtrack, nil)
})
cancel := op("cancel-upload", http.MethodDelete, "/soundtracks/uploads/{id}", "Cancel an upload",
"Deletes an unfinished upload and its songs.", http.StatusNotFound)
cancel.op.DefaultStatus = http.StatusNoContent
register(s, a, cancel, func(ctx context.Context, in *uploadIDInput) (*struct{}, error) {
if err := u.Cancel(in.id()); err != nil {
return nil, apiError(err)
}
return nil, nil
})
}
+106
View File
@@ -0,0 +1,106 @@
package server
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
"music-server/internal/backend"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mp3Body(content string) []byte {
return append([]byte("ID3"), content...)
}
func (e *testEnv) startUpload(name string) backend.Upload {
e.t.Helper()
rec := e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"`+name+`"}`)
require.Equal(e.t, http.StatusCreated, rec.Code, rec.Body.String())
var upload backend.Upload
require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &upload))
return upload
}
func uploadPath(upload backend.Upload) string {
return "/api/v1/soundtracks/uploads/" + upload.ID.String()
}
func TestUploadSoundtrackEndpoints(t *testing.T) {
e := newTestEnv(t)
upload := e.startUpload("Chrono Trigger")
assert.Equal(t, "Chrono Trigger", upload.Name)
for _, song := range []string{"01 Wind Scene.mp3", "02 Corridors of Time.mp3"} {
rec := e.put(uploadPath(upload)+"/files/"+url.PathEscape(song), "audio/mpeg", mp3Body(song))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
}
rec := e.do(http.MethodGet, uploadPath(upload))
require.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), `"name":"02 Corridors of Time.mp3"`)
rec = e.do(http.MethodPost, uploadPath(upload)+"/finish")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var soundtrack backend.SoundtrackWithSongList
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtrack))
assert.Equal(t, "Chrono Trigger", soundtrack.Name)
assert.Len(t, soundtrack.Songs, 2)
// It is in the library, the soundtrack lists and the search right away.
assert.Contains(t, e.do(http.MethodGet, "/api/v1/soundtracks").Body.String(), `"name":"Chrono Trigger"`)
assert.Contains(t, e.do(http.MethodGet, "/api/v1/music/all/order").Body.String(), "Chrono Trigger")
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, uploadPath(upload)).Code)
}
func TestUploadEndpointErrors(t *testing.T) {
e := newTestEnv(t)
e.addSong("Kingdom Hearts", "Dearly Beloved", "x")
assert.Equal(t, http.StatusConflict, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"kingdom hearts"}`).Code)
assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"a/b"}`).Code)
assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":""}`).Code)
upload := e.startUpload("Game")
assert.Equal(t, http.StatusBadRequest, e.put(uploadPath(upload)+"/files/song.wav", "audio/wav", mp3Body("x")).Code)
assert.Equal(t, http.StatusBadRequest, e.put(uploadPath(upload)+"/files/song.mp3", "audio/mpeg", []byte("text")).Code)
assert.Equal(t, http.StatusRequestEntityTooLarge,
e.put(uploadPath(upload)+"/files/big.mp3", "audio/mpeg", mp3Body(strings.Repeat("x", 1<<20))).Code)
assert.Equal(t, http.StatusBadRequest, e.put(uploadPath(upload)+"/files/Disc%201%2F01.mp3", "audio/mpeg", mp3Body("x")).Code,
"an encoded / in the file name")
rec := e.put(uploadPath(upload)+"/files/Disc-1/01.mp3", "audio/mpeg", mp3Body("x"))
assert.Equal(t, http.StatusBadRequest, rec.Code, "a / in the file name")
assert.Contains(t, rec.Body.String(), "plain file names only")
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPost, uploadPath(upload)+"/finish").Code, "no songs yet")
missing := "/api/v1/soundtracks/uploads/00000000-0000-0000-0000-000000000000"
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, missing).Code)
assert.Equal(t, http.StatusNotFound, e.put(missing+"/files/a.mp3", "audio/mpeg", mp3Body("x")).Code)
assert.Equal(t, http.StatusNotFound, e.do(http.MethodPost, missing+"/finish").Code)
assert.Equal(t, http.StatusNotFound, e.do(http.MethodDelete, missing).Code)
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, uploadPath(upload)).Code)
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, uploadPath(upload)).Code)
}
func TestUploadEndpointsLockedDuringSync(t *testing.T) {
e := newTestEnv(t)
upload := e.startUpload("Game")
require.Equal(t, http.StatusOK, e.put(uploadPath(upload)+"/files/01.mp3", "audio/mpeg", mp3Body("x")).Code)
release := e.blockSync()
require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
assert.Equal(t, http.StatusLocked, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Other"}`).Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, uploadPath(upload)+"/finish").Code)
release()
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, uploadPath(upload)+"/finish").Code)
}
func TestUploadEndpointsRequireToken(t *testing.T) {
e := newTestEnv(t, withAuth("secret"))
assert.Equal(t, http.StatusUnauthorized, e.doBody(http.MethodPost, "/api/v1/soundtracks/uploads", `{"name":"Game"}`).Code)
}
+246
View File
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
) )
// Store is an in-memory implementation of the queries used by the application. // Store is an in-memory implementation of the queries used by the application.
@@ -25,6 +26,8 @@ type Store struct {
Soundtracks []repository.Soundtrack Soundtracks []repository.Soundtrack
Songs []repository.Song Songs []repository.Song
Sessions map[string]repository.Session Sessions map[string]repository.Session
Queues []repository.Queue
QueueSongs []repository.QueueSong
// Err, when set, is returned by every implemented query. // Err, when set, is returned by every implemented query.
Err error Err error
@@ -177,6 +180,17 @@ func (s *Store) AddSoundtrackPlayedByUUID(_ context.Context, id uuid.UUID) error
return s.Err return s.Err
} }
func (s *Store) GetSoundtrackById(_ context.Context, id int32) (repository.Soundtrack, error) {
defer s.lock()()
if s.Err != nil {
return repository.Soundtrack{}, s.Err
}
if i := s.soundtrackIndex(id); i >= 0 && s.Soundtracks[i].Deleted == nil {
return s.Soundtracks[i], nil
}
return repository.Soundtrack{}, pgx.ErrNoRows
}
func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository.Soundtrack, error) { func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository.Soundtrack, error) {
defer s.lock()() defer s.lock()()
if s.Err != nil { if s.Err != nil {
@@ -190,6 +204,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 {
@@ -373,6 +474,151 @@ func (s *Store) HasSession(token string) bool {
return ok return ok
} }
func (s *Store) queueIndex(id uuid.UUID) int {
return slices.IndexFunc(s.Queues, func(q repository.Queue) bool { return q.Uuid == id })
}
func (s *Store) updateQueue(id uuid.UUID, f func(*repository.Queue)) error {
defer s.lock()()
if s.Err != nil {
return s.Err
}
if i := s.queueIndex(id); i >= 0 {
f(&s.Queues[i])
}
return nil
}
func (s *Store) CreateQueue(context.Context) (repository.Queue, error) {
defer s.lock()()
if s.Err != nil {
return repository.Queue{}, s.Err
}
queue := repository.Queue{Uuid: uuid.New(), CurrentSong: -1, StartedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}}
s.Queues = append(s.Queues, queue)
return queue, nil
}
func (s *Store) GetQueue(_ context.Context, id uuid.UUID) (repository.Queue, error) {
defer s.lock()()
if s.Err != nil {
return repository.Queue{}, s.Err
}
if i := s.queueIndex(id); i >= 0 {
return s.Queues[i], nil
}
return repository.Queue{}, pgx.ErrNoRows
}
func endQueue(q *repository.Queue) {
if !q.EndedAt.Valid {
q.EndedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true}
}
}
func (s *Store) EndQueue(_ context.Context, id uuid.UUID) error {
return s.updateQueue(id, endQueue)
}
func (s *Store) EndActiveQueues(context.Context) error {
defer s.lock()()
for i := range s.Queues {
endQueue(&s.Queues[i])
}
return s.Err
}
func (s *Store) SetQueueLastFetched(_ context.Context, arg repository.SetQueueLastFetchedParams) error {
return s.updateQueue(arg.Uuid, func(q *repository.Queue) {
q.LastFetchedSongUuid, q.LastFetchedMode = arg.LastFetchedSongUuid, arg.LastFetchedMode
})
}
func (s *Store) SetQueueCurrentSong(_ context.Context, arg repository.SetQueueCurrentSongParams) error {
return s.updateQueue(arg.Uuid, func(q *repository.Queue) { q.CurrentSong = arg.CurrentSong })
}
func (s *Store) AddLastFetchedToQueue(_ context.Context, id uuid.UUID) error {
defer s.lock()()
if s.Err != nil {
return s.Err
}
i := s.queueIndex(id)
if i < 0 {
return nil
}
queue := &s.Queues[i]
songIndex := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == queue.LastFetchedSongUuid })
if songIndex < 0 {
return nil
}
song := s.Songs[songIndex]
var soundtrackName string
if st := s.soundtrackIndex(song.SoundtrackID); st >= 0 {
soundtrackName = s.Soundtracks[st].SoundtrackName
}
mode := "unknown"
if queue.LastFetchedMode != nil {
mode = *queue.LastFetchedMode
}
songNo := int32(0)
for _, qs := range s.QueueSongs {
if qs.QueueUuid == id {
songNo = max(songNo, qs.SongNo+1)
}
}
s.QueueSongs = append(s.QueueSongs, repository.QueueSong{
QueueUuid: id, SongNo: songNo, SongUuid: song.Uuid, SoundtrackName: soundtrackName, SongName: song.SongName,
Mode: mode,
})
queue.CurrentSong = songNo
queue.LastFetchedSongUuid, queue.LastFetchedMode = uuid.Nil, nil
return nil
}
func (s *Store) ListQueueSongs(_ context.Context, id uuid.UUID) ([]repository.ListQueueSongsRow, error) {
defer s.lock()()
if s.Err != nil {
return nil, s.Err
}
var rows []repository.ListQueueSongsRow
for _, qs := range s.QueueSongs {
if qs.QueueUuid != id {
continue
}
row := repository.ListQueueSongsRow{
SongNo: qs.SongNo, SongUuid: qs.SongUuid, SoundtrackName: qs.SoundtrackName, SongName: qs.SongName, Mode: qs.Mode,
}
if i := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == qs.SongUuid }); i >= 0 {
song := s.Songs[i]
row.Path, row.TimesPlayed, row.SoundtrackUuid = &song.Path, &song.TimesPlayed, song.SoundtrackUuid
}
rows = append(rows, row)
}
slices.SortFunc(rows, func(a, b repository.ListQueueSongsRow) int { return cmp.Compare(a.SongNo, b.SongNo) })
return rows, nil
}
func (s *Store) CountLibrary(context.Context) (repository.CountLibraryRow, error) {
defer s.lock()()
if s.Err != nil {
return repository.CountLibraryRow{}, s.Err
}
var count repository.CountLibraryRow
for _, st := range s.Soundtracks {
if st.Deleted != nil {
continue
}
count.Soundtracks++
for _, song := range s.Songs {
if song.SoundtrackID == st.ID {
count.Songs++
}
}
}
return count, nil
}
// Statistics queries return empty results; tests that need data use their own fakes. // 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: