mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-05 09:48:37 +01:00
Merge branch 'master' into lyrics-sidebar
This commit is contained in:
commit
0e3234f09f
217 changed files with 11770 additions and 2485 deletions
5
.github/actions/prepare-docker/action.yml
vendored
5
.github/actions/prepare-docker/action.yml
vendored
|
|
@ -68,6 +68,11 @@ runs:
|
|||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["mirror.gcr.io"]
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
id: meta
|
||||
|
|
|
|||
8
.github/workflows/pipeline.yml
vendored
8
.github/workflows/pipeline.yml
vendored
|
|
@ -68,10 +68,16 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Keep CI on the same version `make lint` installs, so a clean local run
|
||||
# cannot turn red in CI just because a new golangci-lint was released.
|
||||
- name: Resolve golangci-lint version
|
||||
id: golangci-version
|
||||
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
version: ${{ steps.golangci-version.outputs.version }}
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ linters:
|
|||
disable:
|
||||
- staticcheck
|
||||
settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*github.com/zeebo/xxh3.Hasher).Write
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
|||
|
||||
########################################################################################################################
|
||||
### Build xx (original image: tonistiigi/xx)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build
|
||||
|
||||
# v1.9.0
|
||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||
|
|
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
|||
|
||||
########################################################################################################################
|
||||
### Build Navidrome UI
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
||||
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
|
||||
WORKDIR /app
|
||||
|
||||
# Install node dependencies
|
||||
|
|
@ -43,7 +43,7 @@ COPY --from=ui /build /build
|
|||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine
|
||||
COPY --from=xx / /
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
|
@ -85,7 +85,7 @@ EOT
|
|||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
|
|
@ -154,7 +154,7 @@ COPY --from=build /out /
|
|||
|
||||
########################################################################################################################
|
||||
### Build Final Image
|
||||
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||
FROM alpine:3.20 AS final
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
|
||||
|
|
|
|||
1064
cmd/artwork.go
Normal file
1064
cmd/artwork.go
Normal file
File diff suppressed because it is too large
Load diff
1212
cmd/artwork_test.go
Normal file
1212
cmd/artwork_test.go
Normal file
File diff suppressed because it is too large
Load diff
17
cmd/pls.go
17
cmd/pls.go
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
|
@ -141,14 +142,16 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod
|
|||
func runExporter(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
pls := playlist.ToM3U8()
|
||||
if outputFile == "-" || outputFile == "" {
|
||||
println(pls)
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
}
|
||||
|
||||
func writePlaylist(m3u string, out io.Writer, file string) {
|
||||
if file == "" || file == "-" {
|
||||
fmt.Fprint(out, m3u)
|
||||
return
|
||||
}
|
||||
err := os.WriteFile(outputFile, []byte(pls), 0600)
|
||||
if err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +160,7 @@ func runExport(ctx context.Context) {
|
|||
|
||||
if playlistID != "" && outputFile == "" {
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
println(playlist.ToM3U8())
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
35
cmd/pls_test.go
Normal file
35
cmd/pls_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("writePlaylist", func() {
|
||||
const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n"
|
||||
plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid()))
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(func() { _ = os.Remove(plsFile) })
|
||||
})
|
||||
|
||||
DescribeTable("writes the playlist to exactly one destination",
|
||||
func(file, wantStream, wantFile string) {
|
||||
var out strings.Builder
|
||||
|
||||
writePlaylist(m3u, &out, file)
|
||||
|
||||
written, _ := os.ReadFile(plsFile)
|
||||
Expect(out.String()).To(Equal(wantStream))
|
||||
Expect(string(written)).To(Equal(wantFile))
|
||||
},
|
||||
Entry("no file name writes to the stream", "", m3u, ""),
|
||||
Entry("a dash writes to the stream", "-", m3u, ""),
|
||||
Entry("a path writes to the file", plsFile, "", m3u),
|
||||
)
|
||||
})
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -314,7 +313,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
|||
return sb.String(), w.Error()
|
||||
case "table":
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
|
||||
w := newTabWriter(&sb)
|
||||
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
|
|
@ -13,6 +15,11 @@ import (
|
|||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
// newTabWriter keeps every CLI table on the same column settings.
|
||||
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
||||
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
}
|
||||
|
||||
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||
sqlDB := db.Db()
|
||||
ds := persistence.New(sqlDB)
|
||||
|
|
|
|||
|
|
@ -223,6 +223,18 @@ func CreateArtworkWorker() *artwork.Worker {
|
|||
return worker
|
||||
}
|
||||
|
||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
fFmpeg := ffmpeg.New()
|
||||
tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
|
||||
return tracingResolver
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
|
|
|
|||
|
|
@ -144,6 +144,13 @@ func CreateArtworkWorker() *artwork.Worker {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
artwork.NewTracingResolver,
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
|
||||
It("calls the placeholder GetArtistImages", func() {
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}})
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}})
|
||||
songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}}))
|
||||
Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}}))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ func (s Song) Equals(other Song) bool {
|
|||
}
|
||||
|
||||
var (
|
||||
// ErrNotFound means the provider answered and had nothing. Return the underlying error
|
||||
// for a fault instead, or callers that back off on faults will treat it as definitive.
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const LocalAgentName = "local"
|
||||
|
|
@ -37,14 +39,51 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []Song
|
||||
for _, s := range top {
|
||||
result = append(result, Song{
|
||||
Name: s.Title,
|
||||
MBID: s.MbzReleaseTrackID,
|
||||
})
|
||||
return songsFrom(top), nil
|
||||
}
|
||||
|
||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
seed, err := p.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
// Tag ids derive from (name, value), so the seed's genre ids need no extra query.
|
||||
genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID })
|
||||
if len(genreIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
persistence.SongGenres.ByID(genreIDs),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Max: count + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make(model.MediaFiles, 0, len(candidates))
|
||||
for _, s := range candidates {
|
||||
if s.ID == id {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
if len(filtered) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return songsFrom(filtered), nil
|
||||
}
|
||||
|
||||
func songsFrom(mfs model.MediaFiles) []Song {
|
||||
if len(mfs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return slice.Map(mfs, func(mf model.MediaFile) Song {
|
||||
return Song{ID: mf.ID, Name: mf.Title}
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
96
core/agents/local_agent_test.go
Normal file
96
core/agents/local_agent_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("localAgent GetSimilarSongsByTrack", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *tests.MockMediaFileRepo
|
||||
var agent *localAgent
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
mfRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
||||
agent = &localAgent{ds: ds}
|
||||
})
|
||||
|
||||
It("excludes the seed track from its own similars", func() {
|
||||
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed.
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
names := slice.Map(songs, func(s Song) string { return s.Name })
|
||||
Expect(names).ToNot(ContainElement("Seed"))
|
||||
})
|
||||
|
||||
// The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec
|
||||
// would pass just as well with no genre filter at all.
|
||||
It("queries the indexed genre join for the seed's own genres, skipping missing files", func() {
|
||||
rock := model.NewTag(model.TagGenre, "Rock")
|
||||
seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
_, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, sqlErr := mfRepo.Options.Filters.ToSql()
|
||||
Expect(sqlErr).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan")
|
||||
Expect(sql).To(ContainSubstring("missing"))
|
||||
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
|
||||
Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id")
|
||||
Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID))
|
||||
})
|
||||
|
||||
It("returns the library id so the matcher can resolve the song", func() {
|
||||
seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// Without the id the matcher falls through to its MBID/title phases and resolves nothing,
|
||||
// so the local fallback silently returns an empty mix.
|
||||
related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"}))
|
||||
})
|
||||
|
||||
It("asks for one extra candidate so dropping the seed still fills the count", func() {
|
||||
// The mock returns rows sorted by id, so the seed comes first and would consume the only
|
||||
// slot if the query did not over-fetch.
|
||||
seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].Name).To(Equal("Related"))
|
||||
})
|
||||
|
||||
It("returns nil when the seed track has no genres", func() {
|
||||
seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
// Without the early return an empty tag filter would scan the whole library.
|
||||
Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all")
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
|
@ -40,7 +41,13 @@ func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitra
|
|||
}
|
||||
|
||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id})
|
||||
// Match by album-artist participation, not the deprecated album_artist_id
|
||||
// column (first album artist only), so co-album-artists are included too.
|
||||
filter := squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
|
||||
}
|
||||
|
||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
|
@ -69,8 +70,11 @@ var _ = Describe("Archiver", func() {
|
|||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_artist_id": "1"},
|
||||
Sort: "album",
|
||||
Filters: squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
|
|
|
|||
|
|
@ -47,11 +47,18 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
|
|||
// Synthetic artists would otherwise get an unrelated agent result assigned to them.
|
||||
switch ar.ID {
|
||||
case consts.UnknownArtistID, consts.VariousArtistsID:
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"})
|
||||
return nil, "", false
|
||||
}
|
||||
name := externalName(ar.Name)
|
||||
for _, a := range ag.ArtistImageAgents() {
|
||||
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imageAgents := ag.ArtistImageAgents()
|
||||
if len(imageAgents) == 0 {
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides artist images"})
|
||||
return nil, "", false
|
||||
}
|
||||
for _, a := range imageAgents {
|
||||
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
|
@ -62,6 +69,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
|
|||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
recordAgent(ctx, a.Name, reader, path, err)
|
||||
if reader != nil {
|
||||
return reader, a.Name, false
|
||||
}
|
||||
|
|
@ -76,8 +84,14 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
|
|||
// fetchAlbumImage is the album counterpart of fetchArtistImage.
|
||||
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) {
|
||||
name, artist := externalName(al.Name), externalName(al.AlbumArtist)
|
||||
for _, a := range ag.AlbumImageAgents() {
|
||||
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imageAgents := ag.AlbumImageAgents()
|
||||
if len(imageAgents) == 0 {
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides album images"})
|
||||
return nil, "", false
|
||||
}
|
||||
for _, a := range imageAgents {
|
||||
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
|
@ -88,6 +102,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m
|
|||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
recordAgent(ctx, a.Name, reader, path, err)
|
||||
if reader != nil {
|
||||
return reader, a.Name, false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,30 @@ var _ = Describe("agent images", func() {
|
|||
Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
|
||||
})
|
||||
|
||||
It("records a skipped external candidate when no agent provides artist images", func() {
|
||||
ag := imageAgents()
|
||||
t := &ChainTrace{}
|
||||
|
||||
r, _, extErr := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(extErr).To(BeFalse())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides artist images"}}),
|
||||
"a configured external token must never be silently absent from the chain")
|
||||
})
|
||||
|
||||
It("records a skipped external candidate for synthetic artists", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
t := &ChainTrace{}
|
||||
|
||||
_, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate,
|
||||
model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"})
|
||||
Expect(t.Steps()).To(HaveLen(1))
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped))
|
||||
Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic"))
|
||||
})
|
||||
|
||||
It("clears typographic characters from the query name unless preserving unicode", func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
a := &fakeImageAgent{name: "agentA"}
|
||||
|
|
@ -231,6 +255,18 @@ var _ = Describe("agent images", func() {
|
|||
Expect(a.albumCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("records a skipped external candidate when no agent provides album images", func() {
|
||||
ag := imageAgents()
|
||||
t := &ChainTrace{}
|
||||
|
||||
r, _, extErr := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(extErr).To(BeFalse())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides album images"}}),
|
||||
"a configured external token must never be silently absent from the chain")
|
||||
})
|
||||
|
||||
It("reports extErr when the only agent fails transiently", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
|
||||
ag := imageAgents(a)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -118,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
|
|||
}
|
||||
|
||||
// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
|
||||
// hammer external services; below staleAbsentAge to catch younger absences.
|
||||
// hammer external services; below StaleAbsentAge to catch younger absences.
|
||||
const requestRecheckAge = time.Hour
|
||||
|
||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
|
|
@ -317,16 +318,15 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
|
|||
return nil, err
|
||||
}
|
||||
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
|
||||
selectImage := func() (io.ReadCloser, string, error) {
|
||||
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
|
||||
return selectImageReader(ctx, artID, funcs...)
|
||||
selectImage := func() (io.ReadCloser, error) {
|
||||
res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{})
|
||||
return res.reader, err
|
||||
}
|
||||
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
|
||||
// Disc art has no state row, hence no content hash: keying on id, album mtime and
|
||||
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
|
||||
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
|
||||
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square,
|
||||
func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err })
|
||||
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, err
|
||||
|
|
@ -386,6 +386,53 @@ func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkI
|
|||
return model.ArtworkID{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records
|
||||
// the walk and reports the winning source, without ever writing artwork state.
|
||||
type TracingResolver struct {
|
||||
inner *resolver
|
||||
trace *ChainTrace
|
||||
}
|
||||
|
||||
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live
|
||||
// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider;
|
||||
// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed.
|
||||
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
|
||||
inner := newLocalResolver(ds, ffm)
|
||||
if live {
|
||||
inner = newResolver(ds, ag, ffm, passthroughGate)
|
||||
}
|
||||
return &TracingResolver{inner: inner, trace: t}
|
||||
}
|
||||
|
||||
// Resolve walks kind's sources for id, recording the walk, and reports the winning source
|
||||
// ("" when none produced an image).
|
||||
func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) {
|
||||
switch kind {
|
||||
case model.KindArtistArtwork:
|
||||
return r.explain(ctx, r.inner.resolveArtist, id)
|
||||
case model.KindAlbumArtwork:
|
||||
return r.explain(ctx, r.inner.resolveAlbum, id)
|
||||
case model.KindDiscArtwork:
|
||||
return r.explain(ctx, r.inner.resolveDisc, id)
|
||||
case model.KindMediaFileArtwork:
|
||||
return r.explain(ctx, r.inner.resolveMediaFile, id)
|
||||
}
|
||||
return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind)
|
||||
}
|
||||
|
||||
// explain discards the bytes: nothing downstream persists this resolution, so nothing else
|
||||
// would close the reader either.
|
||||
func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) {
|
||||
res, err := resolve(withTrace(ctx, r.trace), id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.reader != nil {
|
||||
_ = res.reader.Close()
|
||||
}
|
||||
return res.source, nil
|
||||
}
|
||||
|
||||
func unixMtime(mtime int64) time.Time {
|
||||
if mtime <= 0 {
|
||||
return time.Time{}
|
||||
|
|
|
|||
|
|
@ -32,35 +32,32 @@ func Encode(img image.Image) (string, error) {
|
|||
}
|
||||
// Pre-downscale: its rounding can flip a component count, and the hash is a client cache key.
|
||||
xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy())
|
||||
return encodeAt(img, xComp, yComp), nil
|
||||
}
|
||||
|
||||
// encodeAt encodes img at the given component counts (each 2..9). Callers guarantee non-empty
|
||||
// bounds, and a source far larger than the counts: too few samples overshoot the AC terms.
|
||||
func encodeAt(img image.Image, xComp, yComp int) string {
|
||||
src := pixelsOf(downscale(img))
|
||||
return encodePixels(src, xComp, yComp, cosBasis(xComp, src.w), cosBasis(yComp, src.h))
|
||||
}
|
||||
w, h := src.w, src.h
|
||||
|
||||
// cosBasis is the cosine basis for comp components sampled across n pixels. It depends only
|
||||
// on its arguments, so a caller with a fixed shape can build it once and reuse it.
|
||||
func cosBasis(comp, n int) [][]float64 {
|
||||
basis := make([][]float64, comp)
|
||||
for i := range basis {
|
||||
basis[i] = make([]float64, n)
|
||||
for x := range basis[i] {
|
||||
basis[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(n))
|
||||
cosX := make([][]float64, xComp)
|
||||
for i := range cosX {
|
||||
cosX[i] = make([]float64, w)
|
||||
for x := range cosX[i] {
|
||||
cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
|
||||
}
|
||||
}
|
||||
cosY := make([][]float64, yComp)
|
||||
for j := range cosY {
|
||||
cosY[j] = make([]float64, h)
|
||||
for y := range cosY[j] {
|
||||
cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
|
||||
}
|
||||
}
|
||||
return basis
|
||||
}
|
||||
|
||||
// encodePixels is the encoder proper; cosX and cosY must match src's dimensions.
|
||||
func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string {
|
||||
w, h := src.w, src.h
|
||||
|
||||
lin := srgbToLinearTable()
|
||||
factors := make([][3]float64, xComp*yComp)
|
||||
linR := make([]float64, w)
|
||||
linG := make([]float64, w)
|
||||
linB := make([]float64, w)
|
||||
rowR := make([]float64, xComp)
|
||||
rowG := make([]float64, xComp)
|
||||
rowB := make([]float64, xComp)
|
||||
for y := range h {
|
||||
row := src.pix[y*src.stride:]
|
||||
for x := range w {
|
||||
|
|
@ -69,15 +66,26 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string {
|
|||
if src.straight {
|
||||
r, g, b = premultiply(r, g, b, row[p+3])
|
||||
}
|
||||
lr, lg, lb := lin[r], lin[g], lin[b]
|
||||
for j := range yComp {
|
||||
for i := range xComp {
|
||||
basis := cosX[i][x] * cosY[j][y]
|
||||
f := &factors[j*xComp+i]
|
||||
f[0] += basis * lr
|
||||
f[1] += basis * lg
|
||||
f[2] += basis * lb
|
||||
}
|
||||
linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b]
|
||||
}
|
||||
// The basis is separable, so a row costs xComp dot products plus one fold over yComp,
|
||||
// rather than xComp*yComp multiply-accumulates per pixel.
|
||||
for i := range xComp {
|
||||
var sr, sg, sb float64
|
||||
for x, c := range cosX[i] {
|
||||
sr += c * linR[x]
|
||||
sg += c * linG[x]
|
||||
sb += c * linB[x]
|
||||
}
|
||||
rowR[i], rowG[i], rowB[i] = sr, sg, sb
|
||||
}
|
||||
for j := range yComp {
|
||||
cy := cosY[j][y]
|
||||
for i := range xComp {
|
||||
f := &factors[j*xComp+i]
|
||||
f[0] += cy * rowR[i]
|
||||
f[1] += cy * rowG[i]
|
||||
f[2] += cy * rowB[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -95,7 +103,7 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string {
|
|||
var sb strings.Builder
|
||||
sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1))
|
||||
|
||||
// Every caller passes at least 2 components, so there is always at least one AC factor.
|
||||
// Derived counts are at least 1x9, so there is always at least one AC factor.
|
||||
ac := factors[1:]
|
||||
actualMax := 0.0
|
||||
for _, f := range ac {
|
||||
|
|
@ -110,7 +118,7 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string {
|
|||
for _, f := range ac {
|
||||
sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
|
||||
}
|
||||
return sb.String()
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At.
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
package blurhash
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
// A real hash never has 3x3 components: components() always targets ~16 tiles.
|
||||
const synthComponents = 3
|
||||
|
||||
// Encoding a source no bigger than the component grid overshoots the AC coefficients,
|
||||
// clamping the corners to black; the encoder assumes many samples per component.
|
||||
const synthSourceSize = 8
|
||||
|
||||
type colorGrid = [synthComponents * synthComponents][3]float64
|
||||
|
||||
// Synthetic returns a blurhash unique to seed, for artwork whose real hash does not exist
|
||||
// yet. baseColor ("#rrggbb") sets the hue family; "" derives it from the seed.
|
||||
func Synthetic(seed, baseColor string) string {
|
||||
hue, sat, light := baseTone(baseColor, xxh3.HashStringSeed(seed, 0))
|
||||
|
||||
var grid colorGrid
|
||||
for i := range grid {
|
||||
// Each cell hashes the seed separately, so one tint shared by many items still
|
||||
// yields one value per item.
|
||||
bits := xxh3.HashStringSeed(seed, uint64(i)+1)
|
||||
dh := (byteFrac(bits, 0) - 0.5) * 60
|
||||
ds := (byteFrac(bits, 8) - 0.5) * 0.16
|
||||
dl := (byteFrac(bits, 16) - 0.5) * 0.30
|
||||
r, g, b := hslToRGB(hue+dh, clamp01(sat+ds), clamp01(light+dl))
|
||||
grid[i] = [3]float64{float64(r), float64(g), float64(b)}
|
||||
}
|
||||
// The shape is fixed, so the cosine basis is reused instead of rebuilt per call.
|
||||
basis := synthBasis()
|
||||
return encodePixels(pixelsOf(upscale(&grid)), synthComponents, synthComponents, basis, basis)
|
||||
}
|
||||
|
||||
// synthBasis is the cosine basis for the fixed synthetic shape. Square, so one serves both axes.
|
||||
var synthBasis = sync.OnceValue(func() [][]float64 {
|
||||
return cosBasis(synthComponents, synthSourceSize)
|
||||
})
|
||||
|
||||
// upscale renders the cell grid bilinearly at synthSourceSize. Hand-rolled because
|
||||
// x/image's scaler allocates per call, and this runs once per mapped item.
|
||||
func upscale(grid *colorGrid) *image.NRGBA {
|
||||
const n, size = synthComponents, synthSourceSize
|
||||
axis := axisWeights()
|
||||
|
||||
// Separable: each grid row is stretched horizontally once, then rows blend vertically.
|
||||
// Doing it per pixel instead would repeat every horizontal lerp `size` times.
|
||||
var rows [n][size][3]float64
|
||||
for r := range n {
|
||||
for x, a := range axis {
|
||||
for c := range 3 {
|
||||
rows[r][x][c] = grid[r*n+a.cell][c]*(1-a.frac) + grid[r*n+a.cell+1][c]*a.frac
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
for y, a := range axis {
|
||||
top, bot := &rows[a.cell], &rows[a.cell+1]
|
||||
for x := range size {
|
||||
p := img.Pix[y*img.Stride+x*4:]
|
||||
for c := range 3 {
|
||||
p[c] = uint8(top[x][c]*(1-a.frac) + bot[x][c]*a.frac + 0.5)
|
||||
}
|
||||
p[3] = 255
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// axisWeights maps each source pixel to its lower cell and the fraction toward the next.
|
||||
// Both axes are square and constant-sized, so one table serves both and outlives the call.
|
||||
var axisWeights = sync.OnceValue(func() *[synthSourceSize]struct {
|
||||
cell int
|
||||
frac float64
|
||||
} {
|
||||
var t [synthSourceSize]struct {
|
||||
cell int
|
||||
frac float64
|
||||
}
|
||||
for i := range t {
|
||||
// Edge pixels fall outside the cell centres, so both ends clamp rather than extrapolate.
|
||||
f := min(max((float64(i)+0.5)*synthComponents/synthSourceSize-0.5, 0), synthComponents-1)
|
||||
t[i].cell = min(int(f), synthComponents-2)
|
||||
t[i].frac = f - float64(t[i].cell)
|
||||
}
|
||||
return &t
|
||||
})
|
||||
|
||||
func byteFrac(bits uint64, shift int) float64 {
|
||||
return float64(bits>>shift&0xFF) / 255
|
||||
}
|
||||
|
||||
// baseTone clamps saturation and lightness away from the extremes, so a placeholder
|
||||
// never reads as neon or as solid black.
|
||||
func baseTone(baseColor string, hueBits uint64) (hue, sat, light float64) {
|
||||
if r, g, b, ok := parseHex(baseColor); ok {
|
||||
h, s, l := rgbToHSL(r, g, b)
|
||||
return h, min(max(s, 0.10), 0.35), min(max(l, 0.15), 0.75)
|
||||
}
|
||||
return float64(hueBits&0x1FF) / 512 * 360, 0.22, 0.40
|
||||
}
|
||||
|
||||
func parseHex(s string) (r, g, b uint8, ok bool) {
|
||||
if len(s) != 7 || s[0] != '#' {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
v, err := strconv.ParseUint(s[1:], 16, 32)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return uint8(v >> 16), uint8(v >> 8), uint8(v), true
|
||||
}
|
||||
|
||||
func rgbToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
rf, gf, bf := float64(r)/255, float64(g)/255, float64(b)/255
|
||||
mx, mn := max(rf, gf, bf), min(rf, gf, bf)
|
||||
l = (mx + mn) / 2
|
||||
if mx == mn {
|
||||
return 0, 0, l
|
||||
}
|
||||
d := mx - mn
|
||||
s = d / (1 - math.Abs(2*l-1))
|
||||
switch mx {
|
||||
case rf:
|
||||
h = math.Mod((gf-bf)/d, 6)
|
||||
case gf:
|
||||
h = (bf-rf)/d + 2
|
||||
default:
|
||||
h = (rf-gf)/d + 4
|
||||
}
|
||||
h *= 60
|
||||
if h < 0 {
|
||||
h += 360
|
||||
}
|
||||
return h, s, l
|
||||
}
|
||||
|
||||
func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
|
||||
h = math.Mod(math.Mod(h, 360)+360, 360)
|
||||
c := (1 - math.Abs(2*l-1)) * s
|
||||
hp := h / 60
|
||||
x := c * (1 - math.Abs(math.Mod(hp, 2)-1))
|
||||
var r, g, b float64
|
||||
switch int(hp) {
|
||||
case 0:
|
||||
r, g, b = c, x, 0
|
||||
case 1:
|
||||
r, g, b = x, c, 0
|
||||
case 2:
|
||||
r, g, b = 0, c, x
|
||||
case 3:
|
||||
r, g, b = 0, x, c
|
||||
case 4:
|
||||
r, g, b = x, 0, c
|
||||
default:
|
||||
r, g, b = c, 0, x
|
||||
}
|
||||
m := l - c/2
|
||||
return to8(r + m), to8(g + m), to8(b + m)
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 { return min(max(v, 0), 1) }
|
||||
|
||||
func to8(v float64) uint8 { return uint8(math.Round(clamp01(v) * 255)) }
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
package blurhash_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
"github.com/zeebo/xxh3"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Synthetic", func() {
|
||||
It("returns a well-formed 3x3 blurhash", func() {
|
||||
h := blurhash.Synthetic("alb-1", "")
|
||||
// 3x3 encodes as (3-1)+(3-1)*9 = 20, which is base83 'K'; length is 1+1+4+8*2.
|
||||
Expect(h).To(HaveLen(22))
|
||||
Expect(h).To(HavePrefix("K"))
|
||||
for _, c := range h {
|
||||
Expect(strings.ContainsRune(alphabet, c)).To(BeTrue(), "unexpected char %q", c)
|
||||
}
|
||||
})
|
||||
|
||||
It("keeps its prefix exclusive to real encodes, across aspect ratios", func() {
|
||||
Expect(blurhash.Synthetic("alb-1", "")).To(HavePrefix("K"))
|
||||
|
||||
ratios := []struct{ w, h int }{
|
||||
{10, 200}, {200, 10}, {1, 50}, {50, 1}, {64, 64}, {100, 300}, {300, 100},
|
||||
}
|
||||
for _, r := range ratios {
|
||||
encoded, err := blurhash.Encode(gradientImage(r.w, r.h))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(encoded).ToNot(HavePrefix("K"), "real encode at %dx%d produced the synthetic prefix", r.w, r.h)
|
||||
}
|
||||
})
|
||||
|
||||
It("is deterministic for one seed", func() {
|
||||
Expect(blurhash.Synthetic("alb-1", "")).To(Equal(blurhash.Synthetic("alb-1", "")))
|
||||
})
|
||||
|
||||
It("differs across seeds", func() {
|
||||
Expect(blurhash.Synthetic("alb-1", "")).ToNot(Equal(blurhash.Synthetic("alb-2", "")))
|
||||
})
|
||||
|
||||
dcOf := func(hash string) (int, int, int) {
|
||||
dc := decode83(hash[2:6])
|
||||
return dc >> 16 & 0xFF, dc >> 8 & 0xFF, dc & 0xFF
|
||||
}
|
||||
|
||||
It("decodes to a muted DC colour", func() {
|
||||
r, g, b := dcOf(blurhash.Synthetic("alb-1", ""))
|
||||
spread := max(r, g, b) - min(r, g, b)
|
||||
Expect(spread).To(BeNumerically("<", 120), "saturation is capped, so no channel should run away")
|
||||
Expect(max(r, g, b)).To(BeNumerically("<", 240), "lightness is capped below white")
|
||||
Expect(min(r, g, b)).To(BeNumerically(">", 10), "lightness is capped above black")
|
||||
})
|
||||
|
||||
It("keeps 1,000,000 seeds effectively distinct", func() {
|
||||
const count = 1_000_000
|
||||
seen := make(map[uint64]struct{}, count)
|
||||
for i := range count {
|
||||
seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("item-%d", i), ""))] = struct{}{}
|
||||
}
|
||||
Expect(len(seen)).To(BeNumerically(">=", 999_900))
|
||||
})
|
||||
|
||||
It("leans the DC towards a red tint", func() {
|
||||
r, g, b := dcOf(blurhash.Synthetic("alb-1", "#c04040"))
|
||||
Expect(r).To(BeNumerically(">", g))
|
||||
Expect(r).To(BeNumerically(">", b))
|
||||
})
|
||||
|
||||
It("leans the DC towards a blue tint", func() {
|
||||
r, g, b := dcOf(blurhash.Synthetic("alb-1", "#4040c0"))
|
||||
Expect(b).To(BeNumerically(">", r))
|
||||
Expect(b).To(BeNumerically(">", g))
|
||||
})
|
||||
|
||||
It("changes the value when only the tint changes", func() {
|
||||
Expect(blurhash.Synthetic("alb-1", "#c04040")).ToNot(Equal(blurhash.Synthetic("alb-1", "#4040c0")))
|
||||
})
|
||||
|
||||
It("falls back to the seed hue for an unparseable tint", func() {
|
||||
Expect(blurhash.Synthetic("alb-1", "not-a-colour")).To(Equal(blurhash.Synthetic("alb-1", "")))
|
||||
})
|
||||
|
||||
It("tracks a dark tint's lightness", func() {
|
||||
dark, _, _ := dcOf(blurhash.Synthetic("alb-1", "#101820"))
|
||||
light, _, _ := dcOf(blurhash.Synthetic("alb-1", "#e8f0f8"))
|
||||
Expect(dark).To(BeNumerically("<", light))
|
||||
})
|
||||
|
||||
// A shared tint must not become a shared value: the seed alone carries uniqueness.
|
||||
It("keeps 1,000,000 seeds distinct under a single fixed tint", func() {
|
||||
const count = 1_000_000
|
||||
seen := make(map[uint64]struct{}, count)
|
||||
for i := range count {
|
||||
seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("track-%d", i), "#3a5f7d"))] = struct{}{}
|
||||
}
|
||||
Expect(len(seen)).To(BeNumerically(">=", 999_900))
|
||||
})
|
||||
})
|
||||
|
|
@ -113,27 +113,71 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at
|
||||
// all, so a chain walk can say why instead of leaving a configured entry unaccounted for.
|
||||
type discCandidate struct {
|
||||
pattern string
|
||||
resolve func() (resolution, bool)
|
||||
skip string
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate {
|
||||
folder := func(sf sourceFunc) func() (resolution, bool) {
|
||||
return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) }
|
||||
}
|
||||
var cc []discCandidate
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
c := discCandidate{pattern: pattern}
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
ff = append(ff,
|
||||
fromTag(ctx, d.lib.FS, d.firstTrackRel),
|
||||
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
|
||||
)
|
||||
case pattern == "external":
|
||||
// Not supported for disc art, silently ignore
|
||||
case pattern == "discsubtitle":
|
||||
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
|
||||
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
|
||||
c.resolve = func() (resolution, bool) {
|
||||
return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel)
|
||||
}
|
||||
case len(d.imgFiles) > 0:
|
||||
ff = append(ff, d.fromExternalFile(ctx, pattern))
|
||||
case pattern == externalCandidate:
|
||||
c.skip = "external sources are not supported for disc artwork"
|
||||
case pattern == "discsubtitle":
|
||||
subtitle := strings.TrimSpace(d.album.Discs[d.discNumber])
|
||||
if subtitle == "" {
|
||||
c.skip = "disc has no subtitle"
|
||||
} else {
|
||||
c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle))
|
||||
}
|
||||
case len(d.imgFiles) == 0:
|
||||
c.skip = "no images in album folder"
|
||||
default:
|
||||
c.resolve = folder(d.fromExternalFile(ctx, pattern))
|
||||
}
|
||||
cc = append(cc, c)
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// selectImage walks the DiscArtPriority entries and returns the first that yields an image.
|
||||
// chain records the walk; the serving path passes an untraced one and pays nothing for it.
|
||||
func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string,
|
||||
chain *chainState) (resolution, error) {
|
||||
for _, c := range d.discCandidates(ctx, ffmpeg, priority) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
if c.skip != "" {
|
||||
chain.record(c.pattern, OutcomeSkipped, c.skip)
|
||||
continue
|
||||
}
|
||||
start := time.Now()
|
||||
res, ok := c.resolve()
|
||||
log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID,
|
||||
"disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath,
|
||||
"elapsed", time.Since(start))
|
||||
if res, ok = chain.try(c.pattern, res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return ff
|
||||
return chain.exhausted(), nil
|
||||
}
|
||||
|
||||
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -179,19 +181,19 @@ var _ = Describe("Disc Artwork Reader", func() {
|
|||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
|
||||
Expect(ff).To(HaveLen(2))
|
||||
r, path, err := ff[0]()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(f2))
|
||||
r.Close()
|
||||
cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*")
|
||||
Expect(cc).To(HaveLen(2))
|
||||
res, ok := cc[0].resolve()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2)))
|
||||
res.reader.Close()
|
||||
|
||||
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
|
||||
Expect(ff).To(HaveLen(2))
|
||||
r, path, err = ff[0]()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(f1))
|
||||
r.Close()
|
||||
cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*")
|
||||
Expect(cc).To(HaveLen(2))
|
||||
res, ok = cc[0].resolve()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1)))
|
||||
res.reader.Close()
|
||||
})
|
||||
|
||||
DescribeTable("numbered match wins over shared fallback within a pattern",
|
||||
|
|
@ -428,64 +430,109 @@ var _ = Describe("Disc Artwork Reader", func() {
|
|||
})
|
||||
|
||||
Describe("discArtworkReader", func() {
|
||||
Describe("fromDiscArtPriority", func() {
|
||||
var (
|
||||
reader *discArtworkReader
|
||||
tmpDir string
|
||||
var (
|
||||
reader *discArtworkReader
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
reader = &discArtworkReader{
|
||||
discNumber: 2,
|
||||
isMultiFolder: true,
|
||||
discFoldersRel: map[string]bool{"music/album/cd2": true},
|
||||
imgFiles: []string{
|
||||
"music/album/cd1/disc.jpg",
|
||||
"music/album/cd2/disc.jpg",
|
||||
"music/album/cd2/disc2.jpg",
|
||||
},
|
||||
firstTrackRel: "music/album/cd2/track1.flac",
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("selectImage", func() {
|
||||
It("abandons the walk when the context is cancelled", func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{})
|
||||
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(res.reader).To(BeNil())
|
||||
})
|
||||
|
||||
// "the track has no embedded art" and "the track is there but unreadable" are the two
|
||||
// answers a wrong-artwork report needs told apart; only the second is worth retrying.
|
||||
It("reports a track it cannot parse as unreadable, not as a miss", func() {
|
||||
trace := &ChainTrace{}
|
||||
track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel))
|
||||
Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed())
|
||||
|
||||
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
|
||||
&chainState{trace: trace})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.localError).To(BeTrue())
|
||||
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}}))
|
||||
})
|
||||
|
||||
It("reports a disc with no tracks to read as a miss", func() {
|
||||
trace := &ChainTrace{}
|
||||
reader.firstTrackRel = ""
|
||||
|
||||
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
|
||||
&chainState{trace: trace})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read")
|
||||
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("discCandidates", func() {
|
||||
It("returns a resolvable candidate for glob patterns", func() {
|
||||
cc := reader.discCandidates(context.Background(), nil, "disc*.*")
|
||||
Expect(cc).To(HaveLen(1))
|
||||
Expect(cc[0].resolve).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns one candidate per entry, in order", func() {
|
||||
cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded")
|
||||
Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })).
|
||||
To(Equal([]string{"disc*.*", "cd*.*", "embedded"}))
|
||||
})
|
||||
|
||||
It("skips an empty entry rather than building a glob that matches nothing", func() {
|
||||
cc := reader.discCandidates(context.Background(), nil, "disc*.*,")
|
||||
Expect(cc).To(HaveLen(1))
|
||||
})
|
||||
|
||||
// The skip reasons below are what `artwork explain` prints, so an entry that maps to no
|
||||
// source must say why instead of vanishing from the walk.
|
||||
DescribeTable("keeps an entry that maps to no source, with its reason",
|
||||
func(setup func(), priority, reason string) {
|
||||
setup()
|
||||
cc := reader.discCandidates(context.Background(), nil, priority)
|
||||
Expect(cc).To(HaveLen(1))
|
||||
Expect(cc[0].resolve).To(BeNil())
|
||||
Expect(cc[0].skip).To(Equal(reason))
|
||||
},
|
||||
Entry("external is unsupported", func() {}, "external",
|
||||
"external sources are not supported for disc artwork"),
|
||||
Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*",
|
||||
"no images in album folder"),
|
||||
Entry("the disc has no subtitle",
|
||||
func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle",
|
||||
"disc has no subtitle"),
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
reader = &discArtworkReader{
|
||||
discNumber: 2,
|
||||
isMultiFolder: true,
|
||||
discFoldersRel: map[string]bool{"music/album/cd2": true},
|
||||
imgFiles: []string{
|
||||
"music/album/cd1/disc.jpg",
|
||||
"music/album/cd2/disc.jpg",
|
||||
"music/album/cd2/disc2.jpg",
|
||||
},
|
||||
firstTrackRel: "music/album/cd2/track1.flac",
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns source funcs for glob patterns", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
|
||||
Expect(ff).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns source funcs for embedded pattern", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
|
||||
Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
|
||||
})
|
||||
|
||||
It("handles multiple comma-separated patterns", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
|
||||
Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
|
||||
})
|
||||
|
||||
It("ignores 'external' pattern silently", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
|
||||
reader.imgFiles = nil
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns source func for discsubtitle pattern", func() {
|
||||
reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
|
||||
Expect(ff).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns no source func for discsubtitle when disc has no subtitle", func() {
|
||||
reader.album = model.Album{Discs: model.Discs{2: ""}}
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
cc := reader.discCandidates(context.Background(), nil, "discsubtitle")
|
||||
Expect(cc).To(HaveLen(1))
|
||||
Expect(cc[0].resolve).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -105,7 +106,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, errors.New("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue())
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
|
||||
|
||||
svc = artwork.NewArtwork(ds, imgCache, store, ffm)
|
||||
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
|
|
@ -116,6 +117,25 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
||||
}
|
||||
|
||||
It("acquires and serves a cover whose format has no registered decoder (#5950)", func() {
|
||||
libDir := GinkgoT().TempDir()
|
||||
Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed())
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}})
|
||||
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
||||
|
||||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeFalse())
|
||||
Expect(readAll(img)).To(Equal(jxlFixture))
|
||||
})
|
||||
|
||||
It("acquires album folder art and serves the exact bytes under its hash", func() {
|
||||
seedFolderAlbum("al1")
|
||||
bump("al", "al1")
|
||||
|
|
@ -317,6 +337,9 @@ func mustGet(img *artwork.Image, err error) *artwork.Image {
|
|||
}
|
||||
|
||||
// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking
|
||||
// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
|
||||
var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
|
||||
|
||||
// the production import the spec above guards.
|
||||
var gifFixture = []byte{
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00,
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ func setupResolutionHarness() {
|
|||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, fmt.Errorf("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(rctx) }).Should(BeTrue())
|
||||
Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue())
|
||||
|
||||
rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm)
|
||||
rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
|
|
@ -16,6 +17,11 @@ import (
|
|||
const (
|
||||
breakerThreshold = 5
|
||||
breakerProbeAfter = time.Minute
|
||||
// breakerRecoveries is how many consecutive answers an open breaker needs before it trusts the
|
||||
// provider again. One is not enough: a provider that is rate-limiting or blocking us still
|
||||
// answers the occasional request, and closing on the first of those puts the agent straight
|
||||
// back to full rate, which is what earns the next block.
|
||||
breakerRecoveries = 3
|
||||
)
|
||||
|
||||
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
|
||||
|
|
@ -43,7 +49,8 @@ type extGate struct {
|
|||
// gate runs a named external step through that agent's rate limiter and circuit breaker.
|
||||
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
g := w.gateFor(name)
|
||||
if !g.breaker.allow() {
|
||||
allowed, gen := g.breaker.allow()
|
||||
if !allowed {
|
||||
log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name)
|
||||
return nil, "", errBreakerOpen
|
||||
}
|
||||
|
|
@ -54,7 +61,7 @@ func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.
|
|||
}
|
||||
callStart := time.Now()
|
||||
r, path, err := f()
|
||||
g.breaker.record(name, err)
|
||||
g.breaker.record(name, gen, err)
|
||||
log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil,
|
||||
"limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err)
|
||||
return r, path, err
|
||||
|
|
@ -78,43 +85,70 @@ func (w *Worker) gateFor(name string) *extGate {
|
|||
}
|
||||
|
||||
// breaker opens after breakerThreshold consecutive errors and admits a single probe once
|
||||
// breakerProbeAfter has elapsed; a success re-closes it.
|
||||
// breakerProbeAfter has elapsed; it closes after breakerRecoveries consecutive answers.
|
||||
type breaker struct {
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
openedAt time.Time
|
||||
// recoveries counts consecutive good answers while open; a single failure discards them.
|
||||
recoveries int
|
||||
// generation identifies the current open episode, so an answer from a call admitted before
|
||||
// the breaker opened cannot be mistaken for evidence that it has recovered.
|
||||
generation int
|
||||
}
|
||||
|
||||
func newBreaker() *breaker { return &breaker{} }
|
||||
|
||||
func (b *breaker) allow() bool {
|
||||
// allow reports whether a call may proceed, and the open episode it was admitted under: zero
|
||||
// when the breaker was closed, the current generation when admitted as a half-open probe.
|
||||
func (b *breaker) allow() (bool, int) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.failures < breakerThreshold {
|
||||
return true
|
||||
return true, 0
|
||||
}
|
||||
if time.Since(b.openedAt) >= breakerProbeAfter {
|
||||
b.openedAt = time.Now() // start a fresh probe window so only one caller passes
|
||||
return true
|
||||
return true, b.generation
|
||||
}
|
||||
return false
|
||||
return false, 0
|
||||
}
|
||||
|
||||
func (b *breaker) record(name string, err error) {
|
||||
func (b *breaker) record(name string, gen int, err error) {
|
||||
// A cancelled run says nothing about the provider, so it neither counts nor clears.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
// A not-found is a definitive answer, not a fault; keep in sync with isTransientExternal.
|
||||
if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) {
|
||||
if b.failures >= breakerThreshold {
|
||||
log.Info("Artwork: Circuit breaker closed for agent", "agent", name)
|
||||
if isTransientExternal(err) {
|
||||
b.recoveries = 0
|
||||
b.failures++
|
||||
if b.failures == breakerThreshold {
|
||||
b.openedAt = time.Now()
|
||||
b.generation++
|
||||
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
|
||||
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if b.failures < breakerThreshold {
|
||||
b.failures = 0
|
||||
return
|
||||
}
|
||||
b.failures++
|
||||
if b.failures == breakerThreshold {
|
||||
b.openedAt = time.Now()
|
||||
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
|
||||
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
|
||||
// Only a probe from this open episode is evidence of recovery. The worker drains concurrently,
|
||||
// so answers keep arriving from calls admitted before the breaker opened; counting those would
|
||||
// close it with no probe interval elapsed, which is the burst this exists to prevent.
|
||||
if gen == 0 || gen != b.generation {
|
||||
return
|
||||
}
|
||||
// A not-found counts because the provider did answer, but on its own it is thin evidence that
|
||||
// a provider which just blocked us is well.
|
||||
b.recoveries++
|
||||
if b.recoveries < breakerRecoveries {
|
||||
return
|
||||
}
|
||||
log.Info("Artwork: Circuit breaker closed for agent", "agent", name,
|
||||
"consecutiveAnswers", b.recoveries)
|
||||
b.failures, b.recoveries = 0, 0
|
||||
}
|
||||
|
|
|
|||
43
core/artwork/gate_test.go
Normal file
43
core/artwork/gate_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// allowed drops the generation token when a caller only cares about admission.
|
||||
func allowed(b *breaker) bool { ok, _ := b.allow(); return ok }
|
||||
|
||||
var _ = Describe("breaker", func() {
|
||||
// The worker drains concurrently, so when the breaker opens there are already calls past
|
||||
// allow(), queued in the rate limiter or waiting on a response. Their answers arrive
|
||||
// afterwards. Counting those as recovery closes the breaker with no probe interval elapsed,
|
||||
// which is the burst the ramp exists to prevent. No clock is involved: the race is an
|
||||
// ordering, so it is reproduced by making the calls in the order concurrency produces.
|
||||
It("ignores answers from calls admitted before it opened", func() {
|
||||
b := newBreaker()
|
||||
|
||||
// A batch clears allow() while the breaker is still closed.
|
||||
for range breakerThreshold + breakerRecoveries {
|
||||
ok, gen := b.allow()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(gen).To(BeZero(), "admitted with the breaker closed, so not a probe")
|
||||
}
|
||||
|
||||
// The fast failures in that batch open it.
|
||||
for range breakerThreshold {
|
||||
b.record("agentA", 0, errors.New("blocked"))
|
||||
}
|
||||
Expect(allowed(b)).To(BeFalse(), "breaker is open")
|
||||
|
||||
// The slower answers from the same batch land now.
|
||||
for range breakerRecoveries {
|
||||
b.record("agentA", 0, nil)
|
||||
}
|
||||
|
||||
Expect(allowed(b)).To(BeFalse(),
|
||||
"answers from calls admitted before the breaker opened must not close it")
|
||||
})
|
||||
})
|
||||
|
|
@ -2,10 +2,10 @@ package artwork
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -14,45 +14,85 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
const staleAbsentAge = 24 * time.Hour
|
||||
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
|
||||
const StaleAbsentAge = 7 * 24 * time.Hour
|
||||
|
||||
// recheckKinds omits media files: they resolve embedded-only, at scan or on view.
|
||||
var recheckKinds = []model.Kind{
|
||||
// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind,
|
||||
// oldest first, so external agents see a flat drip instead of a daily burst.
|
||||
const StaleAbsentRecheckBatch = 100
|
||||
|
||||
// RecheckKinds omits media files: they resolve embedded-only, at scan or on view.
|
||||
var RecheckKinds = []model.Kind{
|
||||
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
|
||||
}
|
||||
|
||||
// KeepsState reports whether a kind is recorded in item_artwork and the artwork queue. Disc
|
||||
// artwork is read through on every request and cached by content key, so it has neither.
|
||||
func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork }
|
||||
|
||||
// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds
|
||||
// KeepsState admits. Media files are absent from RecheckKinds but belong here: the worker
|
||||
// resolves them, it just never revisits them on its own.
|
||||
var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork)
|
||||
|
||||
// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable.
|
||||
func hasRecheckPath(prefix string) bool {
|
||||
kind, ok := model.ParseKind(prefix)
|
||||
return ok && slices.Contains(recheckKinds, kind)
|
||||
return ok && slices.Contains(RecheckKinds, kind)
|
||||
}
|
||||
|
||||
// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change.
|
||||
const artworkEpoch = 1
|
||||
|
||||
// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
|
||||
func fingerprint() string {
|
||||
raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d",
|
||||
conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder,
|
||||
conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, artworkEpoch)
|
||||
sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive
|
||||
return hex.EncodeToString(sum[:])
|
||||
// FingerprintInput is one config value the fingerprint covers, named after the setting it came from.
|
||||
type FingerprintInput struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// FingerprintInputs is the single listing of what ConfigFingerprint hashes.
|
||||
func FingerprintInputs() []FingerprintInput {
|
||||
return []FingerprintInput{
|
||||
{"CoverArtPriority", conf.Server.CoverArtPriority},
|
||||
{"ArtistArtPriority", conf.Server.ArtistArtPriority},
|
||||
{"ArtistImageFolder", conf.Server.ArtistImageFolder},
|
||||
{"Agents", conf.Server.Agents},
|
||||
{"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)},
|
||||
{"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
|
||||
func ConfigFingerprint() string {
|
||||
values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value })
|
||||
raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch)
|
||||
return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw)))
|
||||
}
|
||||
|
||||
// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one
|
||||
// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again.
|
||||
type backfillSummary struct {
|
||||
Ran bool
|
||||
PerKind map[string]int64
|
||||
Items int64
|
||||
MaxExternalLookups int64
|
||||
}
|
||||
|
||||
// backfill enqueues artwork resolution for every entity when the config fingerprint changed.
|
||||
func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
|
||||
func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) {
|
||||
start := time.Now()
|
||||
ctx = auth.WithAdminUser(ctx, ds)
|
||||
current := fingerprint()
|
||||
current := ConfigFingerprint()
|
||||
props := ds.Property(ctx)
|
||||
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
if stored == current {
|
||||
return false, nil
|
||||
return backfillSummary{}, nil
|
||||
}
|
||||
|
||||
// Artists first: few entities, most external-dependent, so they get a queue headstart.
|
||||
|
|
@ -65,21 +105,31 @@ func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
|
|||
{model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
|
||||
{model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
|
||||
}
|
||||
// Counted here, not by the caller: building the agent list constructs every enabled agent, and
|
||||
// an unchanged fingerprint returns above without ever needing the number.
|
||||
agents := agentCount()
|
||||
summary := backfillSummary{Ran: true, PerKind: map[string]int64{}}
|
||||
for _, k := range kinds {
|
||||
ids, err := k.fetch()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil {
|
||||
return false, err
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
n := int64(len(ids))
|
||||
summary.PerKind[k.kind.Prefix()] = n
|
||||
summary.Items += n
|
||||
summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents)
|
||||
}
|
||||
|
||||
if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil {
|
||||
return false, err
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start))
|
||||
return true, nil
|
||||
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items,
|
||||
"byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups,
|
||||
"elapsed", time.Since(start))
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error {
|
||||
|
|
@ -95,10 +145,10 @@ func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kin
|
|||
}
|
||||
|
||||
func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
|
||||
cutoff := time.Now().Add(-staleAbsentAge)
|
||||
cutoff := time.Now().Add(-StaleAbsentAge)
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
for _, kind := range recheckKinds {
|
||||
if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil {
|
||||
for _, kind := range RecheckKinds {
|
||||
if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -108,7 +158,7 @@ func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
|
|||
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
||||
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
for _, kind := range recheckKinds {
|
||||
for _, kind := range RecheckKinds {
|
||||
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package artwork
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
|
|
@ -38,6 +39,8 @@ func adminUserRepo() *tests.MockedUserRepo {
|
|||
return repo
|
||||
}
|
||||
|
||||
func noAgents() ImageAgentCount { return ImageAgentCount{} }
|
||||
|
||||
// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can
|
||||
// assert phase ordering (artists-first) that same-priority timestamps can't guarantee.
|
||||
type orderTrackingQueueRepo struct {
|
||||
|
|
@ -52,6 +55,19 @@ func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error
|
|||
return o.MockArtworkQueueRepo.Enqueue(items...)
|
||||
}
|
||||
|
||||
var _ = Describe("RefreshableKinds", func() {
|
||||
// The two are meant to describe the same fact. Nothing but this test stops them from drifting,
|
||||
// and a drift would have `artwork explain` report state for a kind that keeps none.
|
||||
It("holds exactly the kinds that keep state", func() {
|
||||
for _, k := range []model.Kind{
|
||||
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork,
|
||||
model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork,
|
||||
} {
|
||||
Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Housekeeping", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
|
|
@ -93,32 +109,54 @@ var _ = Describe("Housekeeping", func() {
|
|||
|
||||
Describe("Fingerprint", func() {
|
||||
It("changes when a fingerprint-affecting config value changes", func() {
|
||||
f1 := fingerprint()
|
||||
f1 := ConfigFingerprint()
|
||||
conf.Server.CoverArtPriority = "folder, embedded"
|
||||
f2 := fingerprint()
|
||||
f2 := ConfigFingerprint()
|
||||
Expect(f1).NotTo(Equal(f2))
|
||||
})
|
||||
|
||||
It("changes when ArtistImageFolder changes", func() {
|
||||
conf.Server.ArtistImageFolder = "/before"
|
||||
f1 := fingerprint()
|
||||
f1 := ConfigFingerprint()
|
||||
conf.Server.ArtistImageFolder = "/after"
|
||||
Expect(fingerprint()).NotTo(Equal(f1))
|
||||
Expect(ConfigFingerprint()).NotTo(Equal(f1))
|
||||
})
|
||||
|
||||
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
f1 := fingerprint()
|
||||
f1 := ConfigFingerprint()
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
Expect(fingerprint()).NotTo(Equal(f1))
|
||||
Expect(ConfigFingerprint()).NotTo(Equal(f1))
|
||||
})
|
||||
|
||||
// Pinned: a changed formula re-resolves every library on upgrade, flooding external providers.
|
||||
It("hashes a given config to a stable value", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, embedded"
|
||||
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||
conf.Server.ArtistImageFolder = ""
|
||||
conf.Server.Agents = "lastfm,spotify"
|
||||
conf.Server.EnableExternalServices = true
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d"))
|
||||
})
|
||||
|
||||
It("reports the config inputs it hashes, so a change can be traced to a setting", func() {
|
||||
conf.Server.Agents = "lastfm,spotify"
|
||||
conf.Server.CoverArtPriority = "cover.*, embedded"
|
||||
|
||||
Expect(FingerprintInputs()).To(ContainElements(
|
||||
FingerprintInput{Name: "Agents", Value: "lastfm,spotify"},
|
||||
FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"},
|
||||
))
|
||||
})
|
||||
|
||||
It("does not change when the server version changes", func() {
|
||||
original := consts.Version
|
||||
DeferCleanup(func() { consts.Version = original })
|
||||
f1 := fingerprint()
|
||||
f1 := ConfigFingerprint()
|
||||
consts.Version = original + "-next"
|
||||
Expect(fingerprint()).To(Equal(f1),
|
||||
Expect(ConfigFingerprint()).To(Equal(f1),
|
||||
"the version must not invalidate artwork state: it would re-resolve every entity on every build")
|
||||
})
|
||||
})
|
||||
|
|
@ -126,11 +164,16 @@ var _ = Describe("Housekeeping", func() {
|
|||
Describe("Backfill", func() {
|
||||
It("enqueues nothing and returns false when the stored fingerprint matches", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, fingerprint())).To(Succeed())
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed())
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
counted := false
|
||||
s, err := backfill(ctx, ds, func() ImageAgentCount {
|
||||
counted = true
|
||||
return ImageAgentCount{Artist: 3, Album: 2}
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeFalse())
|
||||
Expect(s).To(Equal(backfillSummary{}))
|
||||
Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it")
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -140,9 +183,9 @@ var _ = Describe("Housekeeping", func() {
|
|||
It("runs the backfill when no fingerprint was ever stored", func() {
|
||||
seedEntities()
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
s, err := backfill(ctx, ds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -150,7 +193,7 @@ var _ = Describe("Housekeeping", func() {
|
|||
|
||||
stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored).To(Equal(fingerprint()))
|
||||
Expect(stored).To(Equal(ConfigFingerprint()))
|
||||
})
|
||||
|
||||
It("enqueues a private playlist by resolving it under an admin context", func() {
|
||||
|
|
@ -161,9 +204,9 @@ var _ = Describe("Housekeeping", func() {
|
|||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
|
||||
did, err := backfill(ctx, vds)
|
||||
s, err := backfill(ctx, vds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
|
|
@ -171,9 +214,9 @@ var _ = Describe("Housekeeping", func() {
|
|||
seedEntities()
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
s, err := backfill(ctx, ds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
Expect(queueRepo.callKinds).ToNot(BeEmpty())
|
||||
firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" })
|
||||
|
|
@ -188,6 +231,22 @@ var _ = Describe("Housekeeping", func() {
|
|||
Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra"))
|
||||
}
|
||||
})
|
||||
|
||||
It("reports what it enqueued, per kind and as an external-lookup ceiling", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||
conf.Server.CoverArtPriority = "cover.*, external"
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
seedEntities()
|
||||
|
||||
s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1}))
|
||||
Expect(s.Items).To(Equal(int64(5)))
|
||||
// 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch.
|
||||
Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueStaleAbsentAll", func() {
|
||||
|
|
@ -200,8 +259,8 @@ var _ = Describe("Housekeeping", func() {
|
|||
})
|
||||
|
||||
It("enqueues only absent entries older than the recheck window, across all kinds", func() {
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
recent := time.Now().Add(-time.Hour)
|
||||
old := time.Now().Add(-StaleAbsentAge - time.Hour)
|
||||
recent := time.Now().Add(-StaleAbsentAge + time.Hour)
|
||||
|
||||
artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
|
|
@ -224,6 +283,20 @@ var _ = Describe("Housekeeping", func() {
|
|||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil())
|
||||
})
|
||||
|
||||
It("caps each tick at the recheck batch, oldest attempts first", func() {
|
||||
for i := range StaleAbsentRecheckBatch + 1 {
|
||||
id := fmt.Sprintf("ar%d", i)
|
||||
artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)}
|
||||
}
|
||||
|
||||
Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed())
|
||||
|
||||
Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch))
|
||||
// ar0 has the newest attempted_at of the cohort, so it is the one left out.
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueMissingAll", func() {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// An open() that reports "no image" as a nil reader would otherwise panic on the Close below.
|
||||
if orig == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
defer orig.Close()
|
||||
data, err := readCapped(orig)
|
||||
if err != nil {
|
||||
|
|
|
|||
41
core/artwork/image_cache_test.go
Normal file
41
core/artwork/image_cache_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("resizedItem", func() {
|
||||
Describe("Reader", func() {
|
||||
newItem := func(open func() (io.ReadCloser, error)) *resizedItem {
|
||||
return &resizedItem{hash: "abc123", size: 300, open: open}
|
||||
}
|
||||
|
||||
It("reports a nil reader as unavailable instead of panicking on it", func() {
|
||||
// Every caller is expected to report "no image" as an error, but a nil reader reaches
|
||||
// the deferred Close as a nil interface, which takes the whole request down.
|
||||
_, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background())
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
|
||||
It("propagates the open error", func() {
|
||||
boom := errors.New("boom")
|
||||
_, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background())
|
||||
Expect(err).To(MatchError(boom))
|
||||
})
|
||||
|
||||
It("serves the original bytes when they cannot be resized", func() {
|
||||
rc, err := newItem(func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("not an image")), nil
|
||||
}).Reader(context.Background())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rc.Close()
|
||||
Expect(io.ReadAll(rc)).To(Equal([]byte("not an image")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ package artwork
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
|
@ -89,12 +90,21 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
|||
|
||||
res, err := p.resolver.resolve(ctx, item)
|
||||
if err != nil {
|
||||
traceStage(ctx, "resolve", err)
|
||||
log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
if res.reader == nil {
|
||||
if res.extError || res.localError {
|
||||
// A fault is not a definitive "no image": never settle absent, keep serving old state.
|
||||
// A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank.
|
||||
if t := traceFrom(ctx); len(t.Steps()) == 0 {
|
||||
outcome := OutcomeError
|
||||
if res.localError {
|
||||
outcome = OutcomeUnreadable
|
||||
}
|
||||
t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome})
|
||||
}
|
||||
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
|
||||
"kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError)
|
||||
return outcomeFailed, nil
|
||||
|
|
@ -106,6 +116,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
|||
readStart := time.Now()
|
||||
data, err := readCapped(res.reader)
|
||||
if err != nil {
|
||||
traceStage(ctx, "read", err)
|
||||
log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
|
|
@ -115,6 +126,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
|||
hashStart := time.Now()
|
||||
hash, err := hashImage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
traceStage(ctx, "hash", err)
|
||||
log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
|
|
@ -123,26 +135,37 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
|||
|
||||
art, err := repo.GetImage(hash)
|
||||
switch {
|
||||
case err == nil:
|
||||
case err == nil && art.Width > 0:
|
||||
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
||||
"id", item.ItemID, "hash", hash)
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
// A row with no dimensions was stored when no decoder matched; retry in case one exists now.
|
||||
case err == nil, errors.Is(err, model.ErrNotFound):
|
||||
decodeStart := time.Now()
|
||||
art, err = decodeArtwork(ctx, hash, data)
|
||||
// Extension-matched local bytes we cannot decode are most likely a codec we lack; an
|
||||
// external body carries no such guarantee, and empty bytes are no image at all.
|
||||
if errors.Is(err, image.ErrFormat) && len(data) > 0 && isLocalSource(res.source) {
|
||||
log.Debug(ctx, "Artwork: No decoder for this image format, storing it without placeholders",
|
||||
"kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data))
|
||||
art, err = undecodedArtwork(hash), nil
|
||||
}
|
||||
if err != nil {
|
||||
traceStage(ctx, "decode", err)
|
||||
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash,
|
||||
"width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart))
|
||||
default:
|
||||
traceStage(ctx, "lookup", err)
|
||||
log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
art.SizeBytes = int64(len(data))
|
||||
|
||||
ia, err := p.persist(repo, item, art, res, data)
|
||||
ia, err := p.persist(ctx, repo, item, art, res, data)
|
||||
if err != nil {
|
||||
traceStage(ctx, "store", err)
|
||||
log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
|
|
@ -157,7 +180,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
|||
|
||||
// persist places the bytes and commits the rows referencing them, excluding Prune for that
|
||||
// window only so a slow resolution can never hold it off.
|
||||
func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem,
|
||||
func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem,
|
||||
art *model.Artwork, res resolution, data []byte,
|
||||
) (*model.ItemArtwork, error) {
|
||||
if p.pruneLock != nil {
|
||||
|
|
@ -180,6 +203,7 @@ func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueu
|
|||
SourcePath: sourcePath,
|
||||
RefMtime: refMtime,
|
||||
AttemptedAt: time.Now(),
|
||||
Trace: traceFrom(ctx).encode(sourcePath),
|
||||
}
|
||||
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
||||
if err := repo.PutItemArtwork(ia); err != nil {
|
||||
|
|
@ -195,6 +219,7 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A
|
|||
ItemID: item.ItemID,
|
||||
ImageType: item.ImageType,
|
||||
AttemptedAt: time.Now(),
|
||||
Trace: traceFrom(ctx).encode(""),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
|
|
@ -234,6 +259,12 @@ func decodeCapped(data []byte) (image.Image, string, error) {
|
|||
return img, format, nil
|
||||
}
|
||||
|
||||
// undecodedArtwork is the row for bytes no decoder matched: servable, but with no dimensions
|
||||
// and none of the placeholders a decode would have produced.
|
||||
func undecodedArtwork(hash string) *model.Artwork {
|
||||
return &model.Artwork{Hash: hash, Mime: mimeForFormat("")}
|
||||
}
|
||||
|
||||
// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two
|
||||
// placeholder hashes, both encoded from one shared downscaled thumbnail.
|
||||
func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) {
|
||||
|
|
@ -288,6 +319,11 @@ func isFileBacked(source string) bool {
|
|||
return source == "folder" || source == "upload"
|
||||
}
|
||||
|
||||
// isLocalSource reports whether the bytes came off disk rather than off the network.
|
||||
func isLocalSource(source string) bool {
|
||||
return isFileBacked(source) || source == "embedded"
|
||||
}
|
||||
|
||||
// placeBytes reports the item's backing-file provenance and writes the bytes into the store
|
||||
// for the sources that have none.
|
||||
func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
|
|
@ -24,6 +25,9 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// jxlCodestream is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
|
||||
var jxlCodestream = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
|
||||
|
||||
// DecodeConfig reads only the header, so the pixel data can be omitted entirely.
|
||||
func pngHeaderWithDims(w, h uint32) []byte {
|
||||
ihdr := make([]byte, 13)
|
||||
|
|
@ -225,6 +229,35 @@ var _ = Describe("processor.acquire", func() {
|
|||
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
||||
})
|
||||
|
||||
// Playlist/radio resolvers walk no chain, so a fault records no step; without a fallback,
|
||||
// explain would show a give-up with an empty "Gave up after" table.
|
||||
It("chainless fault: records a fallback trace step naming the faulted source", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
// os.Open under a non-directory maps to a not-exist error on Windows, so no localError.
|
||||
Skip("cannot provoke an open fault via a non-directory parent on Windows")
|
||||
}
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{}
|
||||
ds.MockedRadio = radioRepo
|
||||
dir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(dir)
|
||||
upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg")
|
||||
// A plain file where the upload's parent should be makes os.Open fault with ENOTDIR,
|
||||
// deterministically and regardless of the test user's privileges.
|
||||
Expect(os.MkdirAll(filepath.Dir(filepath.Dir(upload)), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Dir(upload), []byte("x"), 0o600)).To(Succeed())
|
||||
radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"}
|
||||
|
||||
trace := &ChainTrace{}
|
||||
out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
steps := trace.Steps()
|
||||
Expect(steps).To(HaveLen(1), "a radio fault must leave one step so explain is not blank")
|
||||
Expect(steps[0].Candidate).To(Equal("upload"))
|
||||
Expect(steps[0].Outcome).To(Equal(OutcomeUnreadable))
|
||||
})
|
||||
|
||||
It("failed-on-extError: leaves the item's state untouched", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
|
|
@ -259,6 +292,59 @@ var _ = Describe("processor.acquire", func() {
|
|||
Expect(ia.Source).To(Equal("folder"))
|
||||
})
|
||||
|
||||
It("undecodable local file: acquires it anyway, with no placeholder metadata", func() {
|
||||
libRoot := GinkgoT().TempDir()
|
||||
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), jxlCodestream, 0600)).To(Succeed())
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Width).To(BeZero())
|
||||
Expect(art.BlurHash).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("empty local file: fails without writing state", func() {
|
||||
libRoot := GinkgoT().TempDir()
|
||||
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), nil, 0600)).To(Succeed())
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
// An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover.
|
||||
It("undecodable external body: fails without writing state", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<html>rate limited</html>"))
|
||||
}))
|
||||
DeferCleanup(srv.Close)
|
||||
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}})
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("found-external: persists source as external:<agentName> and stores the fetched bytes", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
||||
|
|
@ -374,7 +460,8 @@ var _ = Describe("processor.acquire", func() {
|
|||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed())
|
||||
// Truncated PNG: a known format, so this is a real decode failure, not a missing decoder.
|
||||
Expect(os.WriteFile(imgPath, pngHeaderWithDims(100, 100), 0600)).To(Succeed())
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}}
|
||||
|
|
@ -428,6 +515,54 @@ var _ = Describe("processor.acquire", func() {
|
|||
Expect(err.Error()).To(ContainSubstring("dimensions"))
|
||||
})
|
||||
|
||||
It("unknown format: reports ErrFormat so the caller can decide", func() {
|
||||
_, err := decodeArtwork(ctx, "jxl", jxlCodestream)
|
||||
Expect(err).To(MatchError(image.ErrFormat))
|
||||
})
|
||||
|
||||
It("undecodedArtwork: carries the hash and mime, and nothing a decode would add", func() {
|
||||
art := undecodedArtwork("jxl")
|
||||
Expect(art.Hash).To(Equal("jxl"))
|
||||
Expect(art.Mime).To(Equal("application/octet-stream"))
|
||||
Expect(art.Width).To(BeZero())
|
||||
Expect(art.Height).To(BeZero())
|
||||
Expect(art.BlurHash).To(BeEmpty())
|
||||
Expect(art.ThumbHash).To(BeEmpty())
|
||||
Expect(art.DominantColor).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("corrupt image of a known format: still fails", func() {
|
||||
data := pngHeaderWithDims(100, 100) // header declares a decodable size, body is missing
|
||||
_, err := decodeArtwork(ctx, "truncated", data)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("decode image"))
|
||||
})
|
||||
|
||||
// Without this a metadata-less row would be reused forever, so a decoder added later
|
||||
// could never upgrade it.
|
||||
It("metadata-less row: re-decodes on reuse instead of skipping", func() {
|
||||
libRoot := GinkgoT().TempDir()
|
||||
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), imgBytes, 0600)).To(Succeed())
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alM", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
|
||||
|
||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
||||
|
||||
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
upgraded, err := artRepo.GetImage(hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
||||
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("store write failure: fails without writing state", func() {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import (
|
|||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
// resolution is one attempted acquisition outcome for an entity.
|
||||
|
|
@ -34,18 +35,31 @@ type resolution struct {
|
|||
|
||||
// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a
|
||||
// transient external failure still retries; localErr is dropped, as the scanner re-lists changes.
|
||||
type chainState struct{ extErr, localErr bool }
|
||||
type chainState struct {
|
||||
extErr, localErr bool
|
||||
trace *ChainTrace // nil only where no caller attached one
|
||||
}
|
||||
|
||||
// try stamps the accumulated external failure onto a hit, and records the miss otherwise.
|
||||
func (c *chainState) try(res resolution, ok bool) (resolution, bool) {
|
||||
func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) {
|
||||
if ok {
|
||||
res.extError = c.extErr
|
||||
c.record(candidate, OutcomeHit, res.sourcePath)
|
||||
return res, true
|
||||
}
|
||||
c.localErr = c.localErr || res.localError
|
||||
if res.localError {
|
||||
c.record(candidate, OutcomeUnreadable, "")
|
||||
} else {
|
||||
c.record(candidate, OutcomeMiss, "")
|
||||
}
|
||||
return resolution{}, false
|
||||
}
|
||||
|
||||
func (c *chainState) record(candidate string, out Outcome, detail string) {
|
||||
c.trace.add(TraceStep{Candidate: candidate, Outcome: out, Detail: detail})
|
||||
}
|
||||
|
||||
// exhausted is the outcome when no source in the chain yielded an image.
|
||||
func (c *chainState) exhausted() resolution {
|
||||
return resolution{extError: c.extErr, localError: c.localErr}
|
||||
|
|
@ -95,8 +109,78 @@ func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (re
|
|||
}
|
||||
}
|
||||
|
||||
// fetchExternalAlbum and fetchExternalArtist are the only places resolution touches the network,
|
||||
// so a local-only resolver is stopped here rather than at each point in the chain walk.
|
||||
// Explainable reports whether TracingResolver can walk this kind's sources and report which one
|
||||
// won; playlists and radios resolve from a fixed internal order, with nothing configured to explain.
|
||||
func Explainable(kind model.Kind) bool {
|
||||
switch kind {
|
||||
case model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, model.KindMediaFileArtwork:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MayFetchExternal reports whether resolving this kind can issue an external request under the
|
||||
// current config. Playlists inherit the album chain: the generated grid resolves album art.
|
||||
func MayFetchExternal(kind model.Kind) bool {
|
||||
switch kind {
|
||||
case model.KindArtistArtwork:
|
||||
return chainFetchesExternal(conf.Server.ArtistArtPriority)
|
||||
case model.KindAlbumArtwork:
|
||||
return chainFetchesExternal(conf.Server.CoverArtPriority)
|
||||
case model.KindPlaylistArtwork:
|
||||
return conf.Server.EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server.CoverArtPriority)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ImageAgentCount is how many enabled agents provide artist and album images.
|
||||
type ImageAgentCount struct{ Artist, Album int }
|
||||
|
||||
// NewImageAgentCount counts what an external step would consult, so an estimate and the gate that
|
||||
// guards it cannot disagree about which agents exist.
|
||||
func NewImageAgentCount(ag *agents.Agents) ImageAgentCount {
|
||||
if ag == nil {
|
||||
return ImageAgentCount{}
|
||||
}
|
||||
return ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())}
|
||||
}
|
||||
|
||||
// ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is
|
||||
// tried, and a zero count still bills one, so agents the caller cannot see never read as free.
|
||||
func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 {
|
||||
if !MayFetchExternal(kind) {
|
||||
return 0
|
||||
}
|
||||
switch kind {
|
||||
case model.KindArtistArtwork:
|
||||
return int64(max(agents.Artist, 1))
|
||||
case model.KindAlbumArtwork:
|
||||
return int64(max(agents.Album, 1))
|
||||
case model.KindPlaylistArtwork:
|
||||
var n int64
|
||||
if conf.Server.EnableM3UExternalAlbumArt {
|
||||
n++
|
||||
}
|
||||
if chainFetchesExternal(conf.Server.CoverArtPriority) {
|
||||
n += PlaylistGridSamples * int64(max(agents.Album, 1))
|
||||
}
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func chainFetchesExternal(priority string) bool {
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
if strings.TrimSpace(pattern) == externalCandidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Album and artist fetches stop here when the resolver is local-only, rather than at each point in
|
||||
// the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself.
|
||||
func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) {
|
||||
if r.ext == nil {
|
||||
return nil, "", false
|
||||
|
|
@ -126,24 +210,31 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
|
|||
return resolution{}, err
|
||||
}
|
||||
|
||||
var chain chainState
|
||||
chain := chainState{trace: traceFrom(ctx)}
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
if res, ok := chain.try(resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)); ok {
|
||||
res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)
|
||||
if res, ok = chain.try(pattern, res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
case pattern == "external":
|
||||
case pattern == externalCandidate:
|
||||
if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil {
|
||||
return resolution{reader: rd, source: "external:" + name}, nil
|
||||
return resolution{reader: rd, source: ExternalPrefix + name}, nil
|
||||
} else if isErr {
|
||||
chain.extErr = true
|
||||
}
|
||||
case len(imgFiles) > 0:
|
||||
if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, pattern)); ok {
|
||||
res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern)
|
||||
if res, ok = chain.try(pattern, res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
default:
|
||||
chain.record(pattern, OutcomeSkipped, "no images in album folder")
|
||||
}
|
||||
}
|
||||
return chain.exhausted(), nil
|
||||
|
|
@ -155,9 +246,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
|
|||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload")
|
||||
if ok {
|
||||
return upload, nil
|
||||
chain := chainState{trace: traceFrom(ctx)}
|
||||
upload, uploadOK := resolveLocalFile(ar.UploadedImagePath(), "upload")
|
||||
if res, ok := chain.try("upload", upload, uploadOK); ok {
|
||||
return res, nil
|
||||
}
|
||||
if upload.localError {
|
||||
// The upload outranks every other source; falling through would persist a lower-priority
|
||||
|
|
@ -166,12 +258,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
|
|||
}
|
||||
|
||||
// Only consider albums where the artist is the sole album artist.
|
||||
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
|
||||
},
|
||||
})
|
||||
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -191,32 +278,43 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
|
|||
}
|
||||
}
|
||||
|
||||
var chain chainState
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case pattern == "external":
|
||||
case pattern == externalCandidate:
|
||||
if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil {
|
||||
return resolution{reader: rd, source: "external:" + name}, nil
|
||||
return resolution{reader: rd, source: ExternalPrefix + name}, nil
|
||||
} else if isErr {
|
||||
chain.extErr = true
|
||||
}
|
||||
case pattern == "image-folder":
|
||||
if res, ok := chain.try(resolveArtistImageFolder(ar)); ok {
|
||||
res, ok := resolveArtistImageFolder(ar)
|
||||
if res, ok = chain.try(pattern, res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
if lib.FS == nil {
|
||||
chain.record(pattern, OutcomeSkipped, "artist has no albums")
|
||||
continue
|
||||
}
|
||||
if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))); ok {
|
||||
res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))
|
||||
if res, ok = chain.try(pattern, res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
default:
|
||||
if lib.FS == nil || artistFolder == "" {
|
||||
if lib.FS == nil {
|
||||
chain.record(pattern, OutcomeSkipped, "artist has no albums")
|
||||
continue
|
||||
}
|
||||
if res, ok := chain.try(resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)); ok {
|
||||
if artistFolder == "" {
|
||||
chain.record(pattern, OutcomeSkipped, "no artist folder")
|
||||
continue
|
||||
}
|
||||
res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)
|
||||
if res, ok = chain.try(pattern, res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -224,6 +322,9 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
|
|||
return chain.exhausted(), nil
|
||||
}
|
||||
|
||||
// PlaylistGridSamples is how many albums resolvePlaylist samples to build the generated grid.
|
||||
const PlaylistGridSamples = 4
|
||||
|
||||
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
||||
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
||||
pl, err := r.ds.Playlist(ctx).Get(playlistID)
|
||||
|
|
@ -262,14 +363,18 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
|||
}
|
||||
if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt {
|
||||
sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) }
|
||||
if res, ok, isErr := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
|
||||
if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
|
||||
return res, nil
|
||||
} else if isErr {
|
||||
} else if err != nil {
|
||||
extErr = true
|
||||
// Record it here with its detail: once album sampling adds its own steps, the processor's
|
||||
// empty-trace fallback no longer fires, and the error that forced the retry would be lost.
|
||||
traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
|
||||
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
|
||||
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -295,7 +400,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
|||
if decErr == nil {
|
||||
tiles = append(tiles, tile)
|
||||
}
|
||||
if len(tiles) == 4 {
|
||||
if len(tiles) == PlaylistGridSamples {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -337,25 +442,48 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution,
|
|||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt {
|
||||
chain := chainState{trace: traceFrom(ctx)}
|
||||
switch {
|
||||
case !conf.Server.EnableMediaFileCoverArt:
|
||||
chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off")
|
||||
return resolution{}, nil
|
||||
case !mf.HasCoverArt:
|
||||
chain.record("embedded", OutcomeMiss, "the track has no embedded cover art")
|
||||
return resolution{}, nil
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
res, _ := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path)
|
||||
return res, nil
|
||||
res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path)
|
||||
if res, ok = chain.try("embedded", res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
return chain.exhausted(), nil
|
||||
}
|
||||
|
||||
// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes
|
||||
// a not-found, which is a definitive "no" rather than a failure.
|
||||
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) {
|
||||
// resolveDisc walks conf.Server.DiscArtPriority. Disc artwork keeps no state row and is never
|
||||
// queued: the serving path reads it through on every request, so this only ever explains.
|
||||
func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) {
|
||||
dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
chain := chainState{trace: traceFrom(ctx)}
|
||||
return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain)
|
||||
}
|
||||
|
||||
// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a
|
||||
// definitive "no", returned as (_, false, nil); any other error is a failure the caller records.
|
||||
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (resolution, bool, error) {
|
||||
r, path, err := gate(name, sf)
|
||||
if r != nil {
|
||||
return resolution{reader: r, source: "external", sourcePath: path}, true, false
|
||||
return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, nil
|
||||
}
|
||||
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return resolution{}, false, nil
|
||||
}
|
||||
return resolution{}, false, err
|
||||
}
|
||||
|
||||
// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a
|
||||
|
|
@ -394,14 +522,36 @@ func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, em
|
|||
return resolution{localError: unreadable}, false
|
||||
}
|
||||
|
||||
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
|
||||
r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)()
|
||||
// resolveFolderSource turns a source that yields a library-relative image path into a folder
|
||||
// resolution, keeping an existing-but-unopenable file distinct from an absent one.
|
||||
func resolveFolderSource(lib libraryView, sf sourceFunc) (resolution, bool) {
|
||||
r, path, err := sf()
|
||||
if r == nil {
|
||||
return resolution{localError: errors.Is(err, errSourceUnreadable)}, false
|
||||
}
|
||||
return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true
|
||||
}
|
||||
|
||||
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
|
||||
return resolveFolderSource(lib, fromExternalFile(ctx, lib.FS, imgFiles, pattern))
|
||||
}
|
||||
|
||||
// IsArtistImageFile reports whether a file name matches any file-glob token of ArtistArtPriority.
|
||||
// Basename-only on purpose: the chain climbs parent folders, so a token's prefix is not fixed.
|
||||
func IsArtistImageFile(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" || pattern == externalCandidate || pattern == "image-folder" {
|
||||
continue
|
||||
}
|
||||
if ok, _ := path.Match(path.Base(pattern), name); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) {
|
||||
folder := conf.Server.ArtistImageFolder
|
||||
if folder == "" {
|
||||
|
|
@ -426,7 +576,9 @@ func resolveLocalFile(path, source string) (resolution, bool) {
|
|||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false
|
||||
// Carry the source label even on a fault, so a resolver with no chain (playlist/radio) can
|
||||
// still name what faulted in the trace.
|
||||
return resolution{source: source, localError: !errors.Is(err, fs.ErrNotExist)}, false
|
||||
}
|
||||
return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,31 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("IsArtistImageFile", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
It("matches bare and album/-prefixed glob tokens, case-insensitively", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artistfolder.*, external"
|
||||
Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue())
|
||||
Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue())
|
||||
Expect(IsArtistImageFile("cover.jpg")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("matches a directory-bearing glob by its basename", func() {
|
||||
conf.Server.ArtistArtPriority = "images/artist.*, external"
|
||||
Expect(IsArtistImageFile("artist.jpg")).To(BeTrue())
|
||||
Expect(IsArtistImageFile("cover.jpg")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not treat non-file tokens as globs", func() {
|
||||
conf.Server.ArtistArtPriority = "image-folder, external"
|
||||
Expect(IsArtistImageFile("image-folder")).To(BeFalse())
|
||||
Expect(IsArtistImageFile("external")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveItem", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
|
|
@ -394,6 +419,28 @@ var _ = Describe("resolveItem", func() {
|
|||
Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1),
|
||||
)
|
||||
|
||||
// The grid samples album art through the full album chain, so a playlist reaches the
|
||||
// network even with the m3u fetch off.
|
||||
It("calls the album image agents for its grid tiles when m3u art is disabled", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
folderRepo.result = nil
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")})
|
||||
var gatedNames []string
|
||||
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
gatedNames = append(gatedNames, name)
|
||||
return f()
|
||||
}
|
||||
|
||||
_, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plgrid"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(gatedNames).To(Equal([]string{"failAgent", "failAgent"}), "one lookup per sampled album")
|
||||
})
|
||||
|
||||
It("resolves the uploaded image before the generated grid", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
|
|
@ -473,6 +520,37 @@ var _ = Describe("resolveItem", func() {
|
|||
Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"")
|
||||
})
|
||||
|
||||
It("records the m3u failure in the trace even when album sampling adds its own steps", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
return nil, "", errors.New("network down")
|
||||
}
|
||||
|
||||
trace := &ChainTrace{}
|
||||
res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace),
|
||||
model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
|
||||
steps := trace.Steps()
|
||||
var m3u *TraceStep
|
||||
for i := range steps {
|
||||
if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError {
|
||||
m3u = &steps[i]
|
||||
}
|
||||
}
|
||||
Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback")
|
||||
Expect(m3u.Detail).To(Equal("network down"),
|
||||
"the trace must carry the underlying error so explain can tell a timeout from an HTTP error")
|
||||
})
|
||||
|
||||
It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() {
|
||||
folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces
|
||||
|
||||
|
|
@ -630,3 +708,110 @@ var _ = Describe("decodeTile", func() {
|
|||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Explainable", func() {
|
||||
It("is true for the kinds the resolver walks", func() {
|
||||
Expect(Explainable(model.KindArtistArtwork)).To(BeTrue())
|
||||
Expect(Explainable(model.KindAlbumArtwork)).To(BeTrue())
|
||||
Expect(Explainable(model.KindDiscArtwork)).To(BeTrue())
|
||||
Expect(Explainable(model.KindMediaFileArtwork)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("is false for the kinds resolved from a fixed internal order", func() {
|
||||
Expect(Explainable(model.KindPlaylistArtwork)).To(BeFalse())
|
||||
Expect(Explainable(model.KindRadioArtwork)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("MayFetchExternal", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.CoverArtPriority = "cover.*, embedded"
|
||||
conf.Server.ArtistArtPriority = "artist.*"
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
})
|
||||
|
||||
It("is true for the kinds whose chain includes the external candidate", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, external"
|
||||
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue())
|
||||
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("is false for a chain with no external candidate", func() {
|
||||
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeFalse())
|
||||
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is true for playlists when the m3u image fetch is enabled", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("is true for playlists whose grid tiles resolve through an external album chain", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, external"
|
||||
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("is false for playlists with both paths off", func() {
|
||||
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is false for the kinds that only read local files", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse())
|
||||
Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ExternalLookupsPerItem", func() {
|
||||
count := ImageAgentCount{Artist: 3, Album: 2}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.CoverArtPriority = "cover.*, external"
|
||||
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
})
|
||||
|
||||
It("bills one call per agent, since the walk only stops early on a hit", func() {
|
||||
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(Equal(int64(3)))
|
||||
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(Equal(int64(2)))
|
||||
})
|
||||
|
||||
It("bills a playlist for every album its grid samples", func() {
|
||||
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
|
||||
To(Equal(int64(PlaylistGridSamples) * 2))
|
||||
})
|
||||
|
||||
It("adds the m3u image fetch on top of the grid", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
|
||||
To(Equal(int64(PlaylistGridSamples)*2 + 1))
|
||||
})
|
||||
|
||||
It("bills only the m3u fetch when the album chain stays local", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("still bills a call when no agent is visible, which plugins never are offline", func() {
|
||||
none := ImageAgentCount{}
|
||||
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, none)).To(Equal(int64(1)))
|
||||
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, none)).To(Equal(int64(1)))
|
||||
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, none)).
|
||||
To(Equal(int64(PlaylistGridSamples)))
|
||||
})
|
||||
|
||||
It("is zero whenever the kind reaches no agent at all", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
conf.Server.ArtistArtPriority = "artist.*"
|
||||
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero())
|
||||
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero())
|
||||
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero())
|
||||
Expect(ExternalLookupsPerItem(model.KindRadioArtwork, count)).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,23 +27,6 @@ import (
|
|||
// to open it is not evidence the entity has no artwork, so callers must not settle on absent.
|
||||
var errSourceUnreadable = errors.New("artwork source unreadable")
|
||||
|
||||
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
|
||||
for _, f := range extractFuncs {
|
||||
if ctx.Err() != nil {
|
||||
return nil, "", ctx.Err()
|
||||
}
|
||||
start := time.Now()
|
||||
r, path, err := f()
|
||||
if r != nil {
|
||||
msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind)
|
||||
log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start))
|
||||
return r, path, nil
|
||||
}
|
||||
log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
|
||||
}
|
||||
|
||||
type sourceFunc func() (r io.ReadCloser, path string, err error)
|
||||
|
||||
func (f sourceFunc) String() string {
|
||||
|
|
|
|||
152
core/artwork/trace.go
Normal file
152
core/artwork/trace.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeHit Outcome = "hit"
|
||||
OutcomeMiss Outcome = "miss"
|
||||
OutcomeUnreadable Outcome = "unreadable"
|
||||
OutcomeSkipped Outcome = "skipped"
|
||||
OutcomeError Outcome = "error"
|
||||
)
|
||||
|
||||
const (
|
||||
// externalCandidate labels the external tier itself, for the cases that never reach an agent.
|
||||
externalCandidate = "external"
|
||||
// ExternalPrefix qualifies a candidate or a stored source with the agent that produced it.
|
||||
ExternalPrefix = externalCandidate + ":"
|
||||
)
|
||||
|
||||
// TraceStep is one candidate the priority chain considered.
|
||||
type TraceStep struct {
|
||||
Candidate string
|
||||
Outcome Outcome
|
||||
Detail string
|
||||
}
|
||||
|
||||
// ChainTrace collects the walk of a single resolution: the worker attaches one per queue
|
||||
// item so it can be stored, and the CLI attaches one per explain.
|
||||
type ChainTrace struct {
|
||||
mu sync.Mutex
|
||||
steps []TraceStep
|
||||
}
|
||||
|
||||
func (t *ChainTrace) add(step TraceStep) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.steps = append(t.steps, step)
|
||||
}
|
||||
|
||||
func (t *ChainTrace) Steps() []TraceStep {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return slices.Clone(t.steps)
|
||||
}
|
||||
|
||||
// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of
|
||||
// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table.
|
||||
const maxTraceDetail = 200
|
||||
|
||||
// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace
|
||||
// is written for every item, and the encoded length is repeated across the whole library.
|
||||
type storedStep struct {
|
||||
C string `json:"c"`
|
||||
O Outcome `json:"o"`
|
||||
D string `json:"d,omitempty"`
|
||||
}
|
||||
|
||||
// encode serializes the trace for storage, without the copy Steps would make for a caller
|
||||
// that only wants to write it.
|
||||
func (t *ChainTrace) encode(sourcePath string) string {
|
||||
if t == nil {
|
||||
return encodeSteps(nil, sourcePath)
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return encodeSteps(t.steps, sourcePath)
|
||||
}
|
||||
|
||||
// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the
|
||||
// same row already stores as source_path, so it is dropped and DecodeTrace puts it back.
|
||||
func encodeSteps(steps []TraceStep, sourcePath string) string {
|
||||
out := make([]storedStep, 0, len(steps))
|
||||
for _, s := range steps {
|
||||
d := s.Detail
|
||||
if s.Outcome == OutcomeHit && d == sourcePath {
|
||||
d = ""
|
||||
}
|
||||
out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")})
|
||||
}
|
||||
b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all,
|
||||
// since a diagnostic command must not fail on a bad row.
|
||||
func DecodeTrace(encoded, sourcePath string) []TraceStep {
|
||||
if encoded == "" {
|
||||
return nil
|
||||
}
|
||||
var stored []storedStep
|
||||
if err := json.Unmarshal([]byte(encoded), &stored); err != nil {
|
||||
return nil
|
||||
}
|
||||
steps := make([]TraceStep, 0, len(stored))
|
||||
for _, s := range stored {
|
||||
d := s.D
|
||||
if d == "" && s.O == OutcomeHit {
|
||||
d = sourcePath
|
||||
}
|
||||
steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
type traceCtxKey struct{}
|
||||
|
||||
func withTrace(ctx context.Context, t *ChainTrace) context.Context {
|
||||
return context.WithValue(ctx, traceCtxKey{}, t)
|
||||
}
|
||||
|
||||
func traceFrom(ctx context.Context) *ChainTrace {
|
||||
t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace)
|
||||
return t
|
||||
}
|
||||
|
||||
// recordAgent files what one external agent answered. The agent loops call this rather than a
|
||||
// gate wrapper, because only they hold the context that carries the trace.
|
||||
func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) {
|
||||
t := traceFrom(ctx)
|
||||
candidate := ExternalPrefix + name
|
||||
switch {
|
||||
case r != nil:
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path})
|
||||
case isTransientExternal(err):
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()})
|
||||
default:
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss})
|
||||
}
|
||||
}
|
||||
|
||||
// traceStage records a failure from the stages that run after the priority chain has already
|
||||
// picked a winner: most ways an item can fail are here, not in the chain walk.
|
||||
func traceStage(ctx context.Context, stage string, err error) {
|
||||
traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()})
|
||||
}
|
||||
664
core/artwork/trace_test.go
Normal file
664
core/artwork/trace_test.go
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("trace vocabulary", func() {
|
||||
// The CLI renders these verbatim and branches on them; a value change is a change to
|
||||
// what `artwork explain` tells an operator, so it must be made deliberately.
|
||||
It("pins the wire values the CLI reads", func() {
|
||||
Expect([]Outcome{
|
||||
OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeError,
|
||||
}).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"}))
|
||||
Expect(externalCandidate).To(Equal("external"))
|
||||
Expect(ExternalPrefix).To(Equal("external:"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("encodeSteps/DecodeTrace", func() {
|
||||
It("round-trips a trace", func() {
|
||||
steps := []TraceStep{
|
||||
{Candidate: "cover.png", Outcome: OutcomeMiss},
|
||||
{Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"},
|
||||
}
|
||||
Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps))
|
||||
})
|
||||
|
||||
It("encodes an empty trace as an empty JSON array", func() {
|
||||
Expect(encodeSteps(nil, "")).To(Equal("[]"))
|
||||
Expect(DecodeTrace("[]", "")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("tolerates a row written before the column existed", func() {
|
||||
Expect(DecodeTrace("", "")).To(BeEmpty())
|
||||
})
|
||||
|
||||
// The hit detail repeats source_path byte for byte, and that column is on the same row.
|
||||
It("drops a hit detail that repeats sourcePath, and restores it on read", func() {
|
||||
path := "/music/artist/album/cover.jpg"
|
||||
steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}}
|
||||
encoded := encodeSteps(steps, path)
|
||||
Expect(encoded).NotTo(ContainSubstring(path))
|
||||
Expect(DecodeTrace(encoded, path)).To(Equal(steps))
|
||||
})
|
||||
|
||||
It("keeps a detail that differs from sourcePath", func() {
|
||||
steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}}
|
||||
Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps))
|
||||
})
|
||||
|
||||
// A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would
|
||||
// slow every scan; Detail is an error string on the failure paths, so it needs a bound.
|
||||
It("bounds a detail so one long error cannot inflate the row", func() {
|
||||
steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}}
|
||||
|
||||
got := DecodeTrace(encodeSteps(steps, ""), "")
|
||||
|
||||
Expect(len(got[0].Detail)).To(BeNumerically("<=", 210))
|
||||
Expect(got[0].Detail).To(HaveSuffix("..."))
|
||||
Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step")
|
||||
})
|
||||
|
||||
It("only restores sourcePath onto a detail-less hit", func() {
|
||||
steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}}
|
||||
Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("chainTrace", func() {
|
||||
It("returns nil when no trace is attached", func() {
|
||||
Expect(traceFrom(context.Background())).To(BeNil())
|
||||
})
|
||||
|
||||
It("collects steps in order", func() {
|
||||
t := &ChainTrace{}
|
||||
ctx := withTrace(context.Background(), t)
|
||||
|
||||
traceFrom(ctx).add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss})
|
||||
traceFrom(ctx).add(TraceStep{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"})
|
||||
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "cover.*", Outcome: OutcomeMiss},
|
||||
{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"},
|
||||
}))
|
||||
|
||||
s := t.Steps()
|
||||
s[0].Candidate = "mutated"
|
||||
Expect(t.Steps()[0].Candidate).To(Equal("cover.*"))
|
||||
})
|
||||
|
||||
It("does not panic when the trace is nil", func() {
|
||||
var t *ChainTrace
|
||||
Expect(func() { t.add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) }).ToNot(Panic())
|
||||
Expect(t.Steps()).To(BeEmpty(), "a nil trace collects nothing, so reading it must be as safe as writing it")
|
||||
})
|
||||
|
||||
It("is safe to use concurrently", func() {
|
||||
t := &ChainTrace{}
|
||||
done := make(chan struct{})
|
||||
for range 10 {
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
t.add(TraceStep{Candidate: "x", Outcome: OutcomeMiss})
|
||||
done <- struct{}{}
|
||||
}()
|
||||
}
|
||||
for range 10 {
|
||||
<-done
|
||||
}
|
||||
Expect(t.Steps()).To(HaveLen(10))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("chainState tracing", func() {
|
||||
It("records a miss when the candidate was absent", func() {
|
||||
t := &ChainTrace{}
|
||||
c := chainState{trace: t}
|
||||
|
||||
_, ok := c.try("cover.*", resolution{}, false)
|
||||
|
||||
Expect(ok).To(BeFalse())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}}))
|
||||
})
|
||||
|
||||
It("records unreadable when the candidate existed but could not be read", func() {
|
||||
t := &ChainTrace{}
|
||||
c := chainState{trace: t}
|
||||
|
||||
_, ok := c.try("cover.*", resolution{localError: true}, false)
|
||||
|
||||
Expect(ok).To(BeFalse())
|
||||
Expect(t.Steps()).To(HaveLen(1))
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable),
|
||||
"a candidate that existed and failed to decode must be distinguishable from one that was absent")
|
||||
})
|
||||
|
||||
It("records a hit with the backing path", func() {
|
||||
t := &ChainTrace{}
|
||||
c := chainState{trace: t}
|
||||
|
||||
res, ok := c.try("embedded", resolution{reader: nil, source: "embedded", sourcePath: "/music/a.flac"}, true)
|
||||
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(res.source).To(Equal("embedded"))
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("external agent tracing", func() {
|
||||
var (
|
||||
t *ChainTrace
|
||||
ctx context.Context
|
||||
body io.ReadCloser
|
||||
)
|
||||
BeforeEach(func() {
|
||||
t = &ChainTrace{}
|
||||
ctx = withTrace(context.Background(), t)
|
||||
body = io.NopCloser(strings.NewReader("x"))
|
||||
})
|
||||
|
||||
It("records a hit with the image path", func() {
|
||||
recordAgent(ctx, "deezer", body, "http://img", nil)
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("records a miss for a not-found", func() {
|
||||
recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound)
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss))
|
||||
})
|
||||
|
||||
It("records a miss for a model not-found", func() {
|
||||
recordAgent(ctx, "deezer", nil, "", model.ErrNotFound)
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss),
|
||||
"both not-found flavours are definitive answers, not faults")
|
||||
})
|
||||
|
||||
It("records an error with its reason", func() {
|
||||
recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429"))
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError))
|
||||
Expect(t.Steps()[0].Detail).To(ContainSubstring("429"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveAlbum tracing", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
folderRepo *fakeFolderRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
ag *agents.Agents
|
||||
t *ChainTrace
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedAlbum: albumRepo,
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||
t = &ChainTrace{}
|
||||
ctx = withTrace(context.Background(), t)
|
||||
})
|
||||
|
||||
It("records a pattern skipped because the album folder holds no images", func() {
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(t.Steps()).To(HaveLen(2), "a configured pattern must appear even when the chain never evaluated it")
|
||||
Expect(t.Steps()[0]).To(Equal(TraceStep{
|
||||
Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder",
|
||||
}))
|
||||
Expect(t.Steps()[1].Candidate).To(Equal("embedded"))
|
||||
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
|
||||
})
|
||||
|
||||
It("records an evaluated pattern that matched nothing as a miss, not a skip", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "al3", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "cover.jpg", Outcome: OutcomeMiss}),
|
||||
"the folder was searched and held no cover.jpg, which is not the same as never looking")
|
||||
})
|
||||
|
||||
It("ignores an empty priority token", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg,"
|
||||
albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
|
||||
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveArtist tracing", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
artistRepo *tests.MockArtistRepo
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
folderRepo *fakeFolderRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
ag *agents.Agents
|
||||
t *ChainTrace
|
||||
repoRoot string
|
||||
)
|
||||
|
||||
uploadPath := func(file string) string {
|
||||
path := model.UploadedImagePath(consts.EntityArtist, file)
|
||||
Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(path, []byte("uploaded artist image"), 0o600)).To(Succeed())
|
||||
return path
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
conf.Server.ArtistArtPriority = "album/artist.*"
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
artistRepo = tests.CreateMockArtistRepo()
|
||||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||
t = &ChainTrace{}
|
||||
ctx = withTrace(context.Background(), t)
|
||||
})
|
||||
|
||||
It("records the upload short-circuit as a hit", func() {
|
||||
path := uploadPath("ar1_test.jpg")
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}})
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "upload", Outcome: OutcomeHit, Detail: path}}))
|
||||
})
|
||||
|
||||
It("records an upload miss before walking the chain", func() {
|
||||
artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}})
|
||||
|
||||
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "upload", Outcome: OutcomeMiss}))
|
||||
})
|
||||
|
||||
It("labels each step with the configured priority token", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
LibraryPath: testFileLibPath(repoRoot),
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}})
|
||||
albumRepo.All = model.Albums{{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}}
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(t.Steps()).To(HaveLen(2))
|
||||
Expect(t.Steps()[1].Candidate).To(Equal("album/artist.*"),
|
||||
"the step must be labelled with the priority token, not the pattern it was rewritten into")
|
||||
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
|
||||
Expect(filepath.ToSlash(t.Steps()[1].Detail)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png"))
|
||||
})
|
||||
|
||||
It("records a configured pattern that could not be evaluated", func() {
|
||||
artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}})
|
||||
|
||||
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "upload", Outcome: OutcomeMiss},
|
||||
{Candidate: "album/artist.*", Outcome: OutcomeSkipped, Detail: "artist has no albums"},
|
||||
}), "a configured pattern that was never evaluated must still appear, and say why")
|
||||
})
|
||||
|
||||
It("records why an artist folder pattern was skipped", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*"
|
||||
artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}})
|
||||
albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}}
|
||||
|
||||
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar6"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "upload", Outcome: OutcomeMiss},
|
||||
{Candidate: "artist.*", Outcome: OutcomeSkipped, Detail: "no artist folder"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("records an upload that exists but cannot be read as unreadable", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
Skip("chmod does not restrict read access on Windows")
|
||||
}
|
||||
path := uploadPath("ar3_test.jpg")
|
||||
Expect(os.Chmod(path, 0o000)).To(Succeed())
|
||||
DeferCleanup(func() { _ = os.Chmod(path, 0o600) })
|
||||
artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist", UploadedImage: "ar3_test.jpg"}})
|
||||
|
||||
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(HaveLen(1))
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable),
|
||||
"an upload that exists and will not open must not look like an absent upload")
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("NewTracingResolver", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
artistRepo *tests.MockArtistRepo
|
||||
artworkRepo *tests.MockArtworkRepo
|
||||
queueRepo *tests.MockArtworkQueueRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
t *ChainTrace
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
conf.Server.CoverArtPriority = "external, embedded"
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
artistRepo = tests.CreateMockArtistRepo()
|
||||
artworkRepo = tests.CreateMockArtworkRepo()
|
||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedAlbum: albumRepo,
|
||||
MockedArtist: artistRepo,
|
||||
MockedFolder: &fakeFolderRepo{},
|
||||
MockedLibrary: libRepo,
|
||||
MockedArtwork: artworkRepo,
|
||||
MockedArtworkQueue: queueRepo,
|
||||
}
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
t = &ChainTrace{}
|
||||
})
|
||||
|
||||
Context("resolving", func() {
|
||||
var fake *fakeImageAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
// Misses, so the chain falls through to the local tier and both are traced.
|
||||
fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound}
|
||||
albumRepo.SetData(model.Albums{{
|
||||
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
|
||||
}})
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
|
||||
})
|
||||
|
||||
It("asks the agents and records what each answered", func() {
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(source).To(Equal("embedded"))
|
||||
Expect(fake.albumCalls).To(Equal(1))
|
||||
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss}))
|
||||
})
|
||||
|
||||
It("records the local chain steps too", func() {
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
last := t.Steps()[len(t.Steps())-1]
|
||||
Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external tier")
|
||||
Expect(last.Outcome).To(Equal(OutcomeHit))
|
||||
})
|
||||
|
||||
It("never persists artwork state", func() {
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artworkRepo.ItemData).To(BeEmpty(),
|
||||
"explain is read-only; a diagnostic walk must never become the stored answer")
|
||||
Expect(queueRepo.Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("resolves an artist without persisting anything", func() {
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindArtistArtwork, "ar1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(source).To(BeEmpty())
|
||||
Expect(fake.artistCalls).To(Equal(1))
|
||||
Expect(artworkRepo.ItemData).To(BeEmpty())
|
||||
Expect(queueRepo.Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("closes the reader it does not hand back", func() {
|
||||
conf.Server.CoverArtPriority = "embedded"
|
||||
ffm = tests.NewMockFFmpeg("fake image bytes")
|
||||
albumRepo.SetData(model.Albums{{
|
||||
ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"},
|
||||
}})
|
||||
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al2")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(source).To(Equal("embedded"))
|
||||
Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation")
|
||||
})
|
||||
|
||||
// Serving falls back disc -> album and track -> disc -> album. The resolver does not, but
|
||||
// if it ever did, an explain without --live would start calling providers uninvited.
|
||||
It("cannot reach a provider without live, whatever the chain does", func() {
|
||||
conf.Server.DiscArtPriority = "external, cover.*"
|
||||
conf.Server.CoverArtPriority = "external, cover.*"
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
mfRepo := tests.CreateMockMediaFileRepo()
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true,
|
||||
Path: "tests/fixtures/artist/an-album/test.mp3"}})
|
||||
ds.MockedMediaFile = mfRepo
|
||||
offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false)
|
||||
|
||||
_, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(fake.albumCalls).To(BeZero())
|
||||
Expect(fake.artistCalls).To(BeZero())
|
||||
})
|
||||
|
||||
It("propagates a lookup error", func() {
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "nope")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveDisc tracing", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
folderRepo *fakeFolderRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
t *ChainTrace
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
mfRepo := tests.CreateMockMediaFileRepo()
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf1", AlbumID: "al1", DiscNumber: 2, Path: "tests/fixtures/artist/an-album/test.mp3"}})
|
||||
ds = &tests.MockDataStore{
|
||||
MockedAlbum: albumRepo,
|
||||
MockedMediaFile: mfRepo,
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
t = &ChainTrace{}
|
||||
ctx = withTrace(context.Background(), t)
|
||||
})
|
||||
|
||||
It("accounts for every configured entry, including the ones that map to no source", func() {
|
||||
conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg"
|
||||
|
||||
res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "external", Outcome: OutcomeSkipped, Detail: "external sources are not supported for disc artwork"},
|
||||
{Candidate: "discsubtitle", Outcome: OutcomeSkipped, Detail: "disc has no subtitle"},
|
||||
{Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("records the entry that won and stops there", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
|
||||
res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(t.Steps()).To(HaveLen(2), "the walk must stop at the winner, and record nothing below it")
|
||||
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "disc*.*", Outcome: OutcomeMiss}))
|
||||
Expect(t.Steps()[1].Candidate).To(Equal("cover.jpg"))
|
||||
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
|
||||
Expect(t.Steps()[1].Detail).To(HaveSuffix(filepath.FromSlash("tests/fixtures/artist/an-album/cover.jpg")))
|
||||
Expect(t.Steps()[1].Detail).ToNot(Equal("tests/fixtures/artist/an-album/cover.jpg"),
|
||||
"a library-relative path sends the operator looking in the wrong place")
|
||||
})
|
||||
|
||||
It("reports an unparseable disc id rather than explaining another disc", func() {
|
||||
conf.Server.DiscArtPriority = "cover.jpg"
|
||||
_, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveMediaFile tracing", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
mfRepo *tests.MockMediaFileRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
t *ChainTrace
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
mfRepo = tests.CreateMockMediaFileRepo()
|
||||
mfRepo.SetData(model.MediaFiles{{
|
||||
ID: "mf1", Title: "Song", HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3",
|
||||
}})
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo, MockedLibrary: libRepo}
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
t = &ChainTrace{}
|
||||
ctx = withTrace(context.Background(), t)
|
||||
})
|
||||
|
||||
It("separates a disabled setting from a track with nothing embedded", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
|
||||
_, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "embedded", Outcome: OutcomeSkipped, Detail: "EnableMediaFileCoverArt is off"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("records a track with no embedded art as a miss", func() {
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", Title: "Song", HasCoverArt: false}})
|
||||
|
||||
_, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf2")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "embedded", Outcome: OutcomeMiss, Detail: "the track has no embedded cover art"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("records the embedded hit", func() {
|
||||
res, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("embedded"))
|
||||
Expect(t.Steps()).To(HaveLen(1))
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeHit))
|
||||
})
|
||||
})
|
||||
|
|
@ -40,6 +40,7 @@ type drainPool struct {
|
|||
// independently, and pruneMu serializes prune against the store-write window.
|
||||
type Worker struct {
|
||||
proc *processor
|
||||
agents *agents.Agents
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
broker events.Broker
|
||||
|
|
@ -54,6 +55,7 @@ type Worker struct {
|
|||
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
|
||||
w := &Worker{
|
||||
proc: &processor{ds: ds, store: store},
|
||||
agents: ag,
|
||||
cache: imgCache,
|
||||
ffmpeg: ffmpeg,
|
||||
broker: broker,
|
||||
|
|
@ -132,12 +134,14 @@ func (w *Worker) RunPrune(ctx context.Context) error {
|
|||
}
|
||||
|
||||
// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed,
|
||||
// artists first. It reports whether anything was enqueued.
|
||||
// artists first. It reports whether the backfill ran.
|
||||
func (w *Worker) Backfill(ctx context.Context) (bool, error) {
|
||||
return backfill(ctx, w.proc.ds)
|
||||
s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) })
|
||||
return s.Ran, err
|
||||
}
|
||||
|
||||
// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge.
|
||||
// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most
|
||||
// StaleAbsentRecheckBatch per kind, oldest first.
|
||||
func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error {
|
||||
return enqueueStaleAbsentAll(ctx, w.proc.ds)
|
||||
}
|
||||
|
|
@ -235,6 +239,8 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu
|
|||
|
||||
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
|
||||
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
||||
trace := &ChainTrace{}
|
||||
ctx = withTrace(ctx, trace)
|
||||
out, got := w.proc.acquire(ctx, item)
|
||||
|
||||
queue := w.proc.ds.ArtworkQueue(ctx)
|
||||
|
|
@ -247,10 +253,11 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
}
|
||||
case outcomeFoundStale, outcomeFailed:
|
||||
retryAt := time.Now().Add(backoff(item.Attempts))
|
||||
encoded := trace.encode("")
|
||||
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
||||
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
||||
// fresh, immediate eligibility.
|
||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil {
|
||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
||||
|
|
@ -265,6 +272,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
|
||||
settled = "recorded absent"
|
||||
}
|
||||
// The queue row is about to go, taking the only record of the failure with it. This write is
|
||||
// unconditional (not CAS-guarded) — safe only because the drain resolves each item serially.
|
||||
w.recordGiveUp(ctx, item, encoded)
|
||||
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
|
|
@ -274,6 +284,18 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
return out, got
|
||||
}
|
||||
|
||||
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
|
||||
// that never resolved has no row to update, and creating one would settle it absent.
|
||||
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
|
||||
kind, ok := model.ParseKind(item.ItemKind)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool {
|
||||
kind, ok := model.ParseKind(item.ItemKind)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,17 @@ func (f *fakeEventBroker) getEvents() []events.Event {
|
|||
|
||||
var _ events.Broker = (*fakeEventBroker)(nil)
|
||||
|
||||
// expireQueued ages a row past the retry budget, so the next drain settles it instead of retrying.
|
||||
func expireQueued(q *tests.MockArtworkQueueRepo, id string) {
|
||||
GinkgoHelper()
|
||||
for k, v := range q.Data {
|
||||
if v.ItemID == id {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
q.Data[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem {
|
||||
for _, it := range q.Data {
|
||||
if it.ItemKind == kind && it.ItemID == id {
|
||||
|
|
@ -318,12 +329,7 @@ var _ = Describe("Worker", func() {
|
|||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||
// Age the row past the retry budget.
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "al9" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
expireQueued(queueRepo, "al9")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -345,12 +351,7 @@ var _ = Describe("Worker", func() {
|
|||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "al10" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
expireQueued(queueRepo, "al10")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -362,7 +363,68 @@ var _ = Describe("Worker", func() {
|
|||
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
||||
})
|
||||
|
||||
// Media files are excluded from recheckKinds, so an absent row here would never be
|
||||
It("records on the queue row why the last attempt failed", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
it := findQueued(queueRepo, "al", "al11")
|
||||
Expect(it).ToNot(BeNil())
|
||||
Expect(DecodeTrace(it.Trace, "")).To(ContainElement(SatisfyAll(
|
||||
HaveField("Candidate", "external:failAgent"),
|
||||
HaveField("Outcome", OutcomeError),
|
||||
HaveField("Detail", ContainSubstring("agent timed out")),
|
||||
)), "a retrying row must say why it is retrying")
|
||||
})
|
||||
|
||||
// The give-up path settles absent before recording, so the row exists by the time the
|
||||
// failure is written. Recording first would silently lose it for every unresolved item.
|
||||
It("keeps the failure for an item that never resolved at all", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
||||
expireQueued(queueRepo, "al13")
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("keeps the failure on the state row after the queue row is deleted", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
||||
Hash: "cafebabe", Source: "external:lastfm",
|
||||
})).To(Succeed())
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
||||
expireQueued(queueRepo, "al12")
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
||||
"the queue row is gone, so this is the only remaining record of the failure")
|
||||
Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art")
|
||||
})
|
||||
|
||||
// Media files are excluded from RecheckKinds, so an absent row here would never be
|
||||
// revisited: a transient read error would look permanent.
|
||||
It("does not settle absent on exhaustion for a kind with no recheck path", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
|
|
@ -371,12 +433,7 @@ var _ = Describe("Worker", func() {
|
|||
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "mfX" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
expireQueued(queueRepo, "mfX")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -386,6 +443,8 @@ var _ = Describe("Worker", func() {
|
|||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound),
|
||||
"no row leaves the track unresolved, so a later view can still recover it")
|
||||
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
||||
// Creating one here would write an empty hash, which every reader treats as absent.
|
||||
})
|
||||
|
||||
It("resolves a private playlist under an admin context instead of failing forever", func() {
|
||||
|
|
@ -539,6 +598,43 @@ var _ = Describe("Worker", func() {
|
|||
Expect(calls).To(Equal(5), "the breaker should have re-closed after the success")
|
||||
})
|
||||
|
||||
It("does not open the breaker when the run is cancelled", func() {
|
||||
cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled }
|
||||
for range breakerThreshold + 3 {
|
||||
_, _, err := w.gate("A", cancelled)
|
||||
Expect(err).To(MatchError(context.Canceled), "a cancellation passes through, never errBreakerOpen")
|
||||
}
|
||||
|
||||
var calls int
|
||||
counting := func() (io.ReadCloser, string, error) {
|
||||
calls++
|
||||
return nil, "", errors.New("boom")
|
||||
}
|
||||
_, _, _ = w.gate("A", counting)
|
||||
Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs")
|
||||
})
|
||||
|
||||
It("ignores a cancellation mid-run, neither counting nor clearing the failures", func() {
|
||||
failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") }
|
||||
cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled }
|
||||
for range breakerThreshold - 1 {
|
||||
_, _, _ = w.gate("A", failing)
|
||||
}
|
||||
_, _, _ = w.gate("A", cancelled)
|
||||
|
||||
var calls int
|
||||
counting := func() (io.ReadCloser, string, error) {
|
||||
calls++
|
||||
return nil, "", errors.New("boom")
|
||||
}
|
||||
_, _, _ = w.gate("A", counting)
|
||||
Expect(calls).To(Equal(1), "the cancellation must not have counted as the final failure")
|
||||
|
||||
_, _, err := w.gate("A", counting)
|
||||
Expect(err).To(MatchError(errBreakerOpen), "the cancellation must not have cleared the earlier failures")
|
||||
Expect(calls).To(Equal(1), "an open breaker must not call the external step")
|
||||
})
|
||||
|
||||
It("does not open the breaker on a run of agent not-found misses", func() {
|
||||
// agents.ErrNotFound is a definitive miss, not a fault: artless items must not
|
||||
// trip the breaker, or they would loop in retry instead of settling absent.
|
||||
|
|
|
|||
|
|
@ -20,24 +20,63 @@ func TestArtworkBreakerHalfOpen(t *testing.T) {
|
|||
b := newBreaker()
|
||||
|
||||
for range breakerThreshold {
|
||||
b.record("agentA", errors.New("boom"))
|
||||
b.record("agentA", 0, errors.New("boom"))
|
||||
}
|
||||
g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors")
|
||||
g.Expect(allowed(b)).To(BeFalse(), "breaker opens after consecutive errors")
|
||||
|
||||
time.Sleep(breakerProbeAfter - time.Nanosecond)
|
||||
g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval")
|
||||
g.Expect(allowed(b)).To(BeFalse(), "still open before the probe interval")
|
||||
|
||||
time.Sleep(time.Nanosecond)
|
||||
g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted")
|
||||
g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval")
|
||||
ok, gen := b.allow()
|
||||
g.Expect(ok).To(BeTrue(), "half-open: one probe is granted")
|
||||
g.Expect(gen).ToNot(BeZero(), "a probe carries the open episode it belongs to")
|
||||
g.Expect(allowed(b)).To(BeFalse(), "only a single probe per interval")
|
||||
|
||||
b.record("agentA", errors.New("boom")) // probe fails -> stay open
|
||||
b.record("agentA", gen, errors.New("boom")) // probe fails -> stay open
|
||||
time.Sleep(breakerProbeAfter)
|
||||
g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval")
|
||||
ok, gen = b.allow()
|
||||
g.Expect(ok).To(BeTrue(), "another probe after the next interval")
|
||||
|
||||
b.record("agentA", nil) // probe succeeds -> close
|
||||
g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely")
|
||||
g.Expect(b.allow()).To(BeTrue())
|
||||
// One good answer must not reopen the floodgates: closing here is what let a burst out at
|
||||
// full rate and got the provider to escalate from throttling to blocking.
|
||||
b.record("agentA", gen, nil)
|
||||
g.Expect(allowed(b)).To(BeFalse(), "a single good answer does not close the breaker")
|
||||
|
||||
for range breakerRecoveries - 1 {
|
||||
time.Sleep(breakerProbeAfter)
|
||||
ok, gen = b.allow()
|
||||
g.Expect(ok).To(BeTrue())
|
||||
b.record("agentA", gen, nil)
|
||||
}
|
||||
g.Expect(allowed(b)).To(BeTrue(), "closed breaker admits freely")
|
||||
g.Expect(allowed(b)).To(BeTrue())
|
||||
})
|
||||
}
|
||||
|
||||
// The failure seen in production: while an agent was blocked, the occasional answer it did serve
|
||||
// reset the breaker, releasing a burst that immediately re-tripped it. Open and closed pairs were
|
||||
// seconds apart, over and over.
|
||||
func TestArtworkBreakerDoesNotCloseOnAnIsolatedAnswer(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
b := newBreaker()
|
||||
open := func() {
|
||||
for range breakerThreshold {
|
||||
b.record("agentA", 0, errors.New("blocked"))
|
||||
}
|
||||
}
|
||||
open()
|
||||
|
||||
// A not-found is an answer, so it counts toward recovery, but never on its own.
|
||||
for range breakerRecoveries * 2 {
|
||||
time.Sleep(breakerProbeAfter)
|
||||
ok, gen := b.allow()
|
||||
g.Expect(ok).To(BeTrue(), "one probe per interval")
|
||||
b.record("agentA", gen, agents.ErrNotFound)
|
||||
b.record("agentA", 0, errors.New("blocked")) // the very next call is refused again
|
||||
g.Expect(allowed(b)).To(BeFalse(), "an answer between failures must not close the breaker")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -41,10 +42,9 @@ var _ = Describe("common.go", func() {
|
|||
})
|
||||
|
||||
It("returns the absolute path when library exists", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)")
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, libId, path)
|
||||
Expect(abs).To(Equal("/library/root/music/file.mp3"))
|
||||
Expect(abs).To(Equal(filepath.FromSlash("/library/root/music/file.mp3")))
|
||||
})
|
||||
|
||||
It("returns the original path if library not found", func() {
|
||||
|
|
|
|||
18
core/external/extdata_helper_test.go
vendored
18
core/external/extdata_helper_test.go
vendored
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
|
|
@ -110,6 +111,19 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
|
|||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
// GetRandom implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
|
|
@ -311,3 +325,7 @@ func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid
|
|||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func ids(mfs model.MediaFiles) []string {
|
||||
return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||
}
|
||||
|
|
|
|||
91
core/external/provider.go
vendored
91
core/external/provider.go
vendored
|
|
@ -16,7 +16,6 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
|
@ -277,96 +276,6 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
|||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var songs []agents.Song
|
||||
|
||||
// Try entity-specific similarity first
|
||||
switch v := entity.(type) {
|
||||
case *model.MediaFile:
|
||||
songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
|
||||
case *model.Album:
|
||||
songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
|
||||
case *model.Artist:
|
||||
songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
|
||||
default:
|
||||
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
if err == nil && len(songs) > 0 {
|
||||
return e.matcher.MatchSongs(ctx, songs, count)
|
||||
}
|
||||
|
||||
// Fallback to existing similar artists + top songs algorithm
|
||||
return e.similarSongsFallback(ctx, id, count)
|
||||
}
|
||||
|
||||
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
|
||||
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
|
||||
// a weighted random selection of songs to return as similar songs.
|
||||
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
topCount := max(count, 20)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
weight := topCount * (4 + artistWeight)
|
||||
for _, mf := range topSongs {
|
||||
weightedSongs.Add(mf, weight)
|
||||
weight -= 4
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = addArtist(artist.Artist, weightedSongs, count, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range artist.SimilarArtists {
|
||||
err := addArtist(a, weightedSongs, count, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var similarSongs model.MediaFiles
|
||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||
s, err := weightedSongs.Pick()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting weighted song", err)
|
||||
continue
|
||||
}
|
||||
similarSongs = append(similarSongs, s)
|
||||
}
|
||||
|
||||
return similarSongs, nil
|
||||
}
|
||||
|
||||
func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.findArtist(ctx, artistName, id)
|
||||
if err != nil {
|
||||
|
|
|
|||
299
core/external/provider_similarsongs.go
vendored
Normal file
299
core/external/provider_similarsongs.go
vendored
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSeeds = 5
|
||||
// Subsonic passes the client's count through unbounded, and it ends up as a SQL limit. 500 is
|
||||
// what the widest caller (similarAlbums, limit*5) legitimately asks for.
|
||||
maxSimilarSongs = 500
|
||||
)
|
||||
|
||||
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
// Subsonic passes the client's count straight through: a non-positive one has no valid
|
||||
// interpretation, and an enormous one overflows the +1 in the local agent's query limit.
|
||||
if count <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
count = min(count, maxSimilarSongs)
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
// Genre ids don't resolve via GetEntityByID; look them up before giving up.
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
genre, err := e.ds.Genre(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.seedMix(ctx, count, func() (model.MediaFiles, error) {
|
||||
return e.sampleGenreTracks(ctx, genre, maxSeeds)
|
||||
})
|
||||
}
|
||||
|
||||
// Try entity-specific similarity first, then fall back to seed-track sampling.
|
||||
switch v := entity.(type) {
|
||||
case *model.MediaFile:
|
||||
return e.mixFromAgent(ctx, count,
|
||||
func() ([]agents.Song, error) {
|
||||
return e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
|
||||
},
|
||||
func() (model.MediaFiles, error) {
|
||||
return e.similarSongsFallback(ctx, id, count)
|
||||
})
|
||||
case *model.Album:
|
||||
return e.mixFromAgent(ctx, count,
|
||||
func() ([]agents.Song, error) {
|
||||
return e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
|
||||
},
|
||||
func() (model.MediaFiles, error) {
|
||||
return e.seedMix(ctx, count, func() (model.MediaFiles, error) {
|
||||
return e.sampleAlbumTracks(ctx, v.ID, maxSeeds)
|
||||
})
|
||||
})
|
||||
case *model.Artist:
|
||||
return e.mixFromAgent(ctx, count,
|
||||
func() ([]agents.Song, error) {
|
||||
return e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
|
||||
},
|
||||
func() (model.MediaFiles, error) {
|
||||
return e.similarSongsFallback(ctx, id, count)
|
||||
},
|
||||
func() (model.MediaFiles, error) {
|
||||
return e.seedMix(ctx, count, func() (model.MediaFiles, error) {
|
||||
return e.sampleArtistTracks(ctx, v.ID, maxSeeds)
|
||||
})
|
||||
})
|
||||
case *model.Playlist:
|
||||
return e.seedMix(ctx, count, func() (model.MediaFiles, error) {
|
||||
return e.samplePlaylistTracks(ctx, v.ID, maxSeeds)
|
||||
})
|
||||
default:
|
||||
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
// mixFromAgent returns the agent's recommendations matched to library tracks, topped up from the
|
||||
// fallbacks in order when they don't fill the mix on their own.
|
||||
func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallbacks ...func() (model.MediaFiles, error)) (model.MediaFiles, error) {
|
||||
var matched model.MediaFiles
|
||||
if songs, err := fetch(); err == nil {
|
||||
// Match the whole response: capping at count can stop before a later unique pick.
|
||||
matched, err = e.matcher.MatchSongs(ctx, songs, len(songs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return topUp(ctx, matched, count, fallbacks...)
|
||||
}
|
||||
|
||||
// topUp draws on each source in turn until the mix holds count distinct tracks.
|
||||
func topUp(ctx context.Context, res model.MediaFiles, count int, sources ...func() (model.MediaFiles, error)) (model.MediaFiles, error) {
|
||||
// The matcher can re-emit a track, so a full-looking res may hold fewer than count unique ones.
|
||||
res = dedupByID(res)
|
||||
var lastErr error
|
||||
for _, more := range sources {
|
||||
if len(res) >= count {
|
||||
break
|
||||
}
|
||||
extra, err := more()
|
||||
if err != nil {
|
||||
log.Debug(ctx, "Could not top up a short mix", "have", len(res), "want", count, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
res = dedupByID(append(res, extra...))
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, lastErr
|
||||
}
|
||||
return res[:min(len(res), count)], nil
|
||||
}
|
||||
|
||||
// seedMix samples seed tracks, runs each through the agent chain's per-track similarity and merges
|
||||
// the results, falling back to the seeds themselves so the result is never empty.
|
||||
func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.MediaFiles, error)) (model.MediaFiles, error) {
|
||||
seeds, err := sample()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(seeds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
seeds = seeds[:min(len(seeds), maxSeeds)]
|
||||
|
||||
// The per-seed similarity calls are independent and hit the (possibly remote) agent chain, so
|
||||
// run them concurrently. Best-effort: a seed that errors just contributes nothing.
|
||||
perSeed := make([][]agents.Song, len(seeds))
|
||||
var g errgroup.Group
|
||||
for i, seed := range seeds {
|
||||
g.Go(func() error {
|
||||
if s, err := e.ag.GetSimilarSongsByTrack(ctx, seed.ID, seed.Title, seed.Artist, seed.MbzRecordingID, count); err == nil {
|
||||
perSeed[i] = s
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
_ = g.Wait()
|
||||
|
||||
var songs []agents.Song
|
||||
for _, s := range perSeed {
|
||||
songs = append(songs, s...)
|
||||
}
|
||||
// Match the whole merged set, not just count of it: the matcher re-emits a track when two
|
||||
// seeds recommend it identically, so the duplicates have to be dropped before trimming. Every
|
||||
// seed reaches the shuffle, so no seed can crowd out the others.
|
||||
matched, err := e.matcher.MatchSongs(ctx, songs, len(songs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matched = dedupByID(matched)
|
||||
if len(matched) == 0 {
|
||||
matched = seeds
|
||||
}
|
||||
rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] })
|
||||
if len(matched) > count {
|
||||
matched = matched[:count]
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) {
|
||||
// Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would
|
||||
// mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay.
|
||||
repo := e.ds.Playlist(ctx).Tracks(playlistID, true)
|
||||
if repo == nil {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
// A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated
|
||||
// seed wastes an agent call and can reach the mix twice through the seed fallback.
|
||||
tracks, err := repo.GetAll(model.QueryOptions{
|
||||
Sort: "random",
|
||||
Max: n * 4,
|
||||
Filters: squirrel.Eq{"missing": false},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mfs := dedupByID(tracks.MediaFiles())
|
||||
return mfs[:min(len(mfs), n)], nil
|
||||
}
|
||||
|
||||
func dedupByID(mfs model.MediaFiles) model.MediaFiles {
|
||||
seen := make(map[string]struct{}, len(mfs))
|
||||
return slice.Filter(mfs, func(mf model.MediaFile) bool {
|
||||
if _, dup := seen[mf.ID]; dup {
|
||||
return false
|
||||
}
|
||||
seen[mf.ID] = struct{}{}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (e *provider) sampleAlbumTracks(ctx context.Context, albumID string, n int) (model.MediaFiles, error) {
|
||||
return e.sampleTracks(ctx, squirrel.Eq{"album_id": albumID}, n)
|
||||
}
|
||||
|
||||
func (e *provider) sampleArtistTracks(ctx context.Context, artistID string, n int) (model.MediaFiles, error) {
|
||||
// media_file.artist_id is the deprecated primary artist, so it misses an artist credited only
|
||||
// on the album, as on compilations. Same filter the artist listings use.
|
||||
filter := persistence.ParticipantIDFilter("media_file", artistID, model.RoleArtist, model.RoleAlbumArtist)
|
||||
return e.sampleTracks(ctx, filter, n)
|
||||
}
|
||||
|
||||
func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n int) (model.MediaFiles, error) {
|
||||
return e.sampleTracks(ctx, persistence.SongGenres.ByID(genre.ID), n)
|
||||
}
|
||||
|
||||
// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so
|
||||
// missing files would surface as unplayable entries.
|
||||
func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) {
|
||||
return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
||||
Filters: squirrel.And{filter, squirrel.Eq{"missing": false}},
|
||||
Max: n,
|
||||
})
|
||||
}
|
||||
|
||||
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
|
||||
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
|
||||
// a weighted random selection of songs to return as similar songs.
|
||||
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
topCount := max(count, 20)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
weight := topCount * (4 + artistWeight)
|
||||
for _, mf := range topSongs {
|
||||
weightedSongs.Add(mf, weight)
|
||||
weight -= 4
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = addArtist(artist.Artist, weightedSongs, count, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range artist.SimilarArtists {
|
||||
err := addArtist(a, weightedSongs, count, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Count distinct tracks, not picks: a collaboration sits in the chooser once per artist that
|
||||
// lists it, and letting those repeats consume the budget strands unique candidates.
|
||||
var similarSongs model.MediaFiles
|
||||
picked := map[string]struct{}{}
|
||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||
s, err := weightedSongs.Pick()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting weighted song", err)
|
||||
continue
|
||||
}
|
||||
if _, dup := picked[s.ID]; dup {
|
||||
continue
|
||||
}
|
||||
picked[s.ID] = struct{}{}
|
||||
similarSongs = append(similarSongs, s)
|
||||
}
|
||||
|
||||
return similarSongs, nil
|
||||
}
|
||||
683
core/external/provider_similarsongs_test.go
vendored
683
core/external/provider_similarsongs_test.go
vendored
|
|
@ -3,6 +3,8 @@ package external_test
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
|
|
@ -26,6 +28,9 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var albumRepo *mockAlbumRepo
|
||||
var playlistRepo *tests.MockPlaylistRepo
|
||||
var playlistTrackRepo *tests.MockPlaylistTrackRepo
|
||||
var genreRepo *tests.MockedGenreRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
|
|
@ -34,11 +39,17 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
albumRepo = newMockAlbumRepo()
|
||||
playlistTrackRepo = &tests.MockPlaylistTrackRepo{}
|
||||
playlistRepo = tests.CreateMockPlaylistRepo()
|
||||
playlistRepo.TracksRepo = playlistTrackRepo
|
||||
genreRepo = &tests.MockedGenreRepo{}
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
MockedPlaylist: playlistRepo,
|
||||
MockedGenre: genreRepo,
|
||||
}
|
||||
|
||||
mockAgent = &mockSimilarArtistAgent{}
|
||||
|
|
@ -53,6 +64,18 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||||
})
|
||||
|
||||
// Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive:
|
||||
// no spec here asserts how many times the entity is looked up.
|
||||
stubTrackEntity := func() {
|
||||
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
|
||||
artist := model.Artist{ID: "artist-1", Name: "Artist"}
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Maybe()
|
||||
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
|
||||
}
|
||||
|
||||
Describe("dispatch by entity type", func() {
|
||||
Context("when ID is a MediaFile (track)", func() {
|
||||
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
|
||||
|
|
@ -71,7 +94,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 1).
|
||||
Return([]agents.Song{
|
||||
{Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}},
|
||||
}, nil).Once()
|
||||
|
|
@ -114,7 +137,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
return false
|
||||
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
|
|
@ -164,6 +187,76 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
|
||||
It("tops the mix up with the fallback when the agent's picks alone are too few", func() {
|
||||
stubTrackEntity()
|
||||
|
||||
// The agent knows one track of the three asked for.
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3).
|
||||
Return([]agents.Song{{Name: "Agent Pick", MBID: "mbid-agent"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).
|
||||
Return(model.MediaFiles{{ID: "agent-1", Title: "Agent Pick", MbzRecordingID: "mbid-agent"}}, nil).Once()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).
|
||||
Return(model.MediaFiles{{ID: "song-1", Title: "Song One", MbzRecordingID: "mbid-1"}}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids(songs)).To(ConsistOf("agent-1", "song-1"))
|
||||
})
|
||||
|
||||
It("reaches a unique pick that sits past the count-th repeat", func() {
|
||||
stubTrackEntity()
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]agents.Artist{}, nil).Maybe()
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]agents.Song{}, nil).Maybe()
|
||||
|
||||
// "Song B" sits past the second repeat, so a matcher capped at count never reaches it.
|
||||
repeated := agents.Song{Name: "Song A", MBID: "mbid-a"}
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 2).
|
||||
Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
||||
{ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"},
|
||||
{ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"},
|
||||
}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids(songs)).To(ConsistOf("t-a", "t-b"))
|
||||
})
|
||||
|
||||
It("keeps topping up when the agent's picks repeat a track", func() {
|
||||
stubTrackEntity()
|
||||
|
||||
// The matcher re-emits a track when the same input song repeats, so these three
|
||||
// picks resolve to only two distinct library tracks.
|
||||
repeated := agents.Song{Name: "Song A", MBID: "mbid-a"}
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3).
|
||||
Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
||||
{ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"},
|
||||
{ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"},
|
||||
}, nil).Once()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "Song C", MBID: "mbid-c"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).
|
||||
Return(model.MediaFiles{{ID: "t-c", Title: "Song C", MbzRecordingID: "mbid-c"}}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is an Album", func() {
|
||||
|
|
@ -175,7 +268,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 1).
|
||||
Return([]agents.Song{
|
||||
{Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
|
||||
}, nil).Once()
|
||||
|
|
@ -196,53 +289,75 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
return hasEq
|
||||
})).Return(model.MediaFiles{matchedSong}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
|
||||
songs, err := provider.SimilarSongs(ctx, "album-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("matched-1"))
|
||||
})
|
||||
|
||||
It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() {
|
||||
It("falls back to sampled album tracks when GetSimilarSongsByAlbum returns ErrNotFound", func() {
|
||||
album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
|
||||
artist := model.Artist{ID: "artist-1", Name: "Artist"}
|
||||
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"}
|
||||
|
||||
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
|
||||
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything).
|
||||
Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album
|
||||
// and recursively calls getArtist(v.AlbumArtistID)
|
||||
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
|
||||
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
sql, args, err := opt.Filters.ToSql()
|
||||
return err == nil && strings.Contains(sql, "album_id") &&
|
||||
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("album-1"))
|
||||
})).Return(model.MediaFiles{seed}, nil).Once()
|
||||
|
||||
// Then it recurses with the artist-1 ID
|
||||
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
|
||||
// seedMix falls back to the seed itself when the agent finds nothing.
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "seed-1", "Seed", "Artist", "", mock.Anything).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
Expect(songs[0].ID).To(Equal("seed-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is an Album and the album agent returns nothing (AudioMuse-only)", func() {
|
||||
It("samples the album's tracks and returns their track-similars", func() {
|
||||
album := model.Album{ID: "al-1", Name: "The Album", AlbumArtist: "A"}
|
||||
artistRepo.On("Get", "al-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "al-1").Return(&album, nil).Once()
|
||||
|
||||
// AudioMuse doesn't implement album similarity -> empty.
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-1", "The Album", "A", "", 5).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
// sampleAlbumTracks -> GetRandom(album_id) -> one seed track
|
||||
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
sql, args, err := opt.Filters.ToSql()
|
||||
return err == nil && strings.Contains(sql, "album_id") &&
|
||||
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("al-1"))
|
||||
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "A"}}, nil).Once()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "A", "", 5).
|
||||
Return([]agents.Song{{Name: "AudioMuseResult", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once()
|
||||
|
||||
// Matcher resolves "AudioMuseResult" -> a real MediaFile credited to artist "A".
|
||||
aArtist := model.Artist{ID: "a1", Name: "A", OrderArtistName: "a"}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "m1", Title: "AudioMuseResult", Artist: "A",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{{Artist: aArtist}}},
|
||||
}
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{aArtist}, nil).Maybe()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{matchedTrack}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "al-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
Expect(songs[0].ID).To(Equal("m1"))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -252,7 +367,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 1).
|
||||
Return([]agents.Song{
|
||||
{Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
|
||||
}, nil).Once()
|
||||
|
|
@ -273,13 +388,454 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
return hasEq
|
||||
})).Return(model.MediaFiles{matchedSong}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("matched-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is an Artist and both the artist agent and the similar-artists fallback are empty", func() {
|
||||
It("samples the artist's tracks and returns their track-similars", func() {
|
||||
artist := model.Artist{ID: "ar-1", Name: "The Artist"}
|
||||
// Get is called twice: once to resolve the entity, once inside similarSongsFallback.
|
||||
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 5).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
// similarSongsFallback: no similar artists, no top songs -> empty (allow its lookups).
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]agents.Artist{}, nil).Maybe()
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]agents.Song{}, nil).Maybe()
|
||||
|
||||
// Seeds come from the participant join covering both roles, so an artist credited
|
||||
// only on the album (compilations, classical) still yields seeds.
|
||||
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
sql, args, err := opt.Filters.ToSql()
|
||||
return err == nil && strings.Contains(sql, "media_file_artists") &&
|
||||
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("ar-1")) &&
|
||||
slices.Contains(args, any(model.RoleAlbumArtist.String())) && slices.Contains(args, any(model.RoleArtist.String()))
|
||||
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5).
|
||||
Return([]agents.Song{{Name: "Result"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Result"}}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "ar-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is an Artist and the similar-artists fallback can't fill the mix", func() {
|
||||
It("tops the mix up with the artist's own track-similars", func() {
|
||||
artist := model.Artist{ID: "ar-1", Name: "Thin Artist"}
|
||||
topSong := model.MediaFile{ID: "top-1", Title: "Top Song", ArtistID: "ar-1", MbzRecordingID: "mbid-top"}
|
||||
|
||||
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "Thin Artist", "", 5).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
// No similar artist is in the library, so the fallback yields only the seed artist's
|
||||
// own matching top song: one track for a mix of five.
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "Thin Artist", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "Thin Artist", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "Top Song", MBID: "mbid-top"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{topSong}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetRandom", mock.Anything).
|
||||
Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "Thin Artist"}}, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "Thin Artist", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "Mix Song", MBID: "mbid-mix"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).
|
||||
Return(model.MediaFiles{{ID: "mix-1", Title: "Mix Song", MbzRecordingID: "mbid-mix"}}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "ar-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids(songs)).To(ConsistOf("top-1", "mix-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is an Artist and the agent plus the similar-artists fallback already fill the mix", func() {
|
||||
It("does not pay for seed-track sampling", func() {
|
||||
artist := model.Artist{ID: "ar-1", Name: "The Artist"}
|
||||
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 3).
|
||||
Return([]agents.Song{{Name: "A", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
||||
{ID: "t-a", Title: "A", MbzRecordingID: "mbid-a"},
|
||||
{ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"},
|
||||
}, nil).Once()
|
||||
|
||||
// The similar-artists fallback supplies the third track, so the mix is full.
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "The Artist", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "The Artist", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "C", MBID: "mbid-c"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).
|
||||
Return(model.MediaFiles{{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetRandom", mock.Anything).Return(model.MediaFiles{{ID: "seed"}}, nil).Maybe()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]agents.Song{}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "ar-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
|
||||
mediaFileRepo.AssertNotCalled(GinkgoT(), "GetRandom", mock.Anything)
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is a Playlist", func() {
|
||||
It("samples playlist tracks and returns their track-similars", func() {
|
||||
pls := model.Playlist{ID: "pl-1", Name: "My List"}
|
||||
seedTrack := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"}
|
||||
|
||||
// GetEntityByID order: Artist, Album, Playlist(hit)
|
||||
artistRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
|
||||
// samplePlaylistTracks -> Tracks(...).GetAll -> one seed track, bounded+randomized in SQL
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{
|
||||
{MediaFile: seedTrack},
|
||||
})
|
||||
|
||||
// seedMix -> GetSimilarSongsByTrack for the seed
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5).
|
||||
Return([]agents.Song{{Name: "Similar", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once()
|
||||
|
||||
// Matcher resolves "Similar" -> a real MediaFile (allow the matcher's lookups).
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{{ID: "a1", Name: "A"}}, nil).Maybe()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("asks for a smart-playlist refresh so an unevaluated one still yields seeds", func() {
|
||||
// A smart playlist materializes no playlist_tracks until it is evaluated, so sampling
|
||||
// without the refresh would mix an empty seed set.
|
||||
pls := model.Playlist{ID: "pl-smart", Name: "Smart"}
|
||||
artistRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
||||
})
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]agents.Song{}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-smart", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
Expect(playlistRepo.TracksRefreshed).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns an error instead of panicking when the track repository is unavailable", func() {
|
||||
// Tracks() logs and returns a nil repository when its own lookup fails.
|
||||
pls := model.Playlist{ID: "pl-nil", Name: "Gone"}
|
||||
artistRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
playlistRepo.TracksRepo = nil
|
||||
|
||||
_, err := provider.SimilarSongs(ctx, "pl-nil", 5)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("does not seed a mix with missing tracks", func() {
|
||||
pls := model.Playlist{ID: "pl-missing", Name: "Missing"}
|
||||
artistRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
||||
})
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]agents.Song{}, nil).Maybe()
|
||||
|
||||
_, err := provider.SimilarSongs(ctx, "pl-missing", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sql, args, sqlErr := playlistTrackRepo.Options.Filters.ToSql()
|
||||
Expect(sqlErr).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("missing"))
|
||||
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
|
||||
})
|
||||
|
||||
It("keeps a later seed's picks when an earlier seed overlaps it", func() {
|
||||
// The matcher stops once it has count matches, and it re-emits the shared track, so
|
||||
// matching only count of the merged set would spend slots on the duplicate.
|
||||
pls := model.Playlist{ID: "pl-overlap2", Name: "Overlap2"}
|
||||
artistRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
||||
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
|
||||
})
|
||||
shared := agents.Song{ID: "x", Name: "X"}
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3).
|
||||
Return([]agents.Song{shared}, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3).
|
||||
Return([]agents.Song{shared, {ID: "y", Name: "Y"}, {ID: "z", Name: "Z"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
||||
{ID: "x", Title: "X"}, {ID: "y", Title: "Y"}, {ID: "z", Title: "Z"},
|
||||
}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-overlap2", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(3), "the duplicate must not cost a slot")
|
||||
})
|
||||
|
||||
It("returns a track once when two seeds recommend it", func() {
|
||||
// The matcher re-emits a track when two inputs are identical, so overlapping
|
||||
// recommendations would otherwise take two slots in the mix.
|
||||
pls := model.Playlist{ID: "pl-overlap", Name: "Overlap"}
|
||||
artistRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
||||
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
|
||||
})
|
||||
shared := agents.Song{ID: "m1", Name: "Shared"}
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5).
|
||||
Return([]agents.Song{shared}, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 5).
|
||||
Return([]agents.Song{shared}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Shared"}}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-overlap", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1), "the shared recommendation must appear once")
|
||||
})
|
||||
|
||||
It("does not seed a mix twice with a track the playlist repeats", func() {
|
||||
pls := model.Playlist{ID: "pl-dup", Name: "Dupes"}
|
||||
artistRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
// The same file at two positions, which playlists allow.
|
||||
dup := model.MediaFile{ID: "s1", Title: "Seed One"}
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{{MediaFile: dup}, {MediaFile: dup}})
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-dup", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1), "the repeated track must appear once")
|
||||
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 1)
|
||||
})
|
||||
|
||||
It("clamps an enormous count before it reaches the queries", func() {
|
||||
// count+1 in the local agent overflows on MaxInt64, and GetRandom omits the SQL
|
||||
// limit unless Max is positive, so the query would hydrate the whole library.
|
||||
pls := model.Playlist{ID: "pl-huge", Name: "Huge"}
|
||||
artistRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
||||
})
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 500).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
_, err := provider.SimilarSongs(ctx, "pl-huge", math.MaxInt64)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
agentsCombined.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("does not panic when the caller asks for a non-positive count", func() {
|
||||
// Subsonic passes count straight through, so a negative one reaches the provider.
|
||||
// The guard returns before any lookup, so no repository setup is needed.
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-neg", -1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("blends results from every seed, not just the first", func() {
|
||||
pls := model.Playlist{ID: "pl-blend", Name: "Blend"}
|
||||
artistRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
||||
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
|
||||
})
|
||||
|
||||
// Each seed returns a full count's worth, as a real similarity agent does. Asking for
|
||||
// one more than seed one can supply makes this independent of the final shuffle:
|
||||
// three of the four matches always include a seed-two track.
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3).
|
||||
Return([]agents.Song{{ID: "a1", Name: "A1"}, {ID: "a2", Name: "A2"}}, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3).
|
||||
Return([]agents.Song{{ID: "b1", Name: "B1"}, {ID: "b2", Name: "B2"}}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
||||
{ID: "a1", Title: "A1"}, {ID: "a2", Title: "A2"},
|
||||
{ID: "b1", Title: "B1"}, {ID: "b2", Title: "B2"},
|
||||
}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-blend", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(3))
|
||||
ids := ids(songs)
|
||||
Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix")
|
||||
})
|
||||
|
||||
It("falls back to the seed tracks themselves when no similar songs are found", func() {
|
||||
pls := model.Playlist{ID: "pl-2", Name: "Fallback List"}
|
||||
seed1 := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"}
|
||||
seed2 := model.MediaFile{ID: "s2", Title: "Seed Two", Artist: "B"}
|
||||
|
||||
artistRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
|
||||
playlistTrackRepo.SetData(model.PlaylistTracks{
|
||||
{MediaFile: seed1},
|
||||
{MediaFile: seed2},
|
||||
})
|
||||
|
||||
// Both seeds come back empty, so the mix must fall back to the seeds themselves.
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "B", "", 5).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-2", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect([]string{songs[0].ID, songs[1].ID}).To(ConsistOf("s1", "s2"))
|
||||
})
|
||||
|
||||
It("samples the album when the agent's picks are not in this library", func() {
|
||||
// Last.fm answers from its own catalogue, so a small library can match none of it.
|
||||
// An unmatched non-empty answer must not shortcut the sampling fallback.
|
||||
album := model.Album{ID: "al-nm", Name: "NoMatch", AlbumArtist: "A"}
|
||||
artistRepo.On("Get", "al-nm").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "al-nm").Return(&album, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-nm", "NoMatch", "A", "", 5).
|
||||
Return([]agents.Song{{Name: "Not In Library"}}, nil).Once()
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe()
|
||||
|
||||
// The matcher resolves nothing; the sampled seed is what reaches the mix.
|
||||
mediaFileRepo.On("GetRandom", mock.Anything).
|
||||
Return(model.MediaFiles{{ID: "s1", Title: "Album Track"}}, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Album Track", "", "", 5).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "al-nm", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("s1"))
|
||||
})
|
||||
|
||||
It("caps agent calls at maxSeeds when the repository ignores the bound", func() {
|
||||
// Isolates seedMix's own cap: the album sampler bounds the query with Max, so this
|
||||
// exercises the guard by having the repo hand back more rows than were asked for.
|
||||
album := model.Album{ID: "al-cap", Name: "Cap", AlbumArtist: "A"}
|
||||
artistRepo.On("Get", "al-cap").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "al-cap").Return(&album, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-cap", "Cap", "A", "", 5).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
var overflow model.MediaFiles
|
||||
for _, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"} {
|
||||
overflow = append(overflow, model.MediaFile{ID: id, Title: id})
|
||||
}
|
||||
mediaFileRepo.On("GetRandom", mock.Anything).Return(overflow, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5).
|
||||
Return([]agents.Song{}, nil)
|
||||
|
||||
_, err := provider.SimilarSongs(ctx, "al-cap", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5)
|
||||
})
|
||||
|
||||
It("still finds maxSeeds distinct seeds when the leading positions repeat", func() {
|
||||
// The sampler over-fetches for exactly this case: a page bounded at maxSeeds could
|
||||
// be entirely one repeated file and collapse to a single seed.
|
||||
pls := model.Playlist{ID: "pl-3", Name: "Big List"}
|
||||
dup := model.MediaFile{ID: "dup", Title: "Dup", Artist: "A"}
|
||||
tracks := model.PlaylistTracks{
|
||||
{MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup},
|
||||
}
|
||||
for _, id := range []string{"seed-1", "seed-2", "seed-3", "seed-4", "seed-5"} {
|
||||
tracks = append(tracks, model.PlaylistTrack{
|
||||
MediaFile: model.MediaFile{ID: id, Title: id, Artist: "A"},
|
||||
})
|
||||
}
|
||||
|
||||
artistRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once()
|
||||
playlistRepo.SetData(model.Playlists{pls})
|
||||
playlistTrackRepo.SetData(tracks)
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5).
|
||||
Return([]agents.Song{}, nil)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "pl-3", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(5))
|
||||
Expect(playlistTrackRepo.Options.Sort).To(Equal("random"))
|
||||
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5)
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is a Genre (not resolved by GetEntityByID)", func() {
|
||||
It("samples genre songs and returns their track-similars", func() {
|
||||
// GetEntityByID misses everywhere; the empty/auto-created mocks need no setup.
|
||||
artistRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
|
||||
genreRepo.Data = map[string]model.Genre{"g-1": {ID: "g-1", Name: "Jazz"}}
|
||||
|
||||
// sampleGenreTracks -> GetRandom with the indexed media_file_tags semi-join (not a json_tree scan)
|
||||
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
if opt.Filters == nil {
|
||||
return false
|
||||
}
|
||||
sql, args, err := opt.Filters.ToSql()
|
||||
return err == nil && strings.Contains(sql, "media_file_tags") &&
|
||||
!strings.Contains(sql, "json_tree") && strings.Contains(sql, "missing") && slices.Contains(args, any(false))
|
||||
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5).
|
||||
Return([]agents.Song{{Name: "Similar"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "g-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
It("returns similar songs from main artist and similar artists", func() {
|
||||
|
|
@ -341,6 +897,60 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
}
|
||||
})
|
||||
|
||||
It("keeps picking until the fallback holds count distinct tracks", func() {
|
||||
// A collaboration in two artists' top songs lands in the chooser twice. Picking a fixed
|
||||
// count of entries lets those duplicates eat the budget and strand unique candidates.
|
||||
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist One", ArtistID: "artist-1"}
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
|
||||
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Twice()
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist One", "", 3).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{{Name: "Similar Artist"}}, nil).Once()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return opt.Max == 0 && ok
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Or)
|
||||
return opt.Max == 0 && ok
|
||||
})).Return(model.Artists{similarArtist}, nil).Once()
|
||||
|
||||
shared := model.MediaFile{ID: "t-a", Title: "Shared", MbzRecordingID: "mbid-a"}
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "Shared", MBID: "mbid-a"}}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).
|
||||
Return(model.MediaFiles{shared}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Shared", MBID: "mbid-a"},
|
||||
{Name: "B", MBID: "mbid-b"},
|
||||
{Name: "C", MBID: "mbid-c"},
|
||||
}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{
|
||||
shared,
|
||||
{ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"},
|
||||
{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"},
|
||||
}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when artist is not found", func() {
|
||||
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
|
|
@ -383,7 +993,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
|
|
@ -412,6 +1022,13 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
|||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, errors.New("error getting top songs")).Once()
|
||||
|
||||
// Fallback yields nothing, so the sampling path is tried and also finds no tracks.
|
||||
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
sql, args, err := opt.Filters.ToSql()
|
||||
return err == nil && strings.Contains(sql, "artist_id") &&
|
||||
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("artist-1"))
|
||||
})).Return(model.MediaFiles{}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
|
@ -153,6 +154,17 @@ func getFSInfo(path string) *insights.FSInfo {
|
|||
return &info
|
||||
}
|
||||
|
||||
// installedPackage returns the official installer format used, as written by our own packagers.
|
||||
func installedPackage() string {
|
||||
data, _ := os.ReadFile(filepath.Join(conf.Server.DataFolder.String(), ".package"))
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// hostingPlatform is env-based, not a file, as app stores can only inject env vars into our image.
|
||||
func hostingPlatform() string {
|
||||
return strings.TrimSpace(os.Getenv("ND_PLATFORM"))
|
||||
}
|
||||
|
||||
var staticData = sync.OnceValue(func() insights.Data {
|
||||
// Basic info
|
||||
data := insights.Data{
|
||||
|
|
@ -165,11 +177,8 @@ var staticData = sync.OnceValue(func() insights.Data {
|
|||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
}
|
||||
data.OS.Package = installedPackage()
|
||||
data.Platform = hostingPlatform()
|
||||
|
||||
// OS info
|
||||
data.OS.Type = runtime.GOOS
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ type Data struct {
|
|||
InsightsID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Build struct {
|
||||
// Platform is the app store or hosting provider this instance runs on, self-declared via ND_PLATFORM
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Build struct {
|
||||
// build settings used by the Go compiler
|
||||
Settings map[string]string `json:"settings"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
|
|
|
|||
70
core/metrics/insights_internal_test.go
Normal file
70
core/metrics/insights_internal_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("installedPackage", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
})
|
||||
|
||||
It("returns empty when there's no .package file", func() {
|
||||
Expect(installedPackage()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("reads the .package file from the data folder", func() {
|
||||
writePackageFile("deb")
|
||||
|
||||
Expect(installedPackage()).To(Equal("deb"))
|
||||
})
|
||||
|
||||
It("trims surrounding whitespace, as the msi packager writes a trailing newline", func() {
|
||||
writePackageFile("msi\n")
|
||||
|
||||
Expect(installedPackage()).To(Equal("msi"))
|
||||
})
|
||||
|
||||
It("ignores ND_PLATFORM", func() {
|
||||
GinkgoT().Setenv("ND_PLATFORM", "zimaos")
|
||||
|
||||
Expect(installedPackage()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("hostingPlatform", func() {
|
||||
BeforeEach(func() {
|
||||
// Setenv registers the restore, then unset so an inherited value can't leak in
|
||||
GinkgoT().Setenv("ND_PLATFORM", "")
|
||||
Expect(os.Unsetenv("ND_PLATFORM")).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty when ND_PLATFORM is not set", func() {
|
||||
Expect(hostingPlatform()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("reads ND_PLATFORM", func() {
|
||||
GinkgoT().Setenv("ND_PLATFORM", "zimaos")
|
||||
|
||||
Expect(hostingPlatform()).To(Equal("zimaos"))
|
||||
})
|
||||
|
||||
It("trims surrounding whitespace", func() {
|
||||
GinkgoT().Setenv("ND_PLATFORM", " pikapods\n")
|
||||
|
||||
Expect(hostingPlatform()).To(Equal("pikapods"))
|
||||
})
|
||||
})
|
||||
|
||||
func writePackageFile(content string) {
|
||||
GinkgoHelper()
|
||||
path := filepath.Join(conf.Server.DataFolder.String(), ".package")
|
||||
Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed())
|
||||
}
|
||||
17
core/metrics/metrics_suite_test.go
Normal file
17
core/metrics/metrics_suite_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMetrics(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Metrics Suite")
|
||||
}
|
||||
|
|
@ -12,8 +12,10 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/zeebo/xxh3"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
|
|
@ -57,10 +59,12 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo
|
|||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
hasher := xxh3.New()
|
||||
reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
|
||||
if err := s.parseM3U(ctx, pls, nil, reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pls.ImportedHash = fingerprint(hasher)
|
||||
if err := s.updatePlaylist(ctx, pls, sync); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -138,7 +142,9 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
|
|||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
// Hash the bytes the parser consumes, giving every imported playlist a content fingerprint
|
||||
hasher := xxh3.New()
|
||||
reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
|
|
@ -146,7 +152,15 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
|
|||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
if err != nil {
|
||||
return pls, err
|
||||
}
|
||||
pls.ImportedHash = fingerprint(hasher)
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func fingerprint(h *xxh3.Hasher) string {
|
||||
return id.Encode(h.Sum128().Bytes())
|
||||
}
|
||||
|
||||
// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
|
||||
|
|
@ -179,6 +193,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
|
|||
}
|
||||
|
||||
if err == nil {
|
||||
// Only smart playlists skip on an unchanged file; M3U must re-run so newly-added tracks resolve.
|
||||
if !forceSync && newPls.IsSmartPlaylist() && newPls.ImportedHash != "" && newPls.ImportedHash == pls.ImportedHash {
|
||||
log.Trace(ctx, "Playlist file unchanged since last import, skipping", "playlist", pls.Name, "path", pls.Path)
|
||||
*newPls = *pls // callers must see the stored record, so e.g. ImportFile can still flip Sync
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
|
|
@ -187,6 +207,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
|
|||
newPls.Public = pls.Public
|
||||
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
|
||||
newPls.EvaluatedAt = nil // force re-evaluation on next read
|
||||
if newPls.IsSmartPlaylist() {
|
||||
// Tracks aren't materialized at parse time; carry the stored counters so callers see real values
|
||||
newPls.SongCount = pls.SongCount
|
||||
newPls.Duration = pls.Duration
|
||||
newPls.Size = pls.Size
|
||||
}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ import (
|
|||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/zeebo/xxh3"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
|
|
@ -307,6 +309,33 @@ var _ = Describe("Playlists - Import", func() {
|
|||
Expect(pls.ID).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("stores a content hash but re-imports unchanged M3U playlists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
|
||||
first, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(first.ImportedHash).ToNot(BeEmpty())
|
||||
|
||||
// Re-import with a matching stored hash: M3U must still be re-imported, not skipped.
|
||||
existingPls := &model.Playlist{
|
||||
ID: "m3u-id", Name: "Test", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", ImportedHash: first.ImportedHash,
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
mockPlsRepo.Last = nil
|
||||
|
||||
_, err = ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("clears ExternalImageURL on re-scan when directive is removed", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
|
|
@ -371,6 +400,85 @@ var _ = Describe("Playlists - Import", func() {
|
|||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
|
||||
})
|
||||
|
||||
It("preserves counters when re-importing an existing smart playlist", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}],"sort":"title","order":"asc"}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id",
|
||||
Name: "My Smart",
|
||||
Path: plsFile,
|
||||
Sync: true,
|
||||
OwnerID: "123",
|
||||
SongCount: 42,
|
||||
Duration: 123.4,
|
||||
Size: 5000,
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
Expect(mockPlsRepo.Last.IsSmartPlaylist()).To(BeTrue())
|
||||
Expect(mockPlsRepo.Last.SongCount).To(Equal(42))
|
||||
Expect(mockPlsRepo.Last.Duration).To(Equal(float32(123.4)))
|
||||
Expect(mockPlsRepo.Last.Size).To(Equal(int64(5000)))
|
||||
})
|
||||
|
||||
It("skips re-import when the smart playlist file content is unchanged", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf(nsp),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(BeNil()) // Put never called: nothing re-written
|
||||
})
|
||||
|
||||
It("re-imports when the smart playlist file content changed", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf("old content"),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil()) // Put called: file changed
|
||||
Expect(mockPlsRepo.Last.ImportedHash).To(Equal(hashOf(nsp)))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
|
||||
|
|
@ -760,6 +868,37 @@ var _ = Describe("Playlists - Import", func() {
|
|||
Expect(pls.ID).To(Equal("existing-id"))
|
||||
Expect(pls.Sync).To(BeTrue())
|
||||
})
|
||||
|
||||
It("unsyncs a synced smart playlist with sync=false even when content is unchanged", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
|
||||
mockFolderRepo := &mockFolderRepoForImport{
|
||||
folder: &model.Folder{
|
||||
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
|
||||
},
|
||||
}
|
||||
ds.MockedFolder = mockFolderRepo
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf(nsp),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
pls, err := ps.ImportFile(ctx, plsFile, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ID).To(Equal("smart-id"))
|
||||
Expect(pls.Sync).To(BeFalse())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ImportM3U", func() {
|
||||
|
|
@ -1083,3 +1222,7 @@ func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.F
|
|||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func hashOf(content string) string {
|
||||
return id.Encode(xxh3.Hash128([]byte(content)).Bytes())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,12 +130,13 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
// Ownership first: a non-owner must get ErrNotAuthorized, not a read-only conflict.
|
||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
if !pls.TracksEditable() {
|
||||
return model.ErrPlaylistNotEditable
|
||||
}
|
||||
} else {
|
||||
pls = &model.Playlist{Name: name}
|
||||
pls.OwnerID = usr.ID
|
||||
|
|
@ -230,14 +231,14 @@ func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playli
|
|||
return pls, nil
|
||||
}
|
||||
|
||||
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
|
||||
// checkTracksEditable verifies the user owns the playlist and its tracks are editable.
|
||||
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
return nil, model.ErrNotAuthorized
|
||||
if !pls.TracksEditable() {
|
||||
return nil, model.ErrPlaylistNotEditable
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ var _ = Describe("Playlists", func() {
|
|||
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true},
|
||||
"pls-synced-other": {ID: "pls-synced-other", Name: "Other's Synced", OwnerID: "other-user", Sync: true, Public: true},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
})
|
||||
|
|
@ -145,6 +147,18 @@ var _ = Describe("Playlists", func() {
|
|||
It("denies replacing tracks on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
|
||||
It("denies replacing tracks on a synced playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-synced", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
|
||||
It("denies a non-owner with authorization, not a conflict, on a public synced playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-synced-other", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
|
@ -159,6 +173,7 @@ var _ = Describe("Playlists", func() {
|
|||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
|
@ -191,13 +206,13 @@ var _ = Describe("Playlists", func() {
|
|||
It("denies adding tracks to a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
|
||||
It("denies removing tracks from a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
|
||||
It("allows metadata updates on a smart playlist", func() {
|
||||
|
|
@ -205,6 +220,18 @@ var _ = Describe("Playlists", func() {
|
|||
err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies adding tracks to a synced playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-synced", nil, nil, nil, []string{"song-1"}, nil)
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
|
||||
It("allows metadata updates on a synced playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-synced", new("Renamed Synced"), nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AddTracks", func() {
|
||||
|
|
@ -216,7 +243,8 @@ var _ = Describe("Playlists", func() {
|
|||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
|
@ -246,7 +274,13 @@ var _ = Describe("Playlists", func() {
|
|||
It("denies editing smart playlists", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
|
||||
It("denies editing synced playlists", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-synced", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
|
|
@ -280,7 +314,7 @@ var _ = Describe("Playlists", func() {
|
|||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
|
|
@ -314,7 +348,7 @@ var _ = Describe("Playlists", func() {
|
|||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod
|
|||
if rulesChanged {
|
||||
current.Rules = entity.Rules
|
||||
current.EvaluatedAt = nil // force re-evaluation on next read
|
||||
current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it
|
||||
}
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
|
|
|
|||
|
|
@ -160,6 +160,25 @@ var _ = Describe("REST Adapter", func() {
|
|||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
})
|
||||
|
||||
It("invalidates the imported hash when rules change, so the next scan re-syncs the file", func() {
|
||||
mockPlsRepo.Data["smart-1"] = &model.Playlist{
|
||||
ID: "smart-1",
|
||||
Name: "Smart Playlist",
|
||||
OwnerID: "user-1",
|
||||
Path: "/music/smart.nsp",
|
||||
Sync: true,
|
||||
ImportedHash: hashOf("file content"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
|
||||
pls := &model.Playlist{Rules: newRules}
|
||||
err := repo.Update("smart-1", pls, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("allows toggling sync for file-backed playlists", func() {
|
||||
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
mockPlsRepo.Data["file-pls"] = &model.Playlist{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package scrobbler
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
|
|
@ -11,6 +12,7 @@ import (
|
|||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
|
|
@ -43,6 +45,10 @@ type PlaybackSession struct {
|
|||
PositionMs int64
|
||||
PlaybackRate float64
|
||||
LastReport time.Time
|
||||
|
||||
// Verdict from the last report, for the expiry callback: its context carries only
|
||||
// a stub user, so it cannot evaluate the filter itself.
|
||||
filtered bool
|
||||
}
|
||||
|
||||
type Submission struct {
|
||||
|
|
@ -68,8 +74,9 @@ type nowPlayingEntry struct {
|
|||
}
|
||||
|
||||
type playbackReportEntry struct {
|
||||
ctx context.Context
|
||||
info PlaybackSession
|
||||
ctx context.Context
|
||||
info PlaybackSession
|
||||
filtered bool
|
||||
}
|
||||
|
||||
type PlayTracker interface {
|
||||
|
|
@ -145,7 +152,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
|||
log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
|
||||
info.State = StateExpired
|
||||
info.LastReport = time.Now()
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
p.enqueuePlaybackReport(ctx, info, info.filtered)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -273,6 +280,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
|
||||
now := time.Now()
|
||||
|
||||
// One verdict per report, reused by every dispatch below, so a filter reading
|
||||
// annotations cannot decide differently on either side of incPlay.
|
||||
var filtered bool
|
||||
|
||||
switch params.State {
|
||||
case StateStarting:
|
||||
// Clients may send starting/playing unordered; a late "starting" must not downgrade
|
||||
|
|
@ -285,8 +296,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered = p.isFilteredOut(ctx, mf)
|
||||
info := PlaybackSession{
|
||||
MediaFile: *mf,
|
||||
filtered: filtered,
|
||||
Start: now,
|
||||
UserId: user.ID,
|
||||
Username: user.UserName,
|
||||
|
|
@ -309,7 +322,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
if err != nil {
|
||||
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
p.enqueuePlaybackReport(ctx, info, filtered)
|
||||
|
||||
case StatePlaying, StatePaused:
|
||||
info, getErr := p.playMap.Get(clientId)
|
||||
|
|
@ -331,6 +344,8 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
info.PositionMs = params.PositionMs
|
||||
info.PlaybackRate = params.PlaybackRate
|
||||
info.LastReport = now
|
||||
filtered = p.isFilteredOut(ctx, &info.MediaFile)
|
||||
info.filtered = filtered
|
||||
ttl := 30 * time.Minute
|
||||
if params.State == StatePlaying {
|
||||
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
|
||||
|
|
@ -342,16 +357,19 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
if err != nil {
|
||||
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
p.enqueuePlaybackReport(ctx, info, filtered)
|
||||
|
||||
case StateStopped:
|
||||
var loadedMF *model.MediaFile
|
||||
haveVerdict := false
|
||||
if !params.IgnoreScrobble && player.ScrobbleEnabled {
|
||||
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
loadedMF = mf
|
||||
filtered = p.isFilteredOut(ctx, mf)
|
||||
haveVerdict = true
|
||||
trackDurationMs := int64(mf.Duration * 1000)
|
||||
threshold := min(trackDurationMs*50/100, 240_000)
|
||||
if params.PositionMs >= threshold {
|
||||
|
|
@ -359,7 +377,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
if err != nil {
|
||||
log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
|
||||
}
|
||||
p.dispatchScrobble(ctx, mf, now)
|
||||
p.dispatchScrobble(ctx, mf, now, filtered)
|
||||
}
|
||||
}
|
||||
p.sessionsMu.Lock()
|
||||
|
|
@ -397,7 +415,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
}
|
||||
stoppedInfo.MediaFile = *mf
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, stoppedInfo)
|
||||
if !haveVerdict {
|
||||
filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile)
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, stoppedInfo, filtered)
|
||||
}
|
||||
|
||||
if conf.Server.EnableNowPlaying {
|
||||
|
|
@ -413,7 +434,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
// scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying.
|
||||
if player.ScrobbleEnabled &&
|
||||
(params.State == StateStarting || params.State == StatePlaying) {
|
||||
if info, err := p.playMap.Get(clientId); err == nil {
|
||||
if filtered {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for filtered track", "mediaId", params.MediaId)
|
||||
} else if info, err := p.playMap.Get(clientId); err == nil {
|
||||
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
|
||||
}
|
||||
}
|
||||
|
|
@ -452,6 +475,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
|
|||
log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err)
|
||||
continue
|
||||
}
|
||||
filtered := p.isFilteredOut(ctx, mf)
|
||||
err = p.incPlay(ctx, mf, s.Timestamp)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err)
|
||||
|
|
@ -460,7 +484,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
|
|||
event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID)
|
||||
log.Info(ctx, "Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp)
|
||||
if player.ScrobbleEnabled {
|
||||
p.dispatchScrobble(ctx, mf, s.Timestamp)
|
||||
p.dispatchScrobble(ctx, mf, s.Timestamp, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -494,11 +518,36 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
|
|||
})
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) {
|
||||
// Take this verdict before incPlay mutates what a filter reads, and independently of
|
||||
// which scrobblers are active: it can be stored on a session and dispatched much later.
|
||||
// Any parse or query failure fails open, because filtering must not break scrobbling.
|
||||
func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) bool {
|
||||
u, _ := request.UserFrom(ctx)
|
||||
if u.ScrobbleFilter == "" {
|
||||
return false
|
||||
}
|
||||
var c criteria.Criteria
|
||||
if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil {
|
||||
log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err)
|
||||
return false
|
||||
}
|
||||
match, err := p.ds.MediaFile(ctx).MatchesCriteria(t.ID, c)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err)
|
||||
return false
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time, filtered bool) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
if filtered {
|
||||
log.Debug(ctx, "Ignoring external Scrobble for filtered track", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
u, _ := request.UserFrom(ctx)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -46,6 +47,26 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
|
|||
return s, ok
|
||||
}
|
||||
|
||||
// flipOnPlayRepo reports one filter verdict before the play is counted and another
|
||||
// after, reproducing a filter that reads annotations incPlay mutates.
|
||||
type flipOnPlayRepo struct {
|
||||
model.MediaFileRepository
|
||||
before, after bool
|
||||
played atomic.Bool
|
||||
}
|
||||
|
||||
func (r *flipOnPlayRepo) IncPlayCount(id string, ts time.Time) error {
|
||||
r.played.Store(true)
|
||||
return r.MediaFileRepository.IncPlayCount(id, ts)
|
||||
}
|
||||
|
||||
func (r *flipOnPlayRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) {
|
||||
if r.played.Load() {
|
||||
return r.after, nil
|
||||
}
|
||||
return r.before, nil
|
||||
}
|
||||
|
||||
// slowMediaFileRepo widens the window between a report's session check and its
|
||||
// write, making check-then-write races reproducible.
|
||||
type slowMediaFileRepo struct {
|
||||
|
|
@ -320,6 +341,185 @@ var _ = Describe("PlayTracker", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble filter", func() {
|
||||
var repo *tests.MockMediaFileRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1",
|
||||
ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`})
|
||||
repo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
||||
})
|
||||
|
||||
It("does not send a matching track to the agent", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("still increments play counts for a filtered track", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(track.PlayCount).To(Equal(int64(1)))
|
||||
Expect(album.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("sends a non-matching track to the agent", func() {
|
||||
repo.MatchesCriteriaValue = false
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("fails open when evaluation errors", func() {
|
||||
repo.MatchesCriteriaErr = errors.New("boom")
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("fails open when the stored filter is not valid JSON", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{broken`})
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not send now-playing for a filtered track", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("does not send playback reports for a filtered track", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("sends playback reports for a non-matching track", func() {
|
||||
repo.MatchesCriteriaValue = false
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("evaluates the filter even when no scrobbler is active yet", func() {
|
||||
// The verdict is stored on the session and dispatched at expiry, by which
|
||||
// time a plugin scrobbler may have been enabled.
|
||||
tracker.builtinScrobblers = map[string]Scrobbler{}
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StatePlaying, ClientId: "player-12", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
stored, getErr := tracker.playMap.Get("player-12")
|
||||
Expect(getErr).ToNot(HaveOccurred())
|
||||
Expect(stored.filtered).To(BeTrue())
|
||||
})
|
||||
|
||||
Context("when incPlay itself flips the filter", func() {
|
||||
// A filter on playCount or lastPlayed changes verdict the moment incPlay
|
||||
// commits, so the verdict has to be taken before it, not at dispatch time.
|
||||
var flip *flipOnPlayRepo
|
||||
|
||||
install := func(before, after bool) {
|
||||
flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(ctx), before: before, after: after}
|
||||
ds.(*tests.MockDataStore).MockedMediaFile = flip
|
||||
}
|
||||
|
||||
It("does not scrobble a track the filter matched before the play was counted", func() {
|
||||
install(true, false)
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("still sends the stopped report when the filter only starts matching after the play", func() {
|
||||
install(false, true)
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("does not report an expired session for a filtered track", func() {
|
||||
// The expiry callback runs with a stub user, so it cannot evaluate the
|
||||
// filter itself and must reuse the verdict stored on the session.
|
||||
info := PlaybackSession{
|
||||
MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1",
|
||||
PlayerId: "player-9", PlayerName: "test-player", State: StatePlaying, filtered: true,
|
||||
}
|
||||
_ = tracker.playMap.AddWithTTL("player-9", info, 10*time.Millisecond)
|
||||
|
||||
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("still reports an expired session for a track that is not filtered", func() {
|
||||
info := PlaybackSession{
|
||||
MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1",
|
||||
PlayerId: "player-10", PlayerName: "test-player", State: StatePlaying, filtered: false,
|
||||
}
|
||||
_ = tracker.playMap.AddWithTTL("player-10", info, 10*time.Millisecond)
|
||||
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("stores the verdict on the session so expiry can reuse it", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StatePlaying, ClientId: "player-11", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
stored, getErr := tracker.playMap.Get("player-11")
|
||||
Expect(getErr).ToNot(HaveOccurred())
|
||||
Expect(stored.filtered).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not scrobble a Submit whose filter matched before the play was counted", func() {
|
||||
install(true, false)
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReportPlayback", func() {
|
||||
const defaultClientId = "client-1"
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) {
|
||||
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession, filtered bool) {
|
||||
p.prMu.Lock()
|
||||
defer p.prMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
p.prQueue = append(p.prQueue, playbackReportEntry{
|
||||
ctx: ctx,
|
||||
info: info,
|
||||
ctx: ctx,
|
||||
info: info,
|
||||
filtered: filtered,
|
||||
})
|
||||
p.sendPlaybackReportSignal()
|
||||
}
|
||||
|
|
@ -44,12 +45,16 @@ func (p *playTracker) playbackReportWorker() {
|
|||
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
for _, entry := range entries {
|
||||
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers)
|
||||
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers, entry.filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) {
|
||||
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler, filtered bool) {
|
||||
if filtered {
|
||||
log.Debug(ctx, "Ignoring external PlaybackReport for filtered track", "track", info.MediaFile.Title, "state", info.State)
|
||||
return
|
||||
}
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, info.UserId) {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -56,13 +55,16 @@ var _ = Describe("Storage", func() {
|
|||
Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp"))
|
||||
})
|
||||
It("should return a file implementation for a relative folder", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)")
|
||||
s, err := For("tmp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
cwd, _ := os.Getwd()
|
||||
Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{}))
|
||||
Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file"))
|
||||
Expect(s.(*fakeLocalStorage).u.Path).To(Equal(filepath.Join(cwd, "tmp")))
|
||||
u := s.(*fakeLocalStorage).u
|
||||
Expect(u.Scheme).To(Equal("file"))
|
||||
// On Windows the drive letter lands in u.Host, so re-join it with
|
||||
// u.Path (as newLocalStorage does) to keep the assertion OS-independent.
|
||||
got := filepath.Join(u.Host, filepath.FromSlash(u.Path))
|
||||
Expect(got).To(Equal(filepath.Join(cwd, "tmp")))
|
||||
})
|
||||
It("should return error if schema is unregistered", func() {
|
||||
_, err := For("webdav:///tmp")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
|
|
@ -33,7 +34,7 @@ var _ = Describe("MediaStreamer", func() {
|
|||
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
|
||||
})
|
||||
testCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
Eventually(func() bool { return testCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue())
|
||||
streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
AfterEach(func() {
|
||||
|
|
@ -75,7 +76,7 @@ var _ = Describe("MediaStreamer", func() {
|
|||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
|
|
@ -92,7 +93,7 @@ var _ = Describe("MediaStreamer", func() {
|
|||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
|
|
@ -112,7 +113,7 @@ var _ = Describe("MediaStreamer", func() {
|
|||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
-- +goose Up
|
||||
alter table user add column scrobble_filter varchar default '' not null;
|
||||
|
||||
-- +goose Down
|
||||
alter table user drop column scrobble_filter;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
-- +goose Up
|
||||
|
||||
-- Repairs album.created_at values stored in RFC3339 T-format by CopyAttributes.
|
||||
-- These values sort incorrectly in "Recently Added", which compares timestamps as raw strings.
|
||||
|
||||
UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00')
|
||||
WHERE created_at LIKE '%T%';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
SELECT 1;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- +goose Up
|
||||
alter table playlist add imported_hash varchar default '' not null;
|
||||
|
||||
-- +goose Down
|
||||
alter table playlist drop column imported_hash;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
-- +goose Up
|
||||
ALTER TABLE item_artwork ADD COLUMN trace jsonb NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE item_artwork ADD COLUMN last_failure jsonb NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE artwork_queue ADD COLUMN trace jsonb NOT NULL DEFAULT '[]';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE artwork_queue DROP COLUMN trace;
|
||||
ALTER TABLE item_artwork DROP COLUMN last_failure;
|
||||
ALTER TABLE item_artwork DROP COLUMN trace;
|
||||
58
go.mod
58
go.mod
|
|
@ -11,7 +11,7 @@ require (
|
|||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933
|
||||
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36
|
||||
github.com/djherbis/atime v1.1.0
|
||||
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4
|
||||
github.com/djherbis/stream v1.4.0
|
||||
|
|
@ -26,7 +26,7 @@ require (
|
|||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
github.com/go-viper/encoding/ini v0.1.1
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/gohugoio/hashstructure v0.6.0
|
||||
github.com/gohugoio/hashstructure v1.0.0
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
|
|
@ -35,37 +35,37 @@ require (
|
|||
github.com/jellydator/ttlcache/v3 v3.4.1
|
||||
github.com/kardianos/service v1.3.0
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/mattn/go-sqlite3 v1.14.48
|
||||
github.com/lestrrat-go/jwx/v3 v3.2.0
|
||||
github.com/mattn/go-sqlite3 v1.14.50
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.32.0
|
||||
github.com/onsi/ginkgo/v2 v2.32.1
|
||||
github.com/onsi/gomega v1.42.1
|
||||
github.com/pelletier/go-toml/v2 v2.4.3
|
||||
github.com/pmezard/go-difflib v1.0.0
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pressly/goose/v3 v3.27.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/pressly/goose/v3 v3.27.3
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
|
||||
github.com/sirupsen/logrus v1.10.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stretchr/testify v1.12.0
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
github.com/zeebo/xxh3 v1.1.0
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.40.0
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
|
@ -79,22 +79,21 @@ require (
|
|||
github.com/cespare/reflex v0.3.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
|
|
@ -114,13 +113,13 @@ require (
|
|||
github.com/ogier/pflag v0.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sanity-io/litter v1.5.8 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.4.0 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
|
|
@ -129,15 +128,14 @@ require (
|
|||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/ini.v1 v1.67.3 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
|
|
|||
136
go.sum
136
go.sum
|
|
@ -27,8 +27,6 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
|
|||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/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.1/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/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU=
|
||||
|
|
@ -37,8 +35,8 @@ github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcH
|
|||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
|
||||
github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g=
|
||||
github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE=
|
||||
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc=
|
||||
|
|
@ -53,8 +51,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
|
||||
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
|
|
@ -79,8 +77,8 @@ github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa
|
|||
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
|
|
@ -96,15 +94,15 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
|||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
||||
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
|
||||
github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg=
|
||||
github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
|
@ -122,8 +120,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
|||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 h1:QixF8Mcbe87ET7pK/fPbBJ9GXFddmEY8yYMepzMzo30=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
|
||||
|
|
@ -136,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd
|
|||
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
|
|
@ -161,16 +159,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
|
|||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
|
||||
github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA=
|
||||
github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
|
||||
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
|
|
@ -187,8 +185,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
|||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
|
||||
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
||||
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
|
||||
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
|
|
@ -201,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8=
|
||||
github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
|
||||
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||
|
|
@ -218,8 +216,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug
|
|||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
|
|
@ -227,15 +225,15 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
|
|||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
|
||||
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
|
||||
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
|
||||
github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
|
|
@ -267,8 +265,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
|
||||
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
|
||||
|
|
@ -293,27 +292,28 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
|||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
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/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
|
@ -322,26 +322,26 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
|
||||
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
|
||||
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
|
||||
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
|
||||
|
|
@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
|
||||
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
|
|
|
|||
|
|
@ -144,6 +144,9 @@ type AlbumRepository interface {
|
|||
Get(id string) (*Album, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
GetAllIDs(...QueryOptions) ([]string, error)
|
||||
// GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in
|
||||
// any of the given library-relative subtrees.
|
||||
GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error)
|
||||
GetCursor(...QueryOptions) (AlbumCursor, error)
|
||||
GetYears(libraryIDs ...int) ([]int, error)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ type ItemArtwork struct {
|
|||
SourcePath string `structs:"source_path"`
|
||||
// RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath.
|
||||
RefMtime int64 `structs:"ref_mtime"`
|
||||
// Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt
|
||||
// that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace.
|
||||
Trace string `structs:"trace"`
|
||||
LastFailure string `structs:"last_failure"`
|
||||
// Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL.
|
||||
AttemptedAt time.Time `structs:"attempted_at"`
|
||||
UpdatedAt time.Time `structs:"updated_at"`
|
||||
|
|
@ -91,6 +95,8 @@ type ArtworkQueueItem struct {
|
|||
Attempts int `structs:"attempts"`
|
||||
RetryAt time.Time `structs:"retry_at"`
|
||||
EnqueuedAt time.Time `structs:"enqueued_at"`
|
||||
// Trace is why the last attempt failed. Only Get reads it; the drain projects it away.
|
||||
Trace string `structs:"trace"`
|
||||
}
|
||||
|
||||
// Queue priorities: higher drains first.
|
||||
|
|
@ -109,6 +115,8 @@ type ArtworkRepository interface {
|
|||
PurgeOrphans(createdBefore time.Time) (int64, error)
|
||||
GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error)
|
||||
PutItemArtwork(ia *ItemArtwork) error
|
||||
// PutLastFailure records the trace of the attempt that exhausted the retry budget.
|
||||
PutLastFailure(kind Kind, id, imageType, trace string) error
|
||||
DeleteForItems(kind Kind, ids []string) error
|
||||
// GetInfoForItems hydrates a page in one batched query.
|
||||
GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error)
|
||||
|
|
@ -119,26 +127,56 @@ type ArtworkRepository interface {
|
|||
}
|
||||
|
||||
type ArtworkQueueRepository interface {
|
||||
// Get returns the pending row for an item, or ErrNotFound when it is not queued.
|
||||
Get(kind Kind, id, imageType string) (*ArtworkQueueItem, error)
|
||||
// Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset.
|
||||
Enqueue(items ...ArtworkQueueItem) error
|
||||
// EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a
|
||||
// request-triggered read-through never resets a failed resolution's backoff.
|
||||
EnqueuePreservingBackoff(items ...ArtworkQueueItem) error
|
||||
// EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff.
|
||||
EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error)
|
||||
// EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff, oldest
|
||||
// first; limit caps the selection, so already-queued rows use up budget (backpressure when the drain stalls).
|
||||
EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time, limit int) (int64, error)
|
||||
// EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority.
|
||||
EnqueueAllMissing(kind Kind, priority int) (int64, error)
|
||||
// EnqueueIfMissing inserts only for items with no item_artwork row yet.
|
||||
EnqueueIfMissing(items ...ArtworkQueueItem) error
|
||||
// CountBySource reports how many items of a kind currently resolve from the given sources.
|
||||
// An empty sources slice means every source; "" matches absent state.
|
||||
CountBySource(kind Kind, sources []string) (int64, error)
|
||||
// SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included.
|
||||
SourcesInUse(kind Kind) ([]string, error)
|
||||
// EnqueueBySource inserts queue rows for items of a kind whose current source matches.
|
||||
// It does not clear existing artwork state: the current image stays until it is replaced.
|
||||
EnqueueBySource(kind Kind, sources []string, priority int) (int64, error)
|
||||
// DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc.
|
||||
// Restricted to the given kinds when any are passed, so one kind cannot block another's drain.
|
||||
DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error)
|
||||
// MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches
|
||||
// seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility.
|
||||
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error
|
||||
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error
|
||||
// DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue.
|
||||
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
|
||||
Count() (int64, error)
|
||||
// CountQueued reports the pending rows matching the kinds and priorities, grouped by both;
|
||||
// an empty filter means every one.
|
||||
CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error)
|
||||
// CountAbsent reports the absent states of a kind, and how many are past the given cutoff,
|
||||
// eligible for EnqueueStaleAbsent (which drains them limit rows per call).
|
||||
CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error)
|
||||
// PurgeDangling removes queue rows whose entity no longer exists.
|
||||
PurgeDangling() (int64, error)
|
||||
// PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one.
|
||||
PurgeQueued(kinds []Kind, priorities []int) (int64, error)
|
||||
}
|
||||
|
||||
type ArtworkQueueStat struct {
|
||||
ItemKind string
|
||||
Priority int
|
||||
Count int64
|
||||
}
|
||||
|
||||
type ArtworkAbsentStat struct {
|
||||
Total int64
|
||||
Stale int64
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Kind struct {
|
||||
|
|
@ -40,6 +42,11 @@ var artworkKindMap = map[string]Kind{
|
|||
KindRadioArtwork.prefix: KindRadioArtwork,
|
||||
}
|
||||
|
||||
// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string.
|
||||
func KindPrefixes(kinds []Kind) []string {
|
||||
return slice.Map(kinds, func(k Kind) string { return k.prefix })
|
||||
}
|
||||
|
||||
// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known.
|
||||
// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain.
|
||||
func ParseKind(prefix string) (Kind, bool) {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ package model
|
|||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("data not found")
|
||||
ErrInvalidAuth = errors.New("invalid authentication")
|
||||
ErrNotAuthorized = errors.New("not authorized")
|
||||
ErrExpired = errors.New("access expired")
|
||||
ErrNotAvailable = errors.New("functionality not available")
|
||||
ErrValidation = errors.New("validation error")
|
||||
ErrNotFound = errors.New("data not found")
|
||||
ErrInvalidAuth = errors.New("invalid authentication")
|
||||
ErrNotAuthorized = errors.New("not authorized")
|
||||
ErrExpired = errors.New("access expired")
|
||||
ErrNotAvailable = errors.New("functionality not available")
|
||||
ErrValidation = errors.New("validation error")
|
||||
ErrPlaylistNotEditable = errors.New("playlist tracks are not editable")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,8 +76,10 @@ func NewFolder(lib Library, folderPath string) *Folder {
|
|||
type FolderCursor iter.Seq2[Folder, error]
|
||||
|
||||
type FolderUpdateInfo struct {
|
||||
UpdatedAt time.Time
|
||||
Hash string
|
||||
UpdatedAt time.Time
|
||||
Hash string
|
||||
ImageFiles []string
|
||||
ImagesUpdatedAt time.Time
|
||||
}
|
||||
|
||||
type FolderRepository interface {
|
||||
|
|
|
|||
|
|
@ -7,21 +7,36 @@ import (
|
|||
|
||||
// TODO: Should the type be encoded in the ID?
|
||||
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
|
||||
getters := []func() (any, error){
|
||||
func() (any, error) { return ds.Artist(ctx).Get(id) },
|
||||
func() (any, error) { return ds.Album(ctx).Get(id) },
|
||||
func() (any, error) { return ds.Playlist(ctx).Get(id) },
|
||||
func() (any, error) { return ds.MediaFile(ctx).Get(id) },
|
||||
func() (any, error) { return ds.Radio(ctx).Get(id) },
|
||||
entity, _, err := getEntity(ctx, ds, id)
|
||||
return entity, err
|
||||
}
|
||||
|
||||
// GetEntityKindByID resolves a bare entity id to its artwork Kind, searching the same tables as
|
||||
// GetEntityByID. It reports ErrNotFound when no entity owns the id.
|
||||
func GetEntityKindByID(ctx context.Context, ds DataStore, id string) (Kind, error) {
|
||||
_, kind, err := getEntity(ctx, ds, id)
|
||||
return kind, err
|
||||
}
|
||||
|
||||
func getEntity(ctx context.Context, ds DataStore, id string) (any, Kind, error) {
|
||||
getters := []struct {
|
||||
kind Kind
|
||||
get func() (any, error)
|
||||
}{
|
||||
{KindArtistArtwork, func() (any, error) { return ds.Artist(ctx).Get(id) }},
|
||||
{KindAlbumArtwork, func() (any, error) { return ds.Album(ctx).Get(id) }},
|
||||
{KindPlaylistArtwork, func() (any, error) { return ds.Playlist(ctx).Get(id) }},
|
||||
{KindMediaFileArtwork, func() (any, error) { return ds.MediaFile(ctx).Get(id) }},
|
||||
{KindRadioArtwork, func() (any, error) { return ds.Radio(ctx).Get(id) }},
|
||||
}
|
||||
for _, get := range getters {
|
||||
entity, err := get()
|
||||
for _, g := range getters {
|
||||
entity, err := g.get()
|
||||
if err == nil {
|
||||
return entity, nil
|
||||
return entity, g.kind, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
return nil, Kind{}, err
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
return nil, Kind{}, ErrNotFound
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,3 +38,25 @@ var _ = Describe("GetEntityByID", func() {
|
|||
Expect(err).ToNot(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("GetEntityKindByID", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
It("returns the artwork kind for the matching id", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}})
|
||||
kind, err := model.GetEntityKindByID(ctx, ds, "a1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kind).To(Equal(model.KindAlbumArtwork))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when no entity matches", func() {
|
||||
_, err := model.GetEntityKindByID(ctx, ds, "missing")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ var (
|
|||
timeRegex = regexp.MustCompile(timeRegexString)
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
|
||||
|
||||
// Non-standard background-vocal tag: [bg: <mm:ss.mm>word...] attaches to the preceding line
|
||||
bgTagRegex = regexp.MustCompile(`^\[bg:(.*)]$`)
|
||||
|
||||
// Any other [name:value] line is a tag we don't support (e.g. [al:], [by:]) and must be skipped whole
|
||||
unknownTagRegex = regexp.MustCompile(`^\[[A-Za-z][^]:]*:[^]]*]`)
|
||||
|
||||
// Enhanced LRC: inline word-level timing markers like <00:12.34>
|
||||
enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>`
|
||||
enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString)
|
||||
|
|
@ -40,7 +46,26 @@ func parseLRC(language, text string) (*Lyrics, error) {
|
|||
priorLine := ""
|
||||
validLine := false
|
||||
repeated := false
|
||||
hasBg := false
|
||||
var timestamps []int64
|
||||
var pendingBg []string
|
||||
|
||||
flushLine := func() {
|
||||
value, cues := parseEnhancedLine(priorLine)
|
||||
var merged bool
|
||||
value, cues, merged = mergeBgLayers(value, cues, pendingBg)
|
||||
hasBg = hasBg || merged
|
||||
for idx := range timestamps {
|
||||
startCopy := timestamps[idx]
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: &startCopy,
|
||||
Value: value,
|
||||
Cue: shiftELRCCues(cues, timestamps[idx]-timestamps[0]),
|
||||
})
|
||||
}
|
||||
timestamps = nil
|
||||
pendingBg = nil
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
line := strings.TrimSpace(line)
|
||||
|
|
@ -77,6 +102,17 @@ func parseLRC(language, text string) (*Lyrics, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
if bgMatch := bgTagRegex.FindStringSubmatch(line); bgMatch != nil {
|
||||
if validLine {
|
||||
pendingBg = append(pendingBg, bgMatch[1])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if unknownTagRegex.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
|
||||
times := timeRegex.FindAllStringSubmatchIndex(line, -1)
|
||||
if len(times) > 1 {
|
||||
repeated = true
|
||||
|
|
@ -92,16 +128,7 @@ func parseLRC(language, text string) (*Lyrics, error) {
|
|||
}
|
||||
|
||||
if validLine {
|
||||
value, baseCues := parseEnhancedLine(priorLine)
|
||||
for idx := range timestamps {
|
||||
startCopy := timestamps[idx]
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: &startCopy,
|
||||
Value: value,
|
||||
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
|
||||
})
|
||||
}
|
||||
timestamps = nil
|
||||
flushLine()
|
||||
}
|
||||
|
||||
end := 0
|
||||
|
|
@ -143,15 +170,11 @@ func parseLRC(language, text string) (*Lyrics, error) {
|
|||
}
|
||||
|
||||
if validLine {
|
||||
value, baseCues := parseEnhancedLine(priorLine)
|
||||
for idx := range timestamps {
|
||||
startCopy := timestamps[idx]
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: &startCopy,
|
||||
Value: value,
|
||||
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
|
||||
})
|
||||
}
|
||||
flushLine()
|
||||
}
|
||||
|
||||
if hasBg {
|
||||
fillMainAgentID(structuredLines)
|
||||
}
|
||||
|
||||
// If there are repeated values, there is no guarantee that they are in order
|
||||
|
|
@ -170,9 +193,51 @@ func parseLRC(language, text string) (*Lyrics, error) {
|
|||
Offset: offset,
|
||||
Synced: synced,
|
||||
}
|
||||
if hasBg {
|
||||
lyrics.Agents = []Agent{
|
||||
{ID: "main", Role: "main"},
|
||||
{ID: backgroundAgentID("main"), Role: "bg"},
|
||||
}
|
||||
}
|
||||
return &lyrics, nil
|
||||
}
|
||||
|
||||
// mergeBgLayers appends parsed [bg:] content to a line's value and cues as a
|
||||
// background agent layer, mirroring the TTML background-vocal representation.
|
||||
func mergeBgLayers(value string, cues []Cue, bgLines []string) (string, []Cue, bool) {
|
||||
merged := false
|
||||
for _, bg := range bgLines {
|
||||
bgValue, bgCues := parseEnhancedLine(bg)
|
||||
if len(bgCues) == 0 {
|
||||
continue
|
||||
}
|
||||
offset := 0
|
||||
if value != "" {
|
||||
offset = len(value) + 1
|
||||
value += " "
|
||||
}
|
||||
value += bgValue
|
||||
for _, c := range bgCues {
|
||||
c.ByteStart += offset
|
||||
c.ByteEnd += offset
|
||||
c.AgentID = backgroundAgentID("main")
|
||||
cues = append(cues, c)
|
||||
}
|
||||
merged = true
|
||||
}
|
||||
return value, cues, merged
|
||||
}
|
||||
|
||||
func fillMainAgentID(lines []Line) {
|
||||
for i := range lines {
|
||||
for j := range lines[i].Cue {
|
||||
if lines[i].Cue[j].AgentID == "" {
|
||||
lines[i].Cue[j].AgentID = "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers
|
||||
// and computes UTF-8 byte offsets against the final stripped line value.
|
||||
func parseEnhancedLine(text string) (string, []Cue) {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,49 @@ var _ = Describe("parseLRC", func() {
|
|||
}))
|
||||
})
|
||||
|
||||
It("should skip unknown tag lines instead of merging them into the prior line", func() {
|
||||
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[by: someone]\n[al: An album]\n[00:03.00]Next")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(HaveLen(2))
|
||||
|
||||
t1000, t1500, t3000 := int64(1000), int64(1500), int64(3000)
|
||||
Expect(lyrics.Line[0].Value).To(Equal("Some lyrics"))
|
||||
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
|
||||
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4},
|
||||
{Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10},
|
||||
}))
|
||||
Expect(lyrics.Line[1].Value).To(Equal("Next"))
|
||||
})
|
||||
|
||||
It("should attach [bg:] background vocals to the previous line as a bg agent layer", func() {
|
||||
lyrics, err := parseLRC("xxx", "[00:52.00]<00:52.00>Main <00:52.50>line\n[bg: <00:53.00>Okay<00:53.50>]\n[00:55.00]Next")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Agents).To(Equal([]Agent{
|
||||
{ID: "main", Role: "main"},
|
||||
{ID: "__nd_bg__|main", Role: "bg"},
|
||||
}))
|
||||
Expect(lyrics.Line).To(HaveLen(2))
|
||||
|
||||
line := lyrics.Line[0]
|
||||
Expect(line.Value).To(Equal("Main line Okay"))
|
||||
Expect(line.Cue).To(Equal([]Cue{
|
||||
{Start: new(int64(52000)), End: new(int64(52500)), Value: "Main ", ByteStart: 0, ByteEnd: 4, AgentID: "main"},
|
||||
{Start: new(int64(52500)), End: new(int64(55000)), Value: "line", ByteStart: 5, ByteEnd: 8, AgentID: "main"},
|
||||
{Start: new(int64(53000)), End: new(int64(53500)), Value: "Okay", ByteStart: 10, ByteEnd: 13, AgentID: "__nd_bg__|main"},
|
||||
}))
|
||||
Expect(lyrics.Line[1].Value).To(Equal("Next"))
|
||||
})
|
||||
|
||||
It("should skip a [bg:] line that has no inline timing", func() {
|
||||
lyrics, err := parseLRC("xxx", "[00:01.00]Hi\n[bg: untimed]\n[00:03.00]Next")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Agents).To(BeNil())
|
||||
Expect(lyrics.Line).To(HaveLen(2))
|
||||
Expect(lyrics.Line[0].Value).To(Equal("Hi"))
|
||||
Expect(lyrics.Line[1].Value).To(Equal("Next"))
|
||||
})
|
||||
|
||||
It("should handle mixed Enhanced and plain LRC lines", func() {
|
||||
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package model
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
|
|
@ -15,10 +14,12 @@ import (
|
|||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
|
|
@ -231,7 +232,7 @@ func (mf MediaFile) Hash() string {
|
|||
ZeroNil: true,
|
||||
}
|
||||
hash, _ := hashstructure.Hash(mf, opts)
|
||||
sum := md5.New()
|
||||
sum := xxh3.New()
|
||||
sum.Write(fmt.Appendf(nil, "%d", hash))
|
||||
sum.Write(mf.Tags.Hash())
|
||||
sum.Write(mf.Participants.Hash())
|
||||
|
|
@ -548,9 +549,15 @@ type MediaFileRepository interface {
|
|||
// filters as GetAll. Sort/Order are ignored.
|
||||
GetRandom(options ...QueryOptions) (MediaFiles, error)
|
||||
GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
|
||||
// MatchesCriteria reports whether the media file matches the criteria's rule
|
||||
// expression, using the logged user's annotations. Limit and offset are ignored.
|
||||
MatchesCriteria(id string, c criteria.Criteria) (bool, error)
|
||||
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
||||
// GetAllIDs returns just the media_file IDs for the same row set as GetAll.
|
||||
GetAllIDs(options ...QueryOptions) ([]string, error)
|
||||
// GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given
|
||||
// folders or their direct children.
|
||||
GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error)
|
||||
// GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't
|
||||
// pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors.
|
||||
GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error)
|
||||
|
|
|
|||
|
|
@ -715,14 +715,10 @@ var _ = Describe("MediaFile.Movements", func() {
|
|||
})
|
||||
|
||||
var _ = Describe("MediaFile.Hash", func() {
|
||||
// Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes,
|
||||
// or every file would be spuriously re-imported on the next scan.
|
||||
// Golden hashes were captured at 46221d516 when those fields were plain ints.
|
||||
It("keeps hashes identical to the pre-pointer-conversion values", func() {
|
||||
// Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning
|
||||
// them guarantees the pointer conversion cannot trigger a full-library re-import.
|
||||
Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622"))
|
||||
Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689"))
|
||||
// Pins the hash formula: an accidental change spuriously re-imports every file on the next scan.
|
||||
It("hashes to a stable value", func() {
|
||||
Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("05fdf70bb0cbe090"))
|
||||
Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b5daf6ac1009a538"))
|
||||
})
|
||||
It("changes the hash when a pointer field has a value", func() {
|
||||
base := MediaFile{Title: "Song"}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ package model
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -193,7 +193,7 @@ func (p Participants) Hash() []byte {
|
|||
flattened = append(flattened, role.String()+":"+strings.Join(ids, "/"))
|
||||
}
|
||||
slices.Sort(flattened)
|
||||
sum := md5.New()
|
||||
sum := xxh3.New()
|
||||
sum.Write([]byte(strings.Join(flattened, "|")))
|
||||
return sum.Sum(nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Playlist struct {
|
|||
ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ImportedHash string `structs:"imported_hash" json:"-"`
|
||||
|
||||
// SmartPlaylist attributes
|
||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||
|
|
@ -41,6 +42,11 @@ func (pls Playlist) IsSmartPlaylist() bool {
|
|||
return pls.Rules != nil && pls.Rules.Expression != nil
|
||||
}
|
||||
|
||||
// TracksEditable reports whether the track list is user-owned rather than server-managed.
|
||||
func (pls Playlist) TracksEditable() bool {
|
||||
return !pls.IsSmartPlaylist() && !pls.Sync
|
||||
}
|
||||
|
||||
// RefreshDelay returns the playlist's own refresh window when set, falling
|
||||
// back to the global SmartPlaylistRefreshDelay.
|
||||
func (pls Playlist) RefreshDelay() time.Duration {
|
||||
|
|
|
|||
|
|
@ -73,4 +73,19 @@ var _ = Describe("Playlist", func() {
|
|||
Expect(pls.RefreshDelay()).To(Equal(5 * time.Second))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TracksEditable", func() {
|
||||
It("is true for a plain playlist", func() {
|
||||
Expect(model.Playlist{}.TracksEditable()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("is false for a smart playlist", func() {
|
||||
pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.Is{"loved": true}}}
|
||||
Expect(pls.TracksEditable()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is false for a synced playlist", func() {
|
||||
Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
// NOTE: This struct is used as a map key, so it should only contain comparable types.
|
||||
type ScanTarget struct {
|
||||
LibraryID int
|
||||
FolderPath string // Relative path within the library, or "" for entire library
|
||||
FolderPath string // Path within the library (relative or absolute), or "" for entire library
|
||||
}
|
||||
|
||||
func (st ScanTarget) String() string {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ package model
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
|
|
@ -117,7 +117,7 @@ func (t Tags) Hash() []byte {
|
|||
}
|
||||
ids := t.IDs()
|
||||
slices.Sort(ids)
|
||||
sum := md5.New()
|
||||
sum := xxh3.New()
|
||||
sum.Write([]byte(strings.Join(ids, "|")))
|
||||
return sum.Sum(nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type User struct {
|
|||
LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
// Smart-playlist criteria JSON; matching songs are not sent to external scrobblers
|
||||
ScrobbleFilter string `structs:"scrobble_filter" json:"scrobbleFilter"`
|
||||
|
||||
// Library associations (many-to-many relationship)
|
||||
Libraries Libraries `structs:"-" json:"libraries,omitempty"`
|
||||
|
|
|
|||
|
|
@ -270,6 +270,39 @@ func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, er
|
|||
return ids, err
|
||||
}
|
||||
|
||||
// soleAlbumArtistFilter matches albums with exactly one album artist. The artist artwork
|
||||
// resolver and the scanner's image-change enqueue must select the same albums.
|
||||
var soleAlbumArtistFilter = Eq{"json_array_length(participants, '$.albumartist')": 1}
|
||||
|
||||
// SoleAlbumArtistFilter matches the albums where the given artist is the only album artist.
|
||||
// Matches by album-artist participation, not the deprecated album_artist_id column.
|
||||
func SoleAlbumArtistFilter(artistID string) Sqlizer {
|
||||
return And{ParticipantIDFilter("album", artistID, model.RoleAlbumArtist), soleAlbumArtistFilter}
|
||||
}
|
||||
|
||||
// GetSoleAlbumArtistIDsInSubtrees matches albums by their own folder_ids, which is the resolver's
|
||||
// notion of an album's folders.
|
||||
func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, paths ...string) ([]string, error) {
|
||||
if len(paths) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := []string{}
|
||||
// Repeated IDs across chunks are fine: the queue upserts by PK.
|
||||
for chunk := range slices.Chunk(paths, subtreePathChunkSize) {
|
||||
inSubtree := Exists("json_each(album.folder_ids) je join folder on folder.id = je.value",
|
||||
folderSubtreeFilter(lib, chunk))
|
||||
// Sole album artist, so participants[0] is the only one.
|
||||
sq := Select("distinct json_extract(participants, '$.albumartist[0].id')").From("album").
|
||||
Where(And{soleAlbumArtistFilter, inSubtree})
|
||||
var chunkIDs []string
|
||||
if err := r.queryAllSlice(sq, &chunkIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, chunkIDs...)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) {
|
||||
ids, err := r.GetAllIDs(options...)
|
||||
if err != nil {
|
||||
|
|
@ -296,8 +329,11 @@ func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) {
|
|||
}
|
||||
|
||||
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
|
||||
// Cast values to text so go-sqlite3 does not decode datetime columns as time.Time
|
||||
// and reformat them as RFC3339 when written back.
|
||||
sel := slice.Map(columns, func(c string) string { return fmt.Sprintf("cast(%[1]s as text) as %[1]s", c) })
|
||||
var from dbx.NullStringMap
|
||||
err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)
|
||||
err := r.queryOne(Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting album to copy fields from: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
|
@ -18,11 +19,22 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// rawColumn returns a column exactly as stored, bypassing go-sqlite3's decoding of
|
||||
// `datetime` columns into time.Time.
|
||||
func rawColumn(r sqlRepository, id, column string) string {
|
||||
var res struct{ Value string }
|
||||
sel := squirrel.Select("cast(" + column + " as text) as value").
|
||||
From(r.tableName).Where(squirrel.Eq{"id": id})
|
||||
ExpectWithOffset(1, r.queryOne(sel, &res)).To(Succeed())
|
||||
return res.Value
|
||||
}
|
||||
|
||||
var _ = Describe("AlbumRepository", func() {
|
||||
var albumRepo *albumRepository
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
|
||||
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
|
||||
})
|
||||
|
||||
|
|
@ -67,6 +79,22 @@ var _ = Describe("AlbumRepository", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
|
||||
})
|
||||
It("returns not found and leaves destination untouched when source does not exist", func() {
|
||||
err := albumRepo.CopyAttributes("copy-missing", "copy-dst", "created_at")
|
||||
Expect(errors.Is(err, model.ErrNotFound)).To(BeTrue())
|
||||
got, getErr := albumRepo.Get("copy-dst")
|
||||
Expect(getErr).ToNot(HaveOccurred())
|
||||
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
|
||||
})
|
||||
It("keeps the copied created_at in the driver's space-separated format", func() {
|
||||
// Copying through a Go string would rewrite it as RFC3339 ("2020-01-02T03:04:05Z"),
|
||||
// which string-sorts above every space-format timestamp and pins the album to the
|
||||
// top of "Recently Added".
|
||||
Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed())
|
||||
Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).
|
||||
To(Equal(rawColumn(albumRepo.sqlRepository, "copy-src", "created_at")))
|
||||
Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetCursor", func() {
|
||||
|
|
@ -96,6 +124,77 @@ var _ = Describe("AlbumRepository", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("GetSoleAlbumArtistIDsInSubtrees", func() {
|
||||
It("returns the sole album artists of albums with folders in the subtree", func() {
|
||||
folderRepo := newFolderRepository(ctx, GetDBXBuilder())
|
||||
lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
inTree := model.NewFolder(*lib, "SubtreeAlbums/Artist")
|
||||
outTree := model.NewFolder(*lib, "OtherTree/Artist")
|
||||
Expect(folderRepo.Put(inTree)).To(Succeed())
|
||||
Expect(folderRepo.Put(outTree)).To(Succeed())
|
||||
|
||||
// album_artist_id is deliberately wrong: the artist must come from participants
|
||||
inAl := model.Album{ID: "subtree-in-al", Name: "In", LibraryID: 1, AlbumArtistID: "999", FolderIDs: []string{inTree.ID},
|
||||
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}}
|
||||
outAl := model.Album{ID: "subtree-out-al", Name: "Out", LibraryID: 1, AlbumArtistID: "3", FolderIDs: []string{outTree.ID},
|
||||
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistBeatles}}}}
|
||||
duoAl := model.Album{ID: "subtree-duo-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "5", FolderIDs: []string{inTree.ID},
|
||||
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistPunctuation}, {Artist: artistBeatles}}}}
|
||||
for _, al := range []model.Album{inAl, outAl, duoAl} {
|
||||
Expect(albumRepo.Put(&al)).To(Succeed())
|
||||
}
|
||||
DeferCleanup(func() {
|
||||
_, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id LIKE 'subtree-%'").Execute()
|
||||
_, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'SubtreeAlbums%' OR path LIKE 'OtherTree%'").Execute()
|
||||
})
|
||||
|
||||
ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, "SubtreeAlbums")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(ConsistOf("2")) // sole artist in the subtree; the duo and the outside album are excluded
|
||||
})
|
||||
|
||||
It("stays under SQLite's expression tree depth limit with many paths", func() {
|
||||
lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := make([]string, 200)
|
||||
for i := range paths {
|
||||
paths[i] = fmt.Sprintf("DepthProbe/Folder%d", i)
|
||||
}
|
||||
|
||||
_, err = albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, paths...)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns nothing when given no paths", func() {
|
||||
lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SoleAlbumArtistFilter", func() {
|
||||
It("matches only albums where the artist is the sole album artist", func() {
|
||||
// album_artist_id is deliberately wrong: matching must come from participation
|
||||
sole := model.Album{ID: "sole-artist-al", Name: "Sole", LibraryID: 1, AlbumArtistID: "999",
|
||||
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}}
|
||||
duo := model.Album{ID: "duo-artist-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "999",
|
||||
Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}, {Artist: artistBeatles}}}}
|
||||
Expect(albumRepo.Put(&sole)).To(Succeed())
|
||||
Expect(albumRepo.Put(&duo)).To(Succeed())
|
||||
DeferCleanup(func() {
|
||||
_, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id IN ('sole-artist-al', 'duo-artist-al')").Execute()
|
||||
})
|
||||
|
||||
als, err := albumRepo.GetAll(model.QueryOptions{Filters: SoleAlbumArtistFilter("2")})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(als).To(HaveLen(1))
|
||||
Expect(als[0].ID).To(Equal(sole.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
|
||||
albums, err := albumRepo.GetAll(opts...)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ import (
|
|||
const enqueueChunkSize = 100
|
||||
|
||||
// Every insert writes these, in this order; the INSERT..SELECT forms must project them to match.
|
||||
// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads.
|
||||
var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"}
|
||||
|
||||
type artworkQueueRepository struct {
|
||||
|
|
@ -31,11 +33,22 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor
|
|||
return r
|
||||
}
|
||||
|
||||
// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget.
|
||||
func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) {
|
||||
var res model.ArtworkQueueItem
|
||||
err := r.queryOne(Select("*").From(r.tableName).
|
||||
Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old
|
||||
// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0).
|
||||
func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error {
|
||||
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
|
||||
priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at,
|
||||
attempts = 0, enqueued_at = excluded.enqueued_at`, items)
|
||||
attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items)
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error {
|
||||
|
|
@ -43,11 +56,12 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork
|
|||
priority = MAX(priority, excluded.priority)`, items)
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) {
|
||||
func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) {
|
||||
now := time.Now()
|
||||
return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ?
|
||||
FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?`,
|
||||
model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore)
|
||||
FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?
|
||||
ORDER BY attempted_at LIMIT ?`,
|
||||
model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore, limit)
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) {
|
||||
|
|
@ -87,12 +101,49 @@ func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueIte
|
|||
return nil
|
||||
}
|
||||
|
||||
// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. DO NOTHING is
|
||||
// deliberate: a recheck must not bump the priority or retry_at of an already-queued item.
|
||||
// DO NOTHING is deliberate: a recheck must not bump the priority or retry_at of an already-queued item.
|
||||
const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`
|
||||
|
||||
// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE.
|
||||
func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) {
|
||||
return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+
|
||||
` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+
|
||||
` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`, args...))
|
||||
` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...))
|
||||
}
|
||||
|
||||
// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the absent state.
|
||||
func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer {
|
||||
f := And{Eq{"item_kind": kind.Prefix()}}
|
||||
if len(sources) > 0 {
|
||||
f = append(f, Eq{"source": sources})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) {
|
||||
var res struct{ Count int64 }
|
||||
err := r.queryOne(Select("count(*) as count").From(itemArtworkTable).
|
||||
Where(artworkSourceFilter(kind, sources)), &res)
|
||||
return res.Count, err
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) SourcesInUse(kind model.Kind) ([]string, error) {
|
||||
var res []struct{ Source string }
|
||||
err := r.queryAll(Select("distinct source").From(itemArtworkTable).
|
||||
Where(Eq{"item_kind": kind.Prefix()}), &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slice.Map(res, func(s struct{ Source string }) string { return s.Source }), nil
|
||||
}
|
||||
|
||||
// EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the
|
||||
// library's artwork until every item is resolved again.
|
||||
func (r *artworkQueueRepository) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) {
|
||||
now := time.Now()
|
||||
sel := Select("item_kind", "item_id", "image_type").
|
||||
Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)).
|
||||
From(itemArtworkTable).Where(artworkSourceFilter(kind, sources))
|
||||
return r.executeSQL(Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued))
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error {
|
||||
|
|
@ -111,7 +162,7 @@ func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQ
|
|||
}
|
||||
|
||||
func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||
sel := Select("*").From(r.tableName).
|
||||
sel := Select(enqueueColumns...).From(r.tableName).
|
||||
Where(LtOrEq{"retry_at": time.Now()}).
|
||||
OrderBy("priority DESC", "enqueued_at ASC").
|
||||
Limit(uint64(n))
|
||||
|
|
@ -123,10 +174,11 @@ func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.A
|
|||
return res, err
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error {
|
||||
func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error {
|
||||
upd := Update(r.tableName).
|
||||
Set("attempts", Expr("attempts + 1")).
|
||||
Set("retry_at", retryAt).
|
||||
Set("trace", trace).
|
||||
Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt})
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
|
|
@ -140,10 +192,52 @@ func (r *artworkQueueRepository) PurgeDangling() (int64, error) {
|
|||
return purgeDangling(r.sqlRepository)
|
||||
}
|
||||
|
||||
// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps
|
||||
// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too.
|
||||
func artworkQueueFilter(kinds []model.Kind, priorities []int) And {
|
||||
var f And
|
||||
if len(kinds) > 0 {
|
||||
f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)})
|
||||
}
|
||||
if len(priorities) > 0 {
|
||||
f = append(f, Eq{"priority": priorities})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses.
|
||||
func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
|
||||
sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName).
|
||||
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc")
|
||||
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
|
||||
sel = sel.Where(f)
|
||||
}
|
||||
var res []model.ArtworkQueueStat
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) {
|
||||
del := Delete(r.tableName)
|
||||
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
|
||||
del = del.Where(f)
|
||||
}
|
||||
return r.executeSQL(del)
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) Count() (int64, error) {
|
||||
var res struct{ Count int64 }
|
||||
err := r.queryOne(Select("count(*) as count").From(r.tableName), &res)
|
||||
return res.Count, err
|
||||
}
|
||||
|
||||
// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is the pool a recheck drains from.
|
||||
func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) {
|
||||
var res model.ArtworkAbsentStat
|
||||
err := r.queryOne(Select("count(*) as total").
|
||||
Column(Expr("coalesce(sum(attempted_at < ?), 0) as stale", attemptedBefore)).
|
||||
From(itemArtworkTable).Where(Eq{"item_kind": kind.Prefix(), "hash": ""}), &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,22 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
|||
Expect(got[0].ItemID).To(Equal("high"))
|
||||
})
|
||||
|
||||
It("Get returns a queued row, including one still backing off", func() {
|
||||
Expect(repo.Enqueue(item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
backOff("ar", "g1", time.Now().Add(time.Hour))
|
||||
|
||||
got, err := repo.Get(model.KindArtistArtwork, "g1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
Expect(got.Attempts).To(Equal(1))
|
||||
Expect(got.RetryAt).To(BeTemporally(">", time.Now()))
|
||||
})
|
||||
|
||||
It("Get reports ErrNotFound when the item is not queued", func() {
|
||||
_, err := repo.Get(model.KindArtistArtwork, "nope", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("keeps the higher priority on duplicate enqueue", func() {
|
||||
Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed())
|
||||
Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed())
|
||||
|
|
@ -111,19 +127,42 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
|||
Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
|
||||
future := time.Now().Add(48 * time.Hour)
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed())
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed())
|
||||
got, _ = repo.DequeueBatch(10)
|
||||
Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible")
|
||||
Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them")
|
||||
current := got[0].RetryAt
|
||||
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed())
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed())
|
||||
got, _ = repo.DequeueBatch(10)
|
||||
Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at")
|
||||
all, _ := repo.Count()
|
||||
Expect(all).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("Enqueue clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() {
|
||||
// Fail an attempt so the queue row carries a failure trace.
|
||||
Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
backOff("al", "t1", time.Now().Add(-time.Hour))
|
||||
got, _ := repo.DequeueBatch(10)
|
||||
Expect(got).To(HaveLen(1))
|
||||
future := time.Now().Add(48 * time.Hour)
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed())
|
||||
|
||||
// A continuation of the same lifecycle must retain the trace.
|
||||
Expect(repo.EnqueuePreservingBackoff(item("al", "t1", model.ArtworkPriorityBump))).To(Succeed())
|
||||
kept, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`))
|
||||
|
||||
// A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it.
|
||||
Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
fresh, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fresh.Attempts).To(BeZero())
|
||||
Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace")
|
||||
})
|
||||
|
||||
It("Enqueue restarts the retry budget an existing row had spent", func() {
|
||||
Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
backOff("al", "e1", time.Now().Add(-time.Hour))
|
||||
|
|
@ -205,7 +244,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
|||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed())
|
||||
|
||||
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))
|
||||
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour), 100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(1)))
|
||||
|
||||
|
|
@ -216,6 +255,23 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
|||
Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||
})
|
||||
|
||||
It("enqueues only the oldest stale absent states up to the limit", func() {
|
||||
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
now := time.Now()
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "oldest", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-72 * time.Hour)})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "older", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-60 * time.Hour)})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "old", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-48 * time.Hour)})).To(Succeed())
|
||||
|
||||
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, now.Add(-24*time.Hour), 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(2)))
|
||||
|
||||
items, err := repo.DequeueBatch(10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := slice.Map(items, func(it model.ArtworkQueueItem) string { return it.ItemID })
|
||||
Expect(ids).To(ConsistOf("oldest", "older"))
|
||||
})
|
||||
|
||||
It("enqueues entities that have no item_artwork row at all", func() {
|
||||
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed())
|
||||
|
|
@ -265,6 +321,89 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
|||
Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive")
|
||||
})
|
||||
|
||||
Describe("EnqueueBySource", func() {
|
||||
BeforeEach(func() {
|
||||
artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
for _, ia := range []model.ItemArtwork{
|
||||
{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "external:deezer"},
|
||||
{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "h2", Source: "external:lastfm"},
|
||||
{ItemKind: "ar", ItemID: "ar3", ImageType: model.ImageTypePrimary, Hash: "", Source: ""},
|
||||
{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h4", Source: "external:deezer"},
|
||||
} {
|
||||
Expect(artRepo.PutItemArtwork(&ia)).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
It("enqueues only the matching source within the kind", func() {
|
||||
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(1)), "al1 is a different kind and must not be touched")
|
||||
|
||||
got, err := repo.DequeueBatch(10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar1"))
|
||||
})
|
||||
|
||||
It("treats the empty source as absent", func() {
|
||||
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(1)))
|
||||
|
||||
got, _ := repo.DequeueBatch(10)
|
||||
Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar3"))
|
||||
})
|
||||
|
||||
It("enqueues every source when none is given", func() {
|
||||
n, err := repo.EnqueueBySource(model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(3)))
|
||||
})
|
||||
|
||||
It("leaves the current artwork state in place", func() {
|
||||
_, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(Equal("h1"), "the current image must survive until it is replaced")
|
||||
Expect(ia.Source).To(Equal("external:deezer"))
|
||||
})
|
||||
|
||||
It("does not disturb an already-queued row", func() {
|
||||
Expect(repo.Enqueue(item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed())
|
||||
|
||||
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(BeZero())
|
||||
|
||||
got, _ := repo.DequeueBatch(10)
|
||||
Expect(got).To(HaveLen(1))
|
||||
Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
})
|
||||
|
||||
It("counts without enqueueing", func() {
|
||||
n, err := repo.CountBySource(model.KindArtistArtwork, []string{"external:deezer"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(1)))
|
||||
|
||||
queued, err := repo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queued).To(BeZero(), "CountBySource must not enqueue")
|
||||
})
|
||||
|
||||
It("counts the absent source and every source", func() {
|
||||
Expect(repo.CountBySource(model.KindArtistArtwork, []string{""})).To(Equal(int64(1)))
|
||||
Expect(repo.CountBySource(model.KindArtistArtwork, nil)).To(Equal(int64(3)))
|
||||
})
|
||||
|
||||
It("lists the distinct sources in use by a kind", func() {
|
||||
Expect(repo.SourcesInUse(model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm"))
|
||||
Expect(repo.SourcesInUse(model.KindAlbumArtwork)).To(ConsistOf("external:deezer"))
|
||||
Expect(repo.SourcesInUse(model.KindRadioArtwork)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
It("does not disturb an already-queued entity when enqueueing missing rows", func() {
|
||||
Expect(repo.Enqueue(item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed())
|
||||
|
||||
|
|
@ -281,4 +420,110 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
|||
}
|
||||
Expect(count).To(Equal(1), "the already-queued row must not be duplicated")
|
||||
})
|
||||
|
||||
Describe("status counters", func() {
|
||||
It("groups queue rows by kind and priority", func() {
|
||||
Expect(repo.Enqueue(item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed())
|
||||
Expect(repo.Enqueue(item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed())
|
||||
Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed())
|
||||
Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
|
||||
Expect(repo.CountQueued(nil, nil)).To(ConsistOf(
|
||||
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2},
|
||||
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1},
|
||||
model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1},
|
||||
))
|
||||
})
|
||||
|
||||
It("reports an empty queue as no rows", func() {
|
||||
Expect(repo.CountQueued(nil, nil)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("counts absent states and how many are due for recheck", func() {
|
||||
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
for _, ia := range []model.ItemArtwork{
|
||||
{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old},
|
||||
{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()},
|
||||
{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old},
|
||||
{ItemKind: "al", ItemID: "stale2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old},
|
||||
} {
|
||||
Expect(awRepo.PutItemArtwork(&ia)).To(Succeed())
|
||||
}
|
||||
|
||||
Expect(repo.CountAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))).
|
||||
To(Equal(model.ArtworkAbsentStat{Total: 2, Stale: 1}))
|
||||
})
|
||||
|
||||
It("reports a kind with no absent state as zero, not as an error", func() {
|
||||
Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PurgeQueued", func() {
|
||||
queuedIDs := func() []string {
|
||||
GinkgoHelper()
|
||||
got, err := repo.DequeueBatch(100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
Expect(repo.Enqueue(
|
||||
item("ar", "ar-backfill", model.ArtworkPriorityBackfill),
|
||||
item("ar", "ar-bump", model.ArtworkPriorityBump),
|
||||
item("al", "al-backfill", model.ArtworkPriorityBackfill),
|
||||
item("mf", "mf-scan", model.ArtworkPriorityScan),
|
||||
)).To(Succeed())
|
||||
})
|
||||
|
||||
// CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so
|
||||
// every selection must count exactly what it deletes.
|
||||
DescribeTable("selects the same rows to count and to delete",
|
||||
func(kinds []model.Kind, priorities []int, deleted int, remaining []string) {
|
||||
counted, err := repo.CountQueued(kinds, priorities)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var total int64
|
||||
for _, s := range counted {
|
||||
total += s.Count
|
||||
}
|
||||
Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete")
|
||||
|
||||
Expect(repo.PurgeQueued(kinds, priorities)).To(BeNumerically("==", deleted))
|
||||
Expect(queuedIDs()).To(ConsistOf(remaining))
|
||||
},
|
||||
Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil,
|
||||
2, []string{"al-backfill", "mf-scan"}),
|
||||
Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill},
|
||||
2, []string{"ar-bump", "mf-scan"}),
|
||||
Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill},
|
||||
1, []string{"ar-bump", "al-backfill", "mf-scan"}),
|
||||
Entry("everything, when neither filter is given", nil, nil,
|
||||
4, []string{}),
|
||||
Entry("several kinds and priorities at once",
|
||||
[]model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork},
|
||||
[]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan},
|
||||
2, []string{"ar-bump", "al-backfill"}),
|
||||
Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil,
|
||||
0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}),
|
||||
)
|
||||
|
||||
It("deletes a row that is still backing off", func() {
|
||||
backOff("ar", "ar-bump", time.Now().Add(time.Hour))
|
||||
|
||||
Expect(repo.PurgeQueued([]model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2))
|
||||
Expect(repo.Get(model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)).
|
||||
Error().To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
// A WHERE clause, even one that matches everything, costs SQLite its truncate optimization
|
||||
// and turns `artwork cancel --all` into a full scan of the queue.
|
||||
It("adds no conditions at all for an empty filter", func() {
|
||||
Expect(artworkQueueFilter(nil, nil)).To(BeEmpty())
|
||||
Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1))
|
||||
Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1))
|
||||
Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})).
|
||||
To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -134,11 +134,21 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error {
|
|||
}
|
||||
ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
|
||||
hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime,
|
||||
trace=excluded.trace, last_failure=excluded.last_failure,
|
||||
attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`)
|
||||
_, err = r.items.executeSQL(ins)
|
||||
return err
|
||||
}
|
||||
|
||||
// PutLastFailure records why an item exhausted its retry budget. It only updates an existing row:
|
||||
// inserting one would write an empty hash, which the rest of the system reads as a settled absent.
|
||||
func (r *artworkRepository) PutLastFailure(kind model.Kind, id, imageType, trace string) error {
|
||||
upd := Update(itemArtworkTable).Set("last_failure", trace).
|
||||
Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType})
|
||||
_, err := r.items.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error {
|
||||
for chunk := range slices.Chunk(ids, artworkBatchSize) {
|
||||
if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,51 @@ var _ = Describe("ArtworkRepository", func() {
|
|||
repo = NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
})
|
||||
|
||||
Context("resolution traces", func() {
|
||||
const traceJSON = `[{"c":"cover.*","o":"hit"}]`
|
||||
|
||||
It("round-trips the trace with the state row", func() {
|
||||
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t1",
|
||||
ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed())
|
||||
|
||||
got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.Trace).To(Equal(traceJSON))
|
||||
Expect(got.LastFailure).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("replaces the trace when the item is resolved again", func() {
|
||||
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2",
|
||||
ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed())
|
||||
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2",
|
||||
ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed())
|
||||
|
||||
got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t2", model.ImageTypePrimary)
|
||||
Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`))
|
||||
})
|
||||
|
||||
It("records a last failure on an existing row", func() {
|
||||
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t3",
|
||||
ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed())
|
||||
|
||||
Expect(repo.PutLastFailure(model.KindAlbumArtwork, "t3", model.ImageTypePrimary,
|
||||
`[{"c":"decode","o":"error"}]`)).To(Succeed())
|
||||
|
||||
got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t3", model.ImageTypePrimary)
|
||||
Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`))
|
||||
Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork")
|
||||
})
|
||||
|
||||
// Inserting here would write hash='', which every reader treats as a settled absent.
|
||||
It("never creates a row for an item that has no state", func() {
|
||||
Expect(repo.PutLastFailure(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary,
|
||||
`[{"c":"decode","o":"error"}]`)).To(Succeed())
|
||||
|
||||
_, err := repo.GetItemArtwork(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("image identity", func() {
|
||||
It("stores and retrieves an artwork by hash", func() {
|
||||
a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{
|
|||
"random": {order: "random()"},
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
|
||||
func (c smartPlaylistCriteria) where() (squirrel.Sqlizer, error) {
|
||||
if c.Criteria.Expression == nil {
|
||||
return squirrel.Expr("1 = 1"), nil
|
||||
}
|
||||
|
|
@ -786,7 +786,7 @@ func fieldJoinType(name string) smartPlaylistJoinType {
|
|||
return field.joinType
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
|
||||
func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType {
|
||||
var joins smartPlaylistJoinType
|
||||
_ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
|
||||
for field := range criteria.Fields(expr) {
|
||||
|
|
@ -797,15 +797,50 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
|
|||
return joins
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
|
||||
joins := c.ExpressionJoins()
|
||||
func (c smartPlaylistCriteria) requiredJoins() smartPlaylistJoinType {
|
||||
joins := c.expressionJoins()
|
||||
for _, name := range c.Criteria.SortFieldNames() {
|
||||
joins |= fieldJoinType(name)
|
||||
}
|
||||
return joins
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) OrderBy() string {
|
||||
// applyExpressionJoins adds every join the criteria's WHERE clause resolves against.
|
||||
func (c smartPlaylistCriteria) applyExpressionJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder {
|
||||
return c.applyJoins(sq, c.expressionJoins(), userID)
|
||||
}
|
||||
|
||||
// applyRequiredJoins adds the WHERE joins plus any the ORDER BY resolves against.
|
||||
func (c smartPlaylistCriteria) applyRequiredJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder {
|
||||
return c.applyJoins(sq, c.requiredJoins(), userID)
|
||||
}
|
||||
|
||||
// applyJoins joins the media_file annotation unconditionally — annotation fields
|
||||
// COALESCE a missing row to a default, so the row has to be reachable to be absent.
|
||||
func (c smartPlaylistCriteria) applyJoins(sq squirrel.SelectBuilder, joins smartPlaylistJoinType, userID string) squirrel.SelectBuilder {
|
||||
sq = sq.LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file.id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = ?)", userID)
|
||||
if joins.has(smartPlaylistJoinAlbumAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
|
||||
"album_annotation.item_id = media_file.album_id"+
|
||||
" AND album_annotation.item_type = 'album'"+
|
||||
" AND album_annotation.user_id = ?)", userID)
|
||||
}
|
||||
if joins.has(smartPlaylistJoinArtistAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
|
||||
"artist_annotation.item_id = media_file.artist_id"+
|
||||
" AND artist_annotation.item_type = 'artist'"+
|
||||
" AND artist_annotation.user_id = ?)", userID)
|
||||
}
|
||||
if joins.has(smartPlaylistJoinAlbum) {
|
||||
sq = sq.LeftJoin("album ON album.id = media_file.album_id")
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) orderBy() string {
|
||||
sortFields := c.Criteria.OrderByFields()
|
||||
parts := make([]string, 0, len(sortFields))
|
||||
for _, sf := range sortFields {
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.
|
|||
|
||||
// Build the full query matching buildSmartPlaylistQuery + addCriteria
|
||||
sq := squirrel.Select("media_file.id").From("media_file")
|
||||
cond, err := cSQL.Where()
|
||||
cond, err := cSQL.where()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
|
@ -132,7 +132,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.
|
|||
if expr.Limit > 0 {
|
||||
sq = sq.Limit(uint64(expr.Limit))
|
||||
}
|
||||
if order := cSQL.OrderBy(); order != "" {
|
||||
if order := cSQL.orderBy(); order != "" {
|
||||
sq = sq.OrderBy(order)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
|
||||
DescribeTable("expressions",
|
||||
func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -204,7 +204,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
sqlizer, err := newSmartPlaylistCriteria(
|
||||
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
|
||||
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
|
||||
).Where()
|
||||
).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -217,7 +217,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
sqlizer, err := newSmartPlaylistCriteria(
|
||||
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
|
||||
withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
|
||||
).Where()
|
||||
).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -228,7 +228,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
})
|
||||
|
||||
It("builds relative date expressions", func() {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -238,7 +238,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
})
|
||||
|
||||
It("builds negated relative date expressions", func() {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -248,76 +248,76 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
})
|
||||
|
||||
It("returns an error for unknown fields", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).where()
|
||||
|
||||
Expect(err).To(MatchError("invalid field in criteria: unknown"))
|
||||
})
|
||||
|
||||
It("returns an error when isMissing is used with a regular field", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
|
||||
})
|
||||
|
||||
It("returns an error when isPresent is used with a regular field", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
|
||||
})
|
||||
|
||||
It("returns an error when isMissing has a non-boolean value", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
|
||||
})
|
||||
|
||||
It("returns an error for a range over a tag/role field", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field")))
|
||||
})
|
||||
|
||||
It("returns a clear error for a malformed range value", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair")))
|
||||
})
|
||||
|
||||
Describe("sort", func() {
|
||||
It("sorts by regular fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).orderBy()).To(Equal("media_file.title asc"))
|
||||
})
|
||||
|
||||
It("sorts by tag fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
|
||||
})
|
||||
|
||||
It("sorts by role fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).orderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
|
||||
})
|
||||
|
||||
It("sorts by albumtype alias", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).orderBy()).To(Equal("random() asc"))
|
||||
})
|
||||
|
||||
It("sorts by album columns bare, with no COALESCE default", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).OrderBy()).
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).orderBy()).
|
||||
To(Equal("album.created_at desc, media_file.track_number asc"))
|
||||
})
|
||||
|
||||
It("sorts by multiple fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).orderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
|
||||
})
|
||||
|
||||
It("reverts order when order is desc", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).orderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
|
||||
})
|
||||
|
||||
It("ignores invalid sort fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).orderBy()).To(Equal("media_file.title asc"))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -362,7 +362,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.Contains{"artist": "Kraftwerk"},
|
||||
criteria.Contains{"artist": "Pink Floyd"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -376,7 +376,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.Contains{"artist": "Beatles"},
|
||||
criteria.Contains{"composer": "Lennon"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
|
|
@ -391,7 +391,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.NotContains{"artist": "Beatles"},
|
||||
criteria.NotContains{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
|
|
@ -406,7 +406,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
for i := range anyExprs {
|
||||
anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)}
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -424,7 +424,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.Contains{"artist": "Beatles"},
|
||||
criteria.Contains{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -440,7 +440,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.Contains{"genre": "Metal"},
|
||||
criteria.Contains{"genre": "Punk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -454,7 +454,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.Contains{"genre": "Rock"},
|
||||
criteria.Contains{"mood": "Happy"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
|
|
@ -467,7 +467,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.NotContains{"genre": "Rock"},
|
||||
criteria.NotContains{"genre": "Metal"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
|
|
@ -482,7 +482,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.Contains{"genre": "Rock"},
|
||||
criteria.Contains{"genre": "Metal"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -499,7 +499,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.IsNot{"artist": "Beatles"},
|
||||
criteria.IsNot{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -515,7 +515,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.NotContains{"artist": "Beatles"},
|
||||
criteria.NotContains{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -530,7 +530,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.NotContains{"genre": "Rock"},
|
||||
criteria.NotContains{"genre": "Metal"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -547,7 +547,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.Contains{"artist": "Beatles"},
|
||||
criteria.IsNot{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
|
|
@ -562,7 +562,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.IsNot{"artist": "Beatles"},
|
||||
criteria.IsNot{"composer": "Lennon"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
|
|
@ -575,7 +575,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
for i := range allExprs {
|
||||
allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)}
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
|
|
@ -591,14 +591,14 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}
|
||||
cSQL := newSmartPlaylistCriteria(c)
|
||||
|
||||
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("includes expression-based joins", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
|
||||
|
||||
Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(newSmartPlaylistCriteria(c).expressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects nested album and artist joins", func() {
|
||||
|
|
@ -607,7 +607,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
criteria.Any{criteria.Gt{"artistPlayCount": 10}},
|
||||
}}
|
||||
|
||||
joins := newSmartPlaylistCriteria(c).RequiredJoins()
|
||||
joins := newSmartPlaylistCriteria(c).requiredJoins()
|
||||
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
|
@ -615,20 +615,20 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
It("detects join types from sort fields with direction prefixes", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"}
|
||||
|
||||
Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
||||
Expect(newSmartPlaylistCriteria(c).requiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("keeps a sort-only album join out of the expression joins", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"}
|
||||
cSQL := newSmartPlaylistCriteria(c)
|
||||
|
||||
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue())
|
||||
Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("distinguishes the album join from the album annotation join", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
|
||||
joins := newSmartPlaylistCriteria(c).RequiredJoins()
|
||||
joins := newSmartPlaylistCriteria(c).requiredJoins()
|
||||
|
||||
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse())
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import (
|
|||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -147,9 +145,8 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath
|
|||
pathConditions := make(Or, 0, len(targetPaths)*2)
|
||||
|
||||
for _, targetPath := range targetPaths {
|
||||
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
|
||||
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
// Slash-form like the stored paths; filepath.Clean would backslash them on Windows.
|
||||
cleanPath := path.Clean(strings.TrimPrefix(targetPath, "/"))
|
||||
|
||||
// Include the target folder itself by ID
|
||||
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
|
||||
|
|
@ -172,11 +169,13 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath
|
|||
|
||||
// queryFolderUpdateInfo executes the query and returns the result map
|
||||
func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) {
|
||||
sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
|
||||
sq := r.newSelect().Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where)
|
||||
var res []struct {
|
||||
ID string
|
||||
UpdatedAt time.Time
|
||||
Hash string
|
||||
ID string
|
||||
UpdatedAt time.Time
|
||||
Hash string
|
||||
ImageFiles string
|
||||
ImagesUpdatedAt time.Time
|
||||
}
|
||||
err := r.queryAll(sq, &res)
|
||||
if err != nil {
|
||||
|
|
@ -184,11 +183,41 @@ func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.Fol
|
|||
}
|
||||
m := make(map[string]model.FolderUpdateInfo, len(res))
|
||||
for _, f := range res {
|
||||
m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash}
|
||||
info := model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash, ImagesUpdatedAt: f.ImagesUpdatedAt}
|
||||
if f.ImageFiles != "" {
|
||||
if err := json.Unmarshal([]byte(f.ImageFiles), &info.ImageFiles); err != nil {
|
||||
return nil, fmt.Errorf("parsing folder image_files: %w", err)
|
||||
}
|
||||
}
|
||||
m[f.ID] = info
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// subtreePathChunkSize bounds how many paths one folderSubtreeFilter may expand into: each adds
|
||||
// 3 OR terms, and SQLite rejects an expression tree deeper than 1000 (measured: 166 paths).
|
||||
const subtreePathChunkSize = 100
|
||||
|
||||
// folderSubtreeFilter matches the folders at the given library-relative paths and all their
|
||||
// descendants. A path of "" or "." selects the whole library, so it drops the path conditions.
|
||||
func folderSubtreeFilter(lib model.Library, paths []string) Sqlizer {
|
||||
conds := make(Or, 0, len(paths)*3)
|
||||
for _, p := range paths {
|
||||
// Paths are io/fs slash-form; filepath.Clean would backslash them on Windows.
|
||||
cleanPath := path.Clean(strings.TrimPrefix(p, "/"))
|
||||
if cleanPath == "." {
|
||||
return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}}
|
||||
}
|
||||
conds = append(conds,
|
||||
Eq{"folder.id": model.FolderID(lib, cleanPath)},
|
||||
// Direct children have path = cleanPath; deeper descendants match the prefix
|
||||
Eq{"folder.path": cleanPath},
|
||||
Expr(`folder.path LIKE ? ESCAPE '\'`, escapeLikePrefix(cleanPath)+"/%"),
|
||||
)
|
||||
}
|
||||
return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}, conds}
|
||||
}
|
||||
|
||||
// HasAudioOutsideFolders reports whether any folder in parent's subtree
|
||||
// (including parent itself) contains audio files and is not one of the given
|
||||
// folder IDs. LIKE wildcards in the parent path are escaped, so it is always
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
|
|
@ -43,6 +43,42 @@ var _ = Describe("FolderRepository", func() {
|
|||
_, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute()
|
||||
})
|
||||
|
||||
Describe("folderSubtreeFilter", func() {
|
||||
var parent, child, grandchild, other *model.Folder
|
||||
|
||||
matching := func(paths ...string) []string {
|
||||
GinkgoHelper()
|
||||
folders, err := repo.GetAll(model.QueryOptions{Filters: folderSubtreeFilter(testLib, paths)})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return slice.Map(folders, func(f model.Folder) string { return f.ID })
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
parent = model.NewFolder(testLib, "TestSubtree")
|
||||
child = model.NewFolder(testLib, "TestSubtree/Child")
|
||||
grandchild = model.NewFolder(testLib, "TestSubtree/Child/Grandchild")
|
||||
other = model.NewFolder(testLib, "TestSubtreeOther")
|
||||
for _, f := range []*model.Folder{parent, child, grandchild, other} {
|
||||
Expect(repo.Put(f)).To(Succeed())
|
||||
}
|
||||
DeferCleanup(func() {
|
||||
_, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestSubtree%' OR path LIKE 'TestSubtree%'").Execute()
|
||||
})
|
||||
})
|
||||
|
||||
It("matches a folder and all its descendants", func() {
|
||||
Expect(matching("TestSubtree")).To(ConsistOf(parent.ID, child.ID, grandchild.ID))
|
||||
})
|
||||
|
||||
It("matches the descendants of a nested slash-form path", func() {
|
||||
Expect(matching("TestSubtree/Child")).To(ConsistOf(child.ID, grandchild.ID))
|
||||
})
|
||||
|
||||
It("matches the whole library for the root path", func() {
|
||||
Expect(matching(".")).To(ContainElements(parent.ID, child.ID, grandchild.ID, other.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetFolderUpdateInfo", func() {
|
||||
Context("with no target paths", func() {
|
||||
It("returns all folders in the library", func() {
|
||||
|
|
@ -100,7 +136,6 @@ var _ = Describe("FolderRepository", func() {
|
|||
})
|
||||
|
||||
It("includes all child folders when querying parent", func() {
|
||||
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||
// Create a parent folder with multiple children
|
||||
parent := model.NewFolder(testLib, "TestParent/Music")
|
||||
child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen")
|
||||
|
|
@ -122,7 +157,6 @@ var _ = Describe("FolderRepository", func() {
|
|||
})
|
||||
|
||||
It("excludes children from other libraries", func() {
|
||||
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||
// Create parent in testLib
|
||||
parent := model.NewFolder(testLib, "TestIsolation/Parent")
|
||||
child := model.NewFolder(testLib, "TestIsolation/Parent/Child")
|
||||
|
|
@ -148,7 +182,6 @@ var _ = Describe("FolderRepository", func() {
|
|||
})
|
||||
|
||||
It("excludes missing children when querying parent", func() {
|
||||
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||
// Create parent and children, mark one as missing
|
||||
parent := model.NewFolder(testLib, "TestMissingChild/Parent")
|
||||
child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1")
|
||||
|
|
@ -169,7 +202,6 @@ var _ = Describe("FolderRepository", func() {
|
|||
})
|
||||
|
||||
It("handles mix of existing and non-existing target paths", func() {
|
||||
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||
// Create folders for one path but not the other
|
||||
existingParent := model.NewFolder(testLib, "TestMixed/Exists")
|
||||
existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pocketbase/dbx"
|
||||
|
|
@ -317,6 +318,26 @@ func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string
|
|||
return ids, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs ...string) ([]string, error) {
|
||||
ids := []string{}
|
||||
for chunk := range slices.Chunk(folderIDs, 200) {
|
||||
// A folder's own cover also covers albums whose tracks sit in its disc subfolders.
|
||||
inFolders := Select("f.id").From("folder f").Where(And{
|
||||
Eq{"f.library_id": lib.ID},
|
||||
Eq{"f.missing": false},
|
||||
Or{Eq{"f.id": chunk}, Eq{"f.parent_id": chunk}},
|
||||
})
|
||||
sq := Select("distinct album_id").From("media_file").
|
||||
Where(And{Eq{"missing": false}, ConcatExpr("folder_id IN (", inFolders, ")")})
|
||||
var chunkIDs []string
|
||||
if err := r.queryAllSlice(sq, &chunkIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, chunkIDs...)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass.
|
||||
func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) {
|
||||
ids, err := r.GetAllIDs(options...)
|
||||
|
|
@ -517,6 +538,23 @@ var mediaFileSearchConfig = searchConfig{
|
|||
MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"},
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) MatchesCriteria(id string, c criteria.Criteria) (bool, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
rulesSQL := newSmartPlaylistCriteria(c, withSmartPlaylistOwner(*usr))
|
||||
cond, err := rulesSQL.where()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
sq := Select("count(*) as count").From("media_file")
|
||||
sq = rulesSQL.applyExpressionJoins(sq, usr.ID)
|
||||
sq = sq.Where(And{Eq{"media_file.id": id}, cond})
|
||||
var res struct{ Count int64 }
|
||||
if err := r.queryOne(sq, &res); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.Count > 0, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
var opts model.QueryOptions
|
||||
if len(options) > 0 {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -29,6 +30,54 @@ var _ = Describe("MediaRepository", func() {
|
|||
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("GetAlbumIDsByFolder", func() {
|
||||
var lib model.Library
|
||||
var albumRoot, disc1, sibling *model.Folder
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
|
||||
libPtr, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
lib = *libPtr
|
||||
|
||||
folderRepo := newFolderRepository(ctx, GetDBXBuilder())
|
||||
albumRoot = model.NewFolder(lib, "ByFolder/Album")
|
||||
disc1 = model.NewFolder(lib, "ByFolder/Album/CD1")
|
||||
sibling = model.NewFolder(lib, "ByFolder/Other")
|
||||
for _, f := range []*model.Folder{albumRoot, disc1, sibling} {
|
||||
Expect(folderRepo.Put(f)).To(Succeed())
|
||||
}
|
||||
// Tracks live in the disc subfolder; the sibling album is the negative control.
|
||||
Expect(mr.Put(&model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed())
|
||||
DeferCleanup(func() {
|
||||
_, _ = GetDBXBuilder().NewQuery("DELETE FROM media_file WHERE id LIKE 'fol-mf-%'").Execute()
|
||||
_, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'ByFolder%' OR name = 'ByFolder'").Execute()
|
||||
})
|
||||
})
|
||||
|
||||
It("returns the distinct album IDs of non-missing tracks in the folder", func() {
|
||||
ids, err := mr.GetAlbumIDsByFolder(lib, disc1.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(ConsistOf("fol-al-1"))
|
||||
})
|
||||
|
||||
It("also matches albums whose tracks are in a direct child of the folder", func() {
|
||||
// A cover in the album root must reach the album whose tracks sit in CD1
|
||||
ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(ConsistOf("fol-al-1"))
|
||||
})
|
||||
|
||||
It("does not match albums outside the folder", func() {
|
||||
ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).ToNot(ContainElement("fol-al-2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetCursor", func() {
|
||||
It("yields the same media files as GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "title"}
|
||||
|
|
@ -1129,4 +1178,31 @@ var _ = Describe("MediaRepository", func() {
|
|||
Expect(NewMediaFileRepository(rctx, GetDBXBuilder()).Exists(songAntenna.ID)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MatchesCriteria", func() {
|
||||
It("returns true when the track matches", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}}
|
||||
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(match).To(BeTrue())
|
||||
})
|
||||
It("returns false when the track does not match", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}}
|
||||
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(match).To(BeFalse())
|
||||
})
|
||||
It("treats missing annotations as their COALESCE default", func() {
|
||||
// unrated track: rating coalesces to 0, so "rating < 4" matches
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}}
|
||||
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(match).To(BeTrue())
|
||||
})
|
||||
It("returns an error for an invalid field", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}}
|
||||
_, err := mr.MatchesCriteria(songDayInALife.ID, c)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue