mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-10 20:47:26 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f3f5b77d | ||
|
|
c35b14dd74 | ||
|
|
6c2644d208 | ||
|
|
bf79d2f3a2 | ||
|
|
fed9665060 | ||
|
|
e6597398c2 | ||
|
|
5927e693d1 | ||
|
|
b27d6f61ae | ||
|
|
4efd92cf83 | ||
|
|
7234ea23b7 | ||
|
|
59f1b4206c | ||
|
|
3158451b8d | ||
|
|
1e82f515c4 | ||
|
|
09ac342f5a | ||
|
|
9c7cf7d734 | ||
|
|
e4a423db11 | ||
|
|
f61b4eee21 | ||
|
|
deaa5e6c02 | ||
|
|
64430af9ce | ||
|
|
85132240e0 | ||
|
|
ae7e81e33f | ||
|
|
27f0210392 | ||
|
|
756df9decf | ||
|
|
29f481cd7b | ||
|
|
c582ed31fa | ||
|
|
caa0043030 | ||
|
|
1d5efdd5a0 | ||
|
|
09022b4bd2 | ||
|
|
adeaa93e7e | ||
|
|
3d438b08ef | ||
|
|
53d54baef0 | ||
|
|
fe6ac2e577 | ||
|
|
3cd4f1eb24 | ||
|
|
ca27335d06 | ||
|
|
9ae252c418 | ||
|
|
feda8de7e9 | ||
|
|
edddc1acb5 | ||
|
|
cc315dcc8c | ||
|
|
4998ac2c59 | ||
|
|
969e7e108c | ||
|
|
6b9f85efcc | ||
|
|
a5efba9a08 |
No files matched your search
@@ -8,7 +8,7 @@ jobs:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
# This snippet is public-domain, taken from
|
||||
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
||||
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Cache ffmpeg
|
||||
id: ffmpeg-cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: C:\ffmpeg
|
||||
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
@@ -323,7 +323,7 @@ jobs:
|
||||
|
||||
- name: Set up QEMU for smoke test
|
||||
if: env.IS_LINUX == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
# The binary is static, so binfmt+qemu runs it directly on the runner.
|
||||
# Catches startup crashes in cross-compiled binaries before they ship,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -95,13 +97,32 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
|
||||
}
|
||||
}
|
||||
|
||||
// If the first one has the same name, that's the one
|
||||
if !strings.EqualFold(artists[0].Name, name) {
|
||||
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
|
||||
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
|
||||
// ahead of non-matches, prefer an exact-case match, then the most fans.
|
||||
rank := func(a Artist) int {
|
||||
switch {
|
||||
case a.Name == name:
|
||||
return 2
|
||||
case strings.EqualFold(a.Name, name):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
slices.SortFunc(artists, func(a, b Artist) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(rank(b), rank(a)),
|
||||
cmp.Compare(b.NbFan, a.NbFan),
|
||||
cmp.Compare(a.ID, b.ID),
|
||||
)
|
||||
})
|
||||
best := artists[0]
|
||||
if !strings.EqualFold(best.Name, name) {
|
||||
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
|
||||
return &artists[0], err
|
||||
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
|
||||
return new(best), nil
|
||||
}
|
||||
|
||||
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
|
||||
|
||||
@@ -34,6 +34,66 @@ var _ = Describe("deezerAgent", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("searchArtist", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *fakeHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
agent = &deezerAgent{
|
||||
dataStore: &tests.MockDataStore{},
|
||||
client: newClient(httpClient),
|
||||
}
|
||||
})
|
||||
|
||||
It("picks the exact-name match with the most fans when several share the name", func() {
|
||||
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":61045802,"name":"Queen","nb_fan":75},
|
||||
{"id":141954732,"name":"Queen","nb_fan":397},
|
||||
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
|
||||
{"id":183179807,"name":"Queen","nb_fan":53},
|
||||
{"id":412,"name":"Queen","nb_fan":12744378}
|
||||
],"total":5}`)),
|
||||
})
|
||||
|
||||
artist, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artist.ID).To(Equal(412))
|
||||
})
|
||||
|
||||
It("matches the name case-insensitively", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":1,"name":"QUEEN","nb_fan":10},
|
||||
{"id":2,"name":"queen","nb_fan":20}
|
||||
],"total":2}`)),
|
||||
})
|
||||
|
||||
artist, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artist.ID).To(Equal(2))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when no result matches the name exactly", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
|
||||
],"total":1}`)),
|
||||
})
|
||||
|
||||
_, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistBiography - Language Fallback", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *langAwareHttpClient
|
||||
|
||||
+18
-7
@@ -86,7 +86,7 @@ func runNavidrome(ctx context.Context) {
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(schedulePeriodicBackup(ctx))
|
||||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBOptimizer(ctx))
|
||||
g.Go(scheduleDBAnalyzer(ctx))
|
||||
g.Go(startPluginManager(ctx))
|
||||
g.Go(runInitialScan(ctx))
|
||||
if conf.Server.Scanner.Enabled {
|
||||
@@ -124,6 +124,9 @@ func startServer(ctx context.Context) func() error {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
||||
}
|
||||
if conf.Server.Jellyfin.Enabled {
|
||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
@@ -275,16 +278,24 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleDBOptimizer(ctx context.Context) func() error {
|
||||
func scheduleDBAnalyzer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
|
||||
if !conf.Server.EnableScheduledDBAnalyze {
|
||||
log.Info(ctx, "Scheduled DB analysis is DISABLED")
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule)
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
|
||||
if scanner.IsScanning() {
|
||||
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
|
||||
_, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() {
|
||||
release, ok := scanner.LockForMaintenance()
|
||||
if !ok {
|
||||
log.Debug(ctx, "Skipping DB analysis check because a scan is in progress")
|
||||
return
|
||||
}
|
||||
db.Optimize(ctx)
|
||||
defer release()
|
||||
if _, err := db.OptimizeIfNeeded(ctx); err != nil {
|
||||
log.Error(ctx, "Error analyzing DB", err)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
+34
-3
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -43,15 +44,20 @@ var scanCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
|
||||
var changesDetected bool
|
||||
var scanErrors []error
|
||||
for status := range pl.ReadOrDone(ctx, progress) {
|
||||
if status.Warning != "" {
|
||||
log.Warn(ctx, "Scan warning", "error", status.Warning)
|
||||
}
|
||||
if status.Error != "" {
|
||||
log.Error(ctx, "Scan error", "error", status.Error)
|
||||
scanErrors = append(scanErrors, errors.New(status.Error))
|
||||
}
|
||||
if status.ChangesDetected {
|
||||
changesDetected = true
|
||||
}
|
||||
// Discard the progress status, we only care about errors
|
||||
}
|
||||
|
||||
if fullScan {
|
||||
@@ -59,6 +65,7 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre
|
||||
} else {
|
||||
log.Info("Finished rescan")
|
||||
}
|
||||
return changesDetected, errors.Join(scanErrors...)
|
||||
}
|
||||
|
||||
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||
@@ -95,6 +102,16 @@ func runScanner(ctx context.Context) {
|
||||
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
|
||||
}
|
||||
|
||||
effectiveFullScan := fullScan
|
||||
if !subprocess {
|
||||
effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets)
|
||||
if effectiveFullScan {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to scan", err)
|
||||
@@ -104,7 +121,21 @@ func runScanner(ctx context.Context) {
|
||||
if subprocess {
|
||||
trackScanAsSubprocess(ctx, progress)
|
||||
} else {
|
||||
trackScanInteractively(ctx, progress)
|
||||
changesDetected, scanErr := trackScanInteractively(ctx, progress)
|
||||
runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
|
||||
}
|
||||
}
|
||||
|
||||
func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) {
|
||||
if changesDetected {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
if effectiveFullScan && scanErr == nil {
|
||||
if err := db.Optimize(ctx); err != nil {
|
||||
log.Error(ctx, "Error analyzing DB", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("trackScanInteractively", func() {
|
||||
It("reports changes and scan errors", func() {
|
||||
progress := make(chan *scanner.ProgressInfo, 2)
|
||||
progress <- &scanner.ProgressInfo{ChangesDetected: true}
|
||||
progress <- &scanner.ProgressInfo{Error: "scan failed"}
|
||||
close(progress)
|
||||
|
||||
changesDetected, err := trackScanInteractively(context.Background(), progress)
|
||||
Expect(changesDetected).To(BeTrue())
|
||||
Expect(err).To(MatchError("scan failed"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("readTargetsFromFile", func() {
|
||||
var tempDir string
|
||||
|
||||
|
||||
+27
-1
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
@@ -116,6 +117,31 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
players := core.NewPlayers(dataStore)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
@@ -221,7 +247,7 @@ func getPluginManager() *plugins.Manager {
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
@@ -33,6 +34,7 @@ var allProviders = wire.NewSet(
|
||||
artwork.Set,
|
||||
server.New,
|
||||
subsonic.New,
|
||||
jellyfin.New,
|
||||
nativeapi.New,
|
||||
public.New,
|
||||
persistence.New,
|
||||
@@ -49,6 +51,7 @@ var allProviders = wire.NewSet(
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(sonic.Engine), new(*sonic.Sonic)),
|
||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||
@@ -79,6 +82,12 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
||||
+31
-2
@@ -51,6 +51,7 @@ type configOptions struct {
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
EnableInsightsCollector bool
|
||||
EnableScheduledDBAnalyze bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
@@ -116,6 +117,7 @@ type configOptions struct {
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
Jellyfin jellyfinOptions `json:",omitzero"`
|
||||
EnableScrobbleHistory bool
|
||||
Tags map[string]TagConf `json:",omitempty"`
|
||||
Agents string
|
||||
@@ -147,7 +149,6 @@ type configOptions struct {
|
||||
DevEnablePluginsInsights bool
|
||||
DevPluginCompilationTimeout time.Duration
|
||||
DevExternalArtistFetchMultiplier float64
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
}
|
||||
@@ -218,6 +219,18 @@ type listenBrainzOptions struct {
|
||||
TrackAlgorithm string
|
||||
}
|
||||
|
||||
type jellyfinOptions struct {
|
||||
Enabled bool
|
||||
ServerName string
|
||||
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
||||
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
||||
ExposedPublicUsers string
|
||||
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
||||
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
||||
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
||||
MaxConcurrentStreams int
|
||||
}
|
||||
|
||||
type httpHeaderOptions struct {
|
||||
FrameOptions string
|
||||
}
|
||||
@@ -800,6 +813,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enableinsightscollector", true)
|
||||
viper.SetDefault("enablescheduleddbanalyze", true)
|
||||
viper.SetDefault("enablelogredacting", true)
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
@@ -848,6 +862,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
||||
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
||||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||
viper.SetDefault("jellyfin.enabled", false)
|
||||
viper.SetDefault("jellyfin.servername", "")
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
@@ -877,6 +893,9 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||
// scrobbles and the UI. See MaxOpenConns.
|
||||
viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2))
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
@@ -891,7 +910,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devenablepluginsinsights", true)
|
||||
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
}
|
||||
@@ -948,3 +966,14 @@ func getConfigFile(cfgFile string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner,
|
||||
// Subsonic, Jellyfin, native API, UI).
|
||||
//
|
||||
// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so
|
||||
// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a
|
||||
// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the
|
||||
// count is only loosely related to core count, and the floor is what matters on small machines.
|
||||
func MaxOpenConns() int {
|
||||
return max(4, runtime.NumCPU())
|
||||
}
|
||||
@@ -58,6 +58,19 @@ var _ = Describe("Configuration", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("scheduled DB analysis", func() {
|
||||
It("is enabled by default", func() {
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue())
|
||||
})
|
||||
|
||||
It("can be disabled", func() {
|
||||
viper.Set("enablescheduleddbanalyze", false)
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ValidateURL", func() {
|
||||
It("accepts a valid http URL", func() {
|
||||
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
||||
|
||||
+11
-1
@@ -20,6 +20,10 @@ const (
|
||||
LastScanErrorKey = "LastScanError"
|
||||
LastScanTypeKey = "LastScanType"
|
||||
LastScanStartTimeKey = "LastScanStartTime"
|
||||
LastDBAnalyzeAtKey = "LastDBAnalyzeAt"
|
||||
LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt"
|
||||
DBAnalyzePendingKey = "DBAnalyzePending"
|
||||
DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
@@ -28,7 +32,8 @@ const (
|
||||
DefaultSessionTimeout = 48 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
||||
OptimizeDBSchedule = "@every 24h"
|
||||
DBAnalyzeCheckSchedule = "@every 30m"
|
||||
DBAnalyzeMaxAge = 24 * time.Hour
|
||||
|
||||
// DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option
|
||||
// Never ever change this! Or it will break all Navidrome installations that don't set the config option
|
||||
@@ -44,6 +49,11 @@ const (
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathJellyfinAPI = "/jellyfin"
|
||||
|
||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
||||
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||
JellyfinServerIDKey = "JellyfinServerID"
|
||||
|
||||
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
|
||||
// available at https://unsplash.com/collections/20072696/navidrome
|
||||
|
||||
+69
-4
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -67,7 +68,7 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
|
||||
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
|
||||
)
|
||||
|
||||
type ffmpeg struct{}
|
||||
@@ -159,16 +160,80 @@ func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioP
|
||||
return nil, err
|
||||
}
|
||||
if err := fileExists(filePath); err != nil {
|
||||
return nil, err
|
||||
return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err),
|
||||
NotFound: errors.Is(err, fs.ErrNotExist), err: err}
|
||||
}
|
||||
args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
|
||||
log.Trace(ctx, "Executing ffprobe command", "args", args)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
|
||||
return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err}
|
||||
}
|
||||
return parseProbeOutput(output)
|
||||
result, err := parseProbeOutput(output)
|
||||
if err != nil {
|
||||
return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ProbeError reports an ffprobe failure. Reason is a path-free message safe to
|
||||
// expose to clients; the wrapped cause carries the full detail for logging.
|
||||
// NotFound marks the media file itself as missing — a launch failure of a
|
||||
// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer
|
||||
// it from the error chain.
|
||||
type ProbeError struct {
|
||||
Path string
|
||||
Reason string
|
||||
NotFound bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *ProbeError) Error() string {
|
||||
if e.err == nil {
|
||||
return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason)
|
||||
}
|
||||
return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err))
|
||||
}
|
||||
|
||||
// Unwrap exposes the underlying cause so callers can test it with errors.Is
|
||||
// (e.g. fs.ErrNotExist to detect a missing file).
|
||||
func (e *ProbeError) Unwrap() error { return e.err }
|
||||
|
||||
// SafeReason returns the path-free reason, safe to send to clients.
|
||||
func (e *ProbeError) SafeReason() string { return e.Reason }
|
||||
|
||||
// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved
|
||||
// or unreadable file reads as "file not found" rather than a raw ffprobe message.
|
||||
func fileAccessReason(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return "file not found"
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
return "permission denied"
|
||||
default:
|
||||
return "file not accessible"
|
||||
}
|
||||
}
|
||||
|
||||
// probeDetail returns the full diagnostic for logging (may contain paths):
|
||||
// ffprobe's stderr when present, otherwise the raw error text.
|
||||
func probeDetail(err error) string {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 {
|
||||
return strings.TrimSpace(string(exitErr.Stderr))
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// probeClientReason returns a path-free reason for an ffprobe execution failure:
|
||||
// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe
|
||||
// couldn't run at all (its launch error may embed the binary path).
|
||||
func probeClientReason(err error, path string) string {
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok || len(exitErr.Stderr) == 0 {
|
||||
return "could not read file"
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file"))
|
||||
}
|
||||
|
||||
type probeOutput struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -553,6 +554,65 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ProbeError", func() {
|
||||
It("uses the underlying cause in Error() so logs keep the full detail", func() {
|
||||
e := &ProbeError{Path: "/music/foo.flac",
|
||||
err: errors.New("/music/foo.flac: Invalid data found when processing input")}
|
||||
Expect(e.Error()).To(ContainSubstring("/music/foo.flac"))
|
||||
Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input"))
|
||||
})
|
||||
|
||||
It("returns the path-free reason from SafeReason()", func() {
|
||||
e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"}
|
||||
Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input"))
|
||||
Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac"))
|
||||
})
|
||||
|
||||
It("unwraps to the underlying cause so errors.Is detects a missing file", func() {
|
||||
e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist}
|
||||
Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("probeClientReason", func() {
|
||||
It("strips the file path from ffprobe stderr", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
Skip("uses /bin/sh")
|
||||
}
|
||||
_, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found"))
|
||||
})
|
||||
|
||||
It("returns a generic reason for launch failures, without leaking the binary path", func() {
|
||||
err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory")
|
||||
Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("probeDetail", func() {
|
||||
It("surfaces ffprobe stderr for logging", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
Skip("uses /bin/sh")
|
||||
}
|
||||
_, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(probeDetail(err)).To(Equal("boom detail"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fileAccessReason", func() {
|
||||
It("reports a missing file as 'file not found', not a raw stat message", func() {
|
||||
_, err := os.Stat("/no/such/dir/really-missing.flac")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(fileAccessReason(err)).To(Equal("file not found"))
|
||||
})
|
||||
|
||||
It("falls back to a generic reason for other access errors", func() {
|
||||
Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FFmpeg", func() {
|
||||
Context("when FFmpeg is available", func() {
|
||||
var ff FFmpeg
|
||||
@@ -566,6 +626,16 @@ var _ = Describe("ffmpeg", func() {
|
||||
}
|
||||
})
|
||||
|
||||
It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() {
|
||||
_, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue())
|
||||
var pe *ProbeError
|
||||
Expect(errors.As(err, &pe)).To(BeTrue())
|
||||
Expect(pe.SafeReason()).To(Equal("file not found"))
|
||||
Expect(pe.NotFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should interrupt transcoding when context is cancelled", func() {
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
@@ -17,6 +20,16 @@ type ImageUploadService interface {
|
||||
RemoveImage(ctx context.Context, path string) error
|
||||
}
|
||||
|
||||
// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default
|
||||
// when it's unset/invalid. Shared by every API that accepts image uploads.
|
||||
func MaxImageUploadSize() int64 {
|
||||
if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 {
|
||||
return int64(size)
|
||||
}
|
||||
size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize)
|
||||
return int64(size)
|
||||
}
|
||||
|
||||
type imageUploadService struct{}
|
||||
|
||||
func NewImageUploadService() ImageUploadService {
|
||||
|
||||
@@ -97,3 +97,29 @@ var _ = Describe("ImageUploadService", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("MaxImageUploadSize", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
It("returns the configured size when valid", func() {
|
||||
conf.Server.MaxImageUploadSize = "20MB"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000)))
|
||||
})
|
||||
|
||||
It("returns the default size when config is empty", func() {
|
||||
conf.Server.MaxImageUploadSize = ""
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000)))
|
||||
})
|
||||
|
||||
It("returns the default size when config is invalid", func() {
|
||||
conf.Server.MaxImageUploadSize = "not-a-size"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000)))
|
||||
})
|
||||
|
||||
It("parses raw byte values", func() {
|
||||
conf.Server.MaxImageUploadSize = "52428800"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800)))
|
||||
})
|
||||
})
|
||||
@@ -223,6 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
@@ -43,45 +43,46 @@ type Data struct {
|
||||
FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"`
|
||||
} `json:"library"`
|
||||
Config struct {
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
|
||||
TLSConfigured bool `json:"tlsConfigured,omitempty"`
|
||||
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
|
||||
ScannerExtractor string `json:"scannerExtractor,omitempty"`
|
||||
ScanSchedule string `json:"scanSchedule,omitempty"`
|
||||
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
|
||||
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
|
||||
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
|
||||
ImageCacheSize string `json:"imageCacheSize,omitempty"`
|
||||
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
|
||||
EnableDownloads bool `json:"enableDownloads,omitempty"`
|
||||
EnableSharing bool `json:"enableSharing,omitempty"`
|
||||
EnableStarRating bool `json:"enableStarRating,omitempty"`
|
||||
EnableLastFM bool `json:"enableLastFM,omitempty"`
|
||||
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||
EnableDeezer bool `json:"enableDeezer,omitempty"`
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
||||
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
||||
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
SearchBackend string `json:"searchBackend,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
BackupCount int `json:"backupCount,omitempty"`
|
||||
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
|
||||
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
|
||||
HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"`
|
||||
ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"`
|
||||
HasCustomPID bool `json:"hasCustomPID,omitempty"`
|
||||
HasCustomTags bool `json:"hasCustomTags,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
|
||||
TLSConfigured bool `json:"tlsConfigured,omitempty"`
|
||||
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
|
||||
ScannerExtractor string `json:"scannerExtractor,omitempty"`
|
||||
ScanSchedule string `json:"scanSchedule,omitempty"`
|
||||
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
|
||||
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
|
||||
EnableScheduledDBAnalyze bool `json:"enableScheduledDBAnalyze,omitempty"`
|
||||
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
|
||||
ImageCacheSize string `json:"imageCacheSize,omitempty"`
|
||||
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
|
||||
EnableDownloads bool `json:"enableDownloads,omitempty"`
|
||||
EnableSharing bool `json:"enableSharing,omitempty"`
|
||||
EnableStarRating bool `json:"enableStarRating,omitempty"`
|
||||
EnableLastFM bool `json:"enableLastFM,omitempty"`
|
||||
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||
EnableDeezer bool `json:"enableDeezer,omitempty"`
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
||||
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
||||
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
SearchBackend string `json:"searchBackend,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
BackupCount int `json:"backupCount,omitempty"`
|
||||
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
|
||||
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
|
||||
HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"`
|
||||
ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"`
|
||||
HasCustomPID bool `json:"hasCustomPID,omitempty"`
|
||||
HasCustomTags bool `json:"hasCustomTags,omitempty"`
|
||||
} `json:"config"`
|
||||
Plugins map[string]PluginInfo `json:"plugins,omitempty"`
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -187,7 +186,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
newPls.EvaluatedAt = nil // force re-evaluation on next read
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
|
||||
@@ -22,6 +22,7 @@ type Playlists interface {
|
||||
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
|
||||
Get(ctx context.Context, id string) (*model.Playlist, error)
|
||||
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
|
||||
Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error)
|
||||
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
|
||||
|
||||
// Mutations
|
||||
@@ -98,6 +99,21 @@ func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model
|
||||
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
|
||||
}
|
||||
|
||||
// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather
|
||||
// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards
|
||||
// its error behind a nil (and warns), and this is probed with ids that are usually not playlists.
|
||||
func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) {
|
||||
repo := s.ds.Playlist(ctx)
|
||||
if _, err := repo.Get(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks := repo.Tracks(id, true)
|
||||
if tracks == nil {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// --- Mutation operations ---
|
||||
|
||||
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
|
||||
|
||||
@@ -73,6 +73,28 @@ var _ = Describe("Playlists", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Tracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("returns the playlist's track repository", func() {
|
||||
Expect(ps.Tracks(ctx, "pls-1")).To(BeIdenticalTo(mockTracks))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound for an unknown or invisible playlist", func() {
|
||||
_, err := ps.Tracks(ctx, "nonexistent")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
|
||||
@@ -135,6 +135,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
|
||||
}
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
|
||||
@@ -314,6 +314,38 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("resets EvaluatedAt when rules change", func() {
|
||||
evaluatedAt := time.Now().Add(-1 * time.Hour)
|
||||
mockPlsRepo.Data["smart-reset"] = &model.Playlist{
|
||||
ID: "smart-reset",
|
||||
Name: "Smart",
|
||||
OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
|
||||
EvaluatedAt: &evaluatedAt,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}}
|
||||
err := repo.Update("smart-reset", &model.Playlist{Rules: newRules}, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil())
|
||||
})
|
||||
|
||||
It("keeps EvaluatedAt when rules are not changed", func() {
|
||||
evaluatedAt := time.Now().Add(-1 * time.Hour)
|
||||
mockPlsRepo.Data["smart-keep"] = &model.Playlist{
|
||||
ID: "smart-keep",
|
||||
Name: "Smart",
|
||||
OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
|
||||
EvaluatedAt: &evaluatedAt,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("smart-keep", &model.Playlist{Name: "Renamed Smart"}, "name")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil())
|
||||
Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second))
|
||||
})
|
||||
|
||||
It("updates name and rules together (smart-playlist Edit form)", func() {
|
||||
mockPlsRepo.Data["smart-edit"] = &model.Playlist{
|
||||
ID: "smart-edit",
|
||||
|
||||
@@ -10,6 +10,30 @@ import (
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
const (
|
||||
minRetryDelay = 5 * time.Second
|
||||
maxRetryDelay = 4 * time.Minute
|
||||
// maxRetryShift caps the exponent so the shift never overflows int64.
|
||||
// minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling.
|
||||
maxRetryShift = 6
|
||||
)
|
||||
|
||||
// backoffDelay returns the delay for a zero-based retry index (0 = first retry):
|
||||
// minRetryDelay doubled per prior failure, clamped to maxRetryDelay.
|
||||
func backoffDelay(failures int) time.Duration {
|
||||
if failures < 0 {
|
||||
failures = 0
|
||||
}
|
||||
if failures >= maxRetryShift {
|
||||
return maxRetryDelay
|
||||
}
|
||||
d := minRetryDelay << failures
|
||||
if d > maxRetryDelay {
|
||||
return maxRetryDelay
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Loader is a function that loads a scrobbler by name.
|
||||
// It returns the scrobbler and true if found, or nil and false if not available.
|
||||
// This allows the buffered scrobbler to always get the current plugin instance.
|
||||
@@ -98,15 +122,23 @@ func (b *bufferedScrobbler) sendWakeSignal() {
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) run(ctx context.Context) {
|
||||
timer := time.NewTimer(time.Hour)
|
||||
timer.Stop()
|
||||
defer timer.Stop()
|
||||
failures := 0
|
||||
for {
|
||||
if !b.processQueue(ctx) {
|
||||
time.AfterFunc(5*time.Second, func() {
|
||||
b.sendWakeSignal()
|
||||
})
|
||||
if b.processQueue(ctx) {
|
||||
failures = 0
|
||||
timer.Stop()
|
||||
} else {
|
||||
timer.Reset(backoffDelay(failures))
|
||||
if failures < maxRetryShift {
|
||||
failures++
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-b.wakeSignal:
|
||||
continue
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -100,3 +103,91 @@ var _ = Describe("BufferedScrobbler", func() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("backoffDelay", func() {
|
||||
DescribeTable("computes the exponential backoff curve clamped to the ceiling",
|
||||
func(failures int, expected time.Duration) {
|
||||
Expect(backoffDelay(failures)).To(Equal(expected))
|
||||
},
|
||||
Entry("first failure", 0, 5*time.Second),
|
||||
Entry("second failure", 1, 10*time.Second),
|
||||
Entry("third failure", 2, 20*time.Second),
|
||||
Entry("fourth failure", 3, 40*time.Second),
|
||||
Entry("fifth failure", 4, 80*time.Second),
|
||||
Entry("sixth failure", 5, 160*time.Second),
|
||||
Entry("reaches the ceiling", 6, 4*time.Minute),
|
||||
Entry("stays clamped past the ceiling", 7, 4*time.Minute),
|
||||
Entry("stays clamped for large values", 1000, 4*time.Minute),
|
||||
Entry("negative is treated as zero", -1, 5*time.Second),
|
||||
)
|
||||
})
|
||||
|
||||
// Drives the real run loop and asserts the exact retry schedule + recovery. Plain
|
||||
// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give.
|
||||
func TestBufferedScrobblerBackoffSchedule(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
buffer := tests.CreateMockedScrobbleBufferRepo()
|
||||
userRepo := tests.CreateMockUserRepo()
|
||||
g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed())
|
||||
ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo}
|
||||
|
||||
flaky := &recoveringScrobbler{}
|
||||
flaky.fail(ErrRetryLater)
|
||||
bs := newBufferedScrobbler(ds, flaky, "flaky")
|
||||
defer func() { bs.Stop(); synctest.Wait() }()
|
||||
|
||||
// Let the loop settle on the empty buffer, then enqueue a scrobble.
|
||||
synctest.Wait()
|
||||
track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"}
|
||||
g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed())
|
||||
|
||||
// First attempt fires immediately on the enqueue wake and is left buffered.
|
||||
synctest.Wait()
|
||||
g.Expect(flaky.count.Load()).To(Equal(int32(1)))
|
||||
g.Expect(buffer.Length()).To(Equal(int64(1)))
|
||||
|
||||
// Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s.
|
||||
for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} {
|
||||
want := int32(i + 2)
|
||||
time.Sleep(gap - time.Nanosecond)
|
||||
synctest.Wait()
|
||||
g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap)
|
||||
time.Sleep(time.Nanosecond)
|
||||
synctest.Wait()
|
||||
g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap)
|
||||
}
|
||||
|
||||
// Once the service recovers, waking the loop drains the buffered entry.
|
||||
flaky.succeed()
|
||||
bs.sendWakeSignal()
|
||||
synctest.Wait()
|
||||
g.Expect(buffer.Length()).To(Equal(int64(0)))
|
||||
})
|
||||
}
|
||||
|
||||
// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while
|
||||
// the buffered scrobbler's goroutine is draining, to exercise retry then recovery.
|
||||
type recoveringScrobbler struct {
|
||||
err atomic.Pointer[error]
|
||||
count atomic.Int32
|
||||
}
|
||||
|
||||
func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) }
|
||||
func (f *recoveringScrobbler) succeed() { f.err.Store(nil) }
|
||||
|
||||
func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true }
|
||||
|
||||
func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error {
|
||||
f.count.Add(1)
|
||||
if e := f.err.Load(); e != nil {
|
||||
return *e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil }
|
||||
@@ -89,6 +89,7 @@ type playTracker struct {
|
||||
ds model.DataStore
|
||||
broker events.Broker
|
||||
playMap cache.SimpleCache[string, PlaybackSession]
|
||||
sessionsMu sync.Mutex // serializes playMap check-then-write across concurrent reports
|
||||
builtinScrobblers map[string]Scrobbler
|
||||
pluginScrobblers map[string]Scrobbler
|
||||
pluginLoader PluginLoader
|
||||
@@ -249,6 +250,12 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
|
||||
return combined
|
||||
}
|
||||
|
||||
// hasPlayingSession reports whether clientId's current session is already playing mediaId.
|
||||
func (p *playTracker) hasPlayingSession(clientId, mediaId string) bool {
|
||||
cur, err := p.playMap.Get(clientId)
|
||||
return err == nil && cur.MediaFile.ID == mediaId && cur.State == StatePlaying
|
||||
}
|
||||
|
||||
func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration {
|
||||
if rate <= 0 {
|
||||
rate = 1.0
|
||||
@@ -268,6 +275,12 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
|
||||
switch params.State {
|
||||
case StateStarting:
|
||||
// Clients may send starting/playing unordered; a late "starting" must not downgrade
|
||||
// a playing session, or position estimation freezes until the next report.
|
||||
if p.hasPlayingSession(clientId, params.MediaId) {
|
||||
log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId)
|
||||
return nil
|
||||
}
|
||||
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -284,7 +297,15 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
PlaybackRate: params.PlaybackRate,
|
||||
LastReport: now,
|
||||
}
|
||||
p.sessionsMu.Lock()
|
||||
// re-check: a concurrent "playing" report may have created the session during the load above
|
||||
if p.hasPlayingSession(clientId, params.MediaId) {
|
||||
p.sessionsMu.Unlock()
|
||||
log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId)
|
||||
return nil
|
||||
}
|
||||
err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
|
||||
p.sessionsMu.Unlock()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
}
|
||||
@@ -315,7 +336,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
|
||||
}
|
||||
log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl)
|
||||
p.sessionsMu.Lock()
|
||||
err := p.playMap.AddWithTTL(clientId, info, ttl)
|
||||
p.sessionsMu.Unlock()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
}
|
||||
@@ -339,6 +362,17 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
p.dispatchScrobble(ctx, mf, now)
|
||||
}
|
||||
}
|
||||
p.sessionsMu.Lock()
|
||||
info, getErr := p.playMap.Get(clientId)
|
||||
// A late stop for a previous track must not end the current session nor reach
|
||||
// playback reporters, or presence-style plugins would clear the active track.
|
||||
if getErr == nil && info.MediaFile.ID != params.MediaId {
|
||||
p.sessionsMu.Unlock()
|
||||
log.Trace(ctx, "Ignoring out-of-order stopped report for different track", "clientId", clientId, "stoppedMediaId", params.MediaId, "currentMediaId", info.MediaFile.ID)
|
||||
return nil
|
||||
}
|
||||
p.playMap.Remove(clientId)
|
||||
p.sessionsMu.Unlock()
|
||||
stoppedInfo := PlaybackSession{
|
||||
UserId: user.ID,
|
||||
Username: user.UserName,
|
||||
@@ -349,7 +383,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
PlaybackRate: params.PlaybackRate,
|
||||
LastReport: now,
|
||||
}
|
||||
if info, getErr := p.playMap.Get(clientId); getErr == nil {
|
||||
if getErr == nil {
|
||||
stoppedInfo.MediaFile = info.MediaFile
|
||||
stoppedInfo.Start = info.Start
|
||||
} else {
|
||||
@@ -364,7 +398,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
stoppedInfo.MediaFile = *mf
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, stoppedInfo)
|
||||
p.playMap.Remove(clientId)
|
||||
}
|
||||
|
||||
if conf.Server.EnableNowPlaying {
|
||||
|
||||
@@ -3,6 +3,7 @@ package scrobbler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -45,6 +46,17 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// slowMediaFileRepo widens the window between a report's session check and its
|
||||
// write, making check-then-write races reproducible.
|
||||
type slowMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (s *slowMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
return s.MediaFileRepository.GetWithParticipants(id)
|
||||
}
|
||||
|
||||
var _ = Describe("PlayTracker", func() {
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
@@ -290,7 +302,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
|
||||
Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
|
||||
Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
|
||||
Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts))
|
||||
Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix()))
|
||||
})
|
||||
|
||||
It("does not record scrobble when history is disabled", func() {
|
||||
@@ -376,18 +388,23 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(playing).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("starting replaces existing entry for same player", func() {
|
||||
It("starting replaces existing entry when switching tracks on same player", func() {
|
||||
track2 := track
|
||||
track2.ID = "456"
|
||||
_ = ds.MediaFile(ctx).Put(&track2)
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
MediaId: "456", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(HaveLen(1))
|
||||
Expect(playing[0].MediaFile.ID).To(Equal("456"))
|
||||
Expect(playing[0].State).To(Equal("starting"))
|
||||
Expect(playing[0].PositionMs).To(Equal(int64(0)))
|
||||
})
|
||||
@@ -696,6 +713,119 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resilience (out-of-order reports)", func() {
|
||||
BeforeEach(func() {
|
||||
track2 := track
|
||||
track2.ID = "456"
|
||||
_ = ds.MediaFile(ctx).Put(&track2)
|
||||
})
|
||||
|
||||
It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 1000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(HaveLen(1))
|
||||
Expect(playing[0].State).To(Equal("playing"))
|
||||
Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(1000)))
|
||||
})
|
||||
|
||||
It("keeps the current session when a stopped report arrives for a different track", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(HaveLen(1))
|
||||
Expect(playing[0].MediaFile.ID).To(Equal("456"))
|
||||
Expect(playing[0].State).To(Equal("playing"))
|
||||
})
|
||||
|
||||
It("still auto-scrobbles the stopped track when the current session is for a different track", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(track.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("does not dispatch NowPlaying from an ignored out-of-order starting report", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 60000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
fake.nowPlayingCalled.Store(false)
|
||||
|
||||
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("never lets a concurrent starting report downgrade the playing session", func() {
|
||||
ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile(ctx)}
|
||||
for i := range 20 {
|
||||
raceClientId := fmt.Sprintf("race-client-%d", i)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer GinkgoRecover()
|
||||
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: raceClientId,
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer GinkgoRecover()
|
||||
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: raceClientId,
|
||||
})
|
||||
}()
|
||||
wg.Wait()
|
||||
info, err := tracker.playMap.Get(raceClientId)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.State).To(Equal("playing"), "iteration %d", i)
|
||||
}
|
||||
})
|
||||
|
||||
It("does NOT forward a stopped report for a different track to playback reporters", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
fake.PlaybackReportCalled.Store(false)
|
||||
fake.LastPlaybackReport.Store(nil)
|
||||
|
||||
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("external scrobbler dispatch", func() {
|
||||
It("dispatches NowPlaying on starting", func() {
|
||||
fake.nowPlayingCalled.Store(false)
|
||||
|
||||
@@ -46,6 +46,15 @@ func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher
|
||||
}
|
||||
}
|
||||
|
||||
// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it.
|
||||
type Engine interface {
|
||||
HasProvider() bool
|
||||
GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error)
|
||||
FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error)
|
||||
}
|
||||
|
||||
var _ Engine = (*Sonic)(nil)
|
||||
|
||||
func (s *Sonic) HasProvider() bool {
|
||||
return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0
|
||||
}
|
||||
|
||||
@@ -43,14 +43,17 @@ func normalizeSourceSampleRate(sampleRate int, codec string) int {
|
||||
return sampleRate
|
||||
}
|
||||
|
||||
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
|
||||
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
|
||||
// what ffmpeg produces). For other codecs, returns the depth unchanged.
|
||||
func normalizeSourceBitDepth(bitDepth int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
|
||||
// targetBitDepth returns the bit depth for a transcoded stream: 0 for lossy
|
||||
// targets (they have no PCM bit depth), otherwise the source depth, with DSD
|
||||
// adjusted to the 24-bit PCM that ffmpeg produces.
|
||||
func targetBitDepth(srcBitDepth int, srcCodec string, targetIsLossless bool) int {
|
||||
if !targetIsLossless {
|
||||
return 0
|
||||
}
|
||||
if strings.EqualFold(srcCodec, "dsd") && srcBitDepth == 1 {
|
||||
return 24
|
||||
}
|
||||
return bitDepth
|
||||
return srcBitDepth
|
||||
}
|
||||
|
||||
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
|
||||
|
||||
@@ -269,7 +269,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai
|
||||
Codec: strings.ToLower(profile.AudioCodec),
|
||||
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
|
||||
Channels: src.Channels,
|
||||
BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
|
||||
BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless),
|
||||
IsLossless: targetIsLossless,
|
||||
}
|
||||
if ts.Codec == "" {
|
||||
|
||||
@@ -656,6 +656,44 @@ var _ = Describe("Decider", func() {
|
||||
Expect(decision.TargetBitDepth).To(Equal(24))
|
||||
})
|
||||
|
||||
It("omits bit depth when transcoding to a lossy format", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
|
||||
Expect(decision.TargetBitDepth).To(BeZero())
|
||||
})
|
||||
|
||||
It("ignores audioBitdepth limitation when transcoding to a lossy format", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "opus",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
|
||||
})
|
||||
|
||||
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
@@ -695,9 +733,9 @@ var _ = Describe("Decider", func() {
|
||||
// DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000
|
||||
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
|
||||
Expect(decision.TargetSampleRate).To(Equal(48000))
|
||||
// DSD 1-bit → 24-bit PCM
|
||||
Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
|
||||
Expect(decision.TargetBitDepth).To(Equal(24))
|
||||
// MP3 is lossy: no bit depth on the transcoded stream
|
||||
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
|
||||
Expect(decision.TargetBitDepth).To(BeZero())
|
||||
})
|
||||
|
||||
It("converts DSD sample rate for FLAC target without codec limit", func() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -43,16 +43,10 @@ func Db() *sql.DB {
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
db, err := sql.Open(Driver, Path)
|
||||
db.SetMaxOpenConns(max(4, runtime.NumCPU()))
|
||||
db.SetMaxOpenConns(conf.MaxOpenConns())
|
||||
if err != nil {
|
||||
log.Fatal("Error opening database", err)
|
||||
}
|
||||
if conf.Server.DevOptimizeDB {
|
||||
_, err = db.Exec("PRAGMA optimize=0x10002")
|
||||
if err != nil {
|
||||
log.Error("Error applying PRAGMA optimize", err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
})
|
||||
}
|
||||
@@ -61,9 +55,6 @@ func Close(ctx context.Context) {
|
||||
// Ignore cancellations when closing the DB
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
|
||||
// Run optimize before closing
|
||||
Optimize(ctx)
|
||||
|
||||
log.Info(ctx, "Closing Database")
|
||||
err := Db().Close()
|
||||
if err != nil {
|
||||
@@ -102,11 +93,11 @@ func Init(ctx context.Context) func() {
|
||||
log.Fatal(ctx, "Failed to apply new migrations", err)
|
||||
}
|
||||
|
||||
if hasSchemaChanges && conf.Server.DevOptimizeDB {
|
||||
log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
|
||||
_, err = db.ExecContext(ctx, "PRAGMA optimize")
|
||||
if hasSchemaChanges {
|
||||
log.Debug(ctx, "Running ANALYZE after schema changes")
|
||||
err = optimizeAt(ctx, db, time.Now())
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error applying PRAGMA optimize", err)
|
||||
log.Error(ctx, "Error running ANALYZE", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,37 +106,6 @@ func Init(ctx context.Context) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// Optimize runs PRAGMA optimize on each connection in the pool
|
||||
func Optimize(ctx context.Context) {
|
||||
if !conf.Server.DevOptimizeDB {
|
||||
return
|
||||
}
|
||||
numConns := Db().Stats().OpenConnections
|
||||
if numConns == 0 {
|
||||
log.Debug(ctx, "No open connections to optimize")
|
||||
return
|
||||
}
|
||||
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
|
||||
var conns []*sql.Conn
|
||||
for range numConns {
|
||||
conn, err := Db().Conn(ctx)
|
||||
conns = append(conns, conn)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting connection from pool", err)
|
||||
continue
|
||||
}
|
||||
_, err = conn.ExecContext(ctx, "PRAGMA optimize;")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running PRAGMA optimize", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return all connections to the Connection Pool
|
||||
for _, conn := range conns {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type statusLogger struct{ numPending int }
|
||||
|
||||
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||
|
||||
+5
-2
@@ -2,6 +2,9 @@ package db
|
||||
|
||||
// Definitions for testing private methods
|
||||
var (
|
||||
IsSchemaEmpty = isSchemaEmpty
|
||||
BackupPath = backupPath
|
||||
IsSchemaEmpty = isSchemaEmpty
|
||||
BackupPath = backupPath
|
||||
OptimizeDBAt = optimizeAt
|
||||
OptimizeDBIfNeeded = optimizeIfNeeded
|
||||
RecordAnalyzeFailure = recordAnalyzeFailure
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE scrobbles_tmp(
|
||||
id INTEGER PRIMARY KEY,
|
||||
media_file_id VARCHAR(255) NOT NULL
|
||||
REFERENCES media_file(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL
|
||||
REFERENCES user(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
submission_time INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO scrobbles_tmp SELECT ROWID, media_file_id, user_id, submission_time FROM scrobbles;
|
||||
|
||||
DROP INDEX scrobbles_date;
|
||||
DROP TABLE scrobbles;
|
||||
ALTER TABLE scrobbles_tmp RENAME TO scrobbles;
|
||||
CREATE INDEX scrobbles_user_time ON scrobbles(user_id, submission_time);
|
||||
|
||||
|
||||
-- +goose Down
|
||||
CREATE TABLE scrobbles_tmp(
|
||||
media_file_id VARCHAR(255) NOT NULL
|
||||
REFERENCES media_file(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL
|
||||
REFERENCES user(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
submission_time INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO scrobbles_tmp SELECT media_file_id, user_id, submission_time FROM scrobbles;
|
||||
|
||||
DROP INDEX scrobbles_user_time;
|
||||
DROP TABLE scrobbles;
|
||||
ALTER TABLE scrobbles_tmp RENAME TO scrobbles;
|
||||
CREATE INDEX scrobbles_date ON scrobbles(submission_time);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE playlist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE playlist DROP COLUMN average_rating;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Covering index for the title-sorted, library-scoped song listing:
|
||||
-- WHERE missing = ? AND library_id = ? ORDER BY order_title LIMIT n OFFSET m
|
||||
-- (Jellyfin clients page through the whole library this way; non-admin native and
|
||||
-- Subsonic song lists produce the same shape.)
|
||||
--
|
||||
-- Without it, SQLite walks media_file_order_title and must fetch the table row for
|
||||
-- every *skipped* entry just to evaluate the WHERE, so a deep page costs offset+limit
|
||||
-- random row reads (seconds on cold spinning disks). With the filter columns in the
|
||||
-- index the skip is index-only. `id` is included because the annotation/bookmark
|
||||
-- LEFT JOINs run per candidate row and need the join key; without it each skipped
|
||||
-- entry still triggers a row fetch.
|
||||
create index if not exists media_file_missing_library_order_title
|
||||
on media_file(missing, library_id, order_title, id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
drop index if exists media_file_missing_library_order_title;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,41 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain)
|
||||
}
|
||||
|
||||
func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error {
|
||||
// Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows
|
||||
// into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album.
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
ALTER TABLE album ADD COLUMN rg_album_gain real;
|
||||
ALTER TABLE album ADD COLUMN rg_album_peak real;
|
||||
|
||||
CREATE TEMP TABLE _rg_backfill AS
|
||||
SELECT album_id, rg_album_gain, rg_album_peak FROM media_file
|
||||
WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL;
|
||||
CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id);
|
||||
|
||||
UPDATE album SET
|
||||
rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL
|
||||
GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1),
|
||||
rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL
|
||||
GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1)
|
||||
WHERE album.id IN (SELECT album_id FROM _rg_backfill);
|
||||
|
||||
DROP TABLE _rg_backfill;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
)
|
||||
|
||||
@@ -21,13 +20,6 @@ func notice(ctx context.Context, tx *sql.Tx, msg string) {
|
||||
|
||||
// Call this in migrations that requires a full rescan
|
||||
func forceFullRescan(ctx context.Context, tx *sql.Tx) error {
|
||||
// If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`.
|
||||
if conf.Server.DevOptimizeDB {
|
||||
_, err := tx.ExecContext(ctx, `ANALYZE;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, fmt.Sprintf(`
|
||||
INSERT OR REPLACE into property (id, value) values ('%s', '1');
|
||||
`, consts.FullScanAfterMigrationFlagKey))
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
var analyzeMux sync.Mutex
|
||||
|
||||
// Optimize refreshes the query-planner statistics with a full ANALYZE. PRAGMA optimize is avoided
|
||||
// because its limited analysis misestimates Navidrome's low-cardinality indexes.
|
||||
func Optimize(ctx context.Context) error {
|
||||
analyzeMux.Lock()
|
||||
defer analyzeMux.Unlock()
|
||||
start := time.Now()
|
||||
if err := optimizeAt(ctx, Db(), start); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
// OptimizeIfNeeded refreshes statistics when they are stale or a database-changing operation
|
||||
// marked them for refresh.
|
||||
func OptimizeIfNeeded(ctx context.Context) (bool, error) {
|
||||
analyzeMux.Lock()
|
||||
defer analyzeMux.Unlock()
|
||||
start := time.Now()
|
||||
ran, err := optimizeIfNeeded(ctx, Db(), start)
|
||||
if err != nil || !ran {
|
||||
return ran, err
|
||||
}
|
||||
log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func optimizeIfNeeded(ctx context.Context, db *sql.DB, now time.Time) (bool, error) {
|
||||
due, err := optimizeDue(ctx, db, now)
|
||||
if err != nil || !due {
|
||||
return false, err
|
||||
}
|
||||
return true, optimizeAt(ctx, db, now)
|
||||
}
|
||||
|
||||
func optimizeDue(ctx context.Context, db *sql.DB, now time.Time) (bool, error) {
|
||||
backingOff, err := analyzeRetryBackoffActive(ctx, db, now)
|
||||
if err != nil || backingOff {
|
||||
return false, err
|
||||
}
|
||||
|
||||
pending, found, err := getProperty(ctx, db, consts.DBAnalyzePendingKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if found && pending == "1" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
value, found, err := getProperty(ctx, db, consts.LastDBAnalyzeAtKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
lastAnalyze, valid := parseAnalyzeTime(value)
|
||||
if !valid || lastAnalyze.After(now) {
|
||||
return true, nil
|
||||
}
|
||||
return now.Sub(lastAnalyze) >= consts.DBAnalyzeMaxAge, nil
|
||||
}
|
||||
|
||||
func parseAnalyzeTime(value string) (time.Time, bool) {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func analyzeRetryBackoffActive(ctx context.Context, db *sql.DB, now time.Time) (bool, error) {
|
||||
value, found, err := getProperty(ctx, db, consts.DBAnalyzeFailureCountKey)
|
||||
if err != nil || !found {
|
||||
return false, err
|
||||
}
|
||||
failures, _ := strconv.Atoi(value)
|
||||
if failures < 1 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
value, found, err = getProperty(ctx, db, consts.LastDBAnalyzeAttemptAtKey)
|
||||
if err != nil || !found {
|
||||
return false, err
|
||||
}
|
||||
lastAttempt, valid := parseAnalyzeTime(value)
|
||||
if !valid || lastAttempt.After(now) {
|
||||
return false, nil
|
||||
}
|
||||
return now.Sub(lastAttempt) < analyzeRetryDelay(failures), nil
|
||||
}
|
||||
|
||||
func analyzeRetryDelay(failures int) time.Duration {
|
||||
switch failures {
|
||||
case 1:
|
||||
return 30 * time.Minute
|
||||
case 2:
|
||||
return time.Hour
|
||||
case 3:
|
||||
return 2 * time.Hour
|
||||
default:
|
||||
return 24 * time.Hour
|
||||
}
|
||||
}
|
||||
|
||||
// MarkOptimizePending requests a statistics refresh on the next scheduled maintenance check.
|
||||
func MarkOptimizePending(ctx context.Context) error {
|
||||
analyzeMux.Lock()
|
||||
defer analyzeMux.Unlock()
|
||||
return markOptimizePending(ctx, Db())
|
||||
}
|
||||
|
||||
func markOptimizePending(ctx context.Context, db *sql.DB) error {
|
||||
return putProperty(ctx, db, consts.DBAnalyzePendingKey, "1")
|
||||
}
|
||||
|
||||
func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error {
|
||||
if err := markOptimizePending(ctx, db); err != nil {
|
||||
return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err))
|
||||
}
|
||||
log.Debug(ctx, "Refreshing query planner statistics")
|
||||
_, err := db.ExecContext(ctx, "ANALYZE")
|
||||
if err != nil {
|
||||
return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err))
|
||||
}
|
||||
if err = recordAnalyzeSuccess(ctx, db, now); err != nil {
|
||||
return recordAnalyzeError(ctx, db, now, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording ANALYZE time: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err = putProperty(ctx, tx, consts.LastDBAnalyzeAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("recording ANALYZE time: %w", err)
|
||||
}
|
||||
if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "0"); err != nil {
|
||||
return fmt.Errorf("clearing pending ANALYZE: %w", err)
|
||||
}
|
||||
if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, "0"); err != nil {
|
||||
return fmt.Errorf("clearing ANALYZE failure count: %w", err)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return fmt.Errorf("recording ANALYZE state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordAnalyzeError(ctx context.Context, db *sql.DB, now time.Time, analyzeErr error) error {
|
||||
if err := recordAnalyzeFailure(ctx, db, now); err != nil {
|
||||
return errors.Join(analyzeErr, fmt.Errorf("recording ANALYZE failure: %w", err))
|
||||
}
|
||||
return analyzeErr
|
||||
}
|
||||
|
||||
func recordAnalyzeFailure(ctx context.Context, db *sql.DB, now time.Time) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
value, found, err := getProperty(ctx, tx, consts.DBAnalyzeFailureCountKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
failures := 0
|
||||
if found {
|
||||
failures, _ = strconv.Atoi(value)
|
||||
failures = max(failures, 0)
|
||||
}
|
||||
if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "1"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, strconv.Itoa(failures+1)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = putProperty(ctx, tx, consts.LastDBAnalyzeAttemptAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type sqlExecer interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
type sqlQueryer interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func putProperty(ctx context.Context, db sqlExecer, key, value string) error {
|
||||
_, err := db.ExecContext(ctx, `insert into property(id, value) values(?, ?)
|
||||
on conflict(id) do update set value=excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func getProperty(ctx context.Context, db sqlQueryer, key string) (string, bool, error) {
|
||||
var value string
|
||||
err := db.QueryRowContext(ctx, "select value from property where id=?", key).Scan(&value)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return value, err == nil, err
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Optimize", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
database *sql.DB
|
||||
now time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
now = time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC)
|
||||
var err error
|
||||
database, err = sql.Open(db.Dialect, "file::memory:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(database.Close)
|
||||
|
||||
_, err = database.Exec(`create table property(
|
||||
id varchar(255) primary key,
|
||||
value varchar(255) not null default ''
|
||||
)`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = database.Exec("create table analyze_probe(id integer primary key, flag int)")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = database.Exec(`insert into analyze_probe(flag)
|
||||
with recursive s(x) as (select 1 union all select x+1 from s where x < 3000)
|
||||
select 0 from s`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = database.Exec("create index probe_flag on analyze_probe(flag)")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = database.Exec("analyze")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
putProperty := func(key, value string) {
|
||||
_, err := database.Exec(`insert into property(id, value) values(?, ?)
|
||||
on conflict(id) do update set value=excluded.value`, key, value)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
getProperty := func(key string) string {
|
||||
var value string
|
||||
Expect(database.QueryRow("select value from property where id=?", key).Scan(&value)).To(Succeed())
|
||||
return value
|
||||
}
|
||||
|
||||
poisonStats := func() {
|
||||
_, err := database.Exec("update sqlite_stat1 set stat='3000 50' where idx='probe_flag'")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
It("replaces poisoned planner statistics with full-quality ones", func() {
|
||||
poisonStats()
|
||||
putProperty(consts.DBAnalyzePendingKey, "1")
|
||||
|
||||
Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed())
|
||||
|
||||
var stat string
|
||||
err := database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// A full ANALYZE sees all 3000 rows share one value: avg rows per key = row count.
|
||||
Expect(stat).To(Equal("3000 3000"))
|
||||
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
|
||||
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||||
})
|
||||
|
||||
It("runs when no previous analysis was recorded", func() {
|
||||
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ran).To(BeTrue())
|
||||
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
|
||||
})
|
||||
|
||||
It("skips a recent analysis when no refresh is pending", func() {
|
||||
lastAnalyze := now.Add(-23 * time.Hour)
|
||||
putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze.Format(time.RFC3339Nano))
|
||||
putProperty(consts.DBAnalyzePendingKey, "0")
|
||||
poisonStats()
|
||||
|
||||
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ran).To(BeFalse())
|
||||
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze.Format(time.RFC3339Nano)))
|
||||
|
||||
var stat string
|
||||
Expect(database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)).To(Succeed())
|
||||
Expect(stat).To(Equal("3000 50"))
|
||||
})
|
||||
|
||||
It("runs when the previous analysis is stale", func() {
|
||||
putProperty(consts.LastDBAnalyzeAtKey, now.Add(-consts.DBAnalyzeMaxAge).Format(time.RFC3339Nano))
|
||||
putProperty(consts.DBAnalyzePendingKey, "0")
|
||||
|
||||
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ran).To(BeTrue())
|
||||
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
|
||||
})
|
||||
|
||||
It("runs when a refresh is pending even if the previous analysis is recent", func() {
|
||||
putProperty(consts.LastDBAnalyzeAtKey, now.Format(time.RFC3339Nano))
|
||||
putProperty(consts.DBAnalyzePendingKey, "1")
|
||||
|
||||
ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(time.Hour))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ran).To(BeTrue())
|
||||
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||||
})
|
||||
|
||||
DescribeTable("backs off after consecutive analysis failures",
|
||||
func(failures string, retryDelay time.Duration) {
|
||||
putProperty(consts.DBAnalyzePendingKey, "1")
|
||||
putProperty(consts.DBAnalyzeFailureCountKey, failures)
|
||||
putProperty(consts.LastDBAnalyzeAttemptAtKey, now.Format(time.RFC3339Nano))
|
||||
|
||||
ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay-time.Nanosecond))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ran).To(BeFalse())
|
||||
|
||||
ran, err = db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ran).To(BeTrue())
|
||||
Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("0"))
|
||||
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||||
},
|
||||
Entry("for 30 minutes after the first failure", "1", 30*time.Minute),
|
||||
Entry("for one hour after the second failure", "2", time.Hour),
|
||||
Entry("for two hours after the third failure", "3", 2*time.Hour),
|
||||
Entry("for 24 hours after the fourth failure", "4", 24*time.Hour),
|
||||
)
|
||||
|
||||
It("records consecutive analysis failures", func() {
|
||||
putProperty(consts.DBAnalyzeFailureCountKey, "2")
|
||||
|
||||
Expect(db.RecordAnalyzeFailure(ctx, database, now)).To(Succeed())
|
||||
|
||||
Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("3"))
|
||||
Expect(getProperty(consts.LastDBAnalyzeAttemptAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
|
||||
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("1"))
|
||||
})
|
||||
|
||||
It("does not record success when analysis fails", func() {
|
||||
lastAnalyze := now.Add(-48 * time.Hour).Format(time.RFC3339Nano)
|
||||
putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze)
|
||||
canceledCtx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
Expect(db.OptimizeDBAt(canceledCtx, database, now)).To(MatchError(ContainSubstring("context canceled")))
|
||||
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze))
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ module github.com/navidrome/navidrome
|
||||
go 1.26
|
||||
|
||||
// Fork to implement raw tags support
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
@@ -36,7 +36,7 @@ require (
|
||||
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.47
|
||||
github.com/mattn/go-sqlite3 v1.14.48
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.32.0
|
||||
@@ -59,12 +59,12 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.39.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/net v0.57.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/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -75,7 +75,7 @@ require (
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/reflex v0.3.1 // indirect
|
||||
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
|
||||
@@ -89,7 +89,7 @@ require (
|
||||
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-20260604005048-7023385849c0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // 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
|
||||
@@ -133,10 +133,10 @@ require (
|
||||
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.53.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect
|
||||
golang.org/x/tools v0.47.0 // 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
|
||||
gopkg.in/ini.v1 v1.67.3 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
|
||||
@@ -16,13 +16,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
||||
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
|
||||
github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ=
|
||||
github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -32,8 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
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-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs=
|
||||
github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
|
||||
github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU=
|
||||
github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
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=
|
||||
@@ -62,7 +61,6 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
|
||||
@@ -105,8 +103,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-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-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
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/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=
|
||||
@@ -143,11 +141,8 @@ github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl
|
||||
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=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -174,8 +169,8 @@ 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.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
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/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=
|
||||
@@ -309,39 +304,38 @@ 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/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
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/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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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/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=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc=
|
||||
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
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/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.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
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/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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
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=
|
||||
|
||||
+4
-2
@@ -45,8 +45,10 @@ var redacted = &Hook{
|
||||
"([^\\w]p=)[^&]+",
|
||||
"([^\\w]jwt=)[^&]+",
|
||||
|
||||
// External services query params
|
||||
"([^\\w]api_key=)[\\w]+",
|
||||
// External services query params. Values can be JWTs (dots, dashes), so match everything up
|
||||
// to the next query separator or whitespace, not just word chars. A [\w]+ class would stop
|
||||
// at a JWT's first '.' and leak its payload and signature.
|
||||
"([^\\w]api_key=)[^&\\s]+",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -259,5 +259,10 @@ var _ = Describe("Logger", func() {
|
||||
msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title"
|
||||
Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title"))
|
||||
})
|
||||
|
||||
It("redacts a whole JWT in api_key, not just up to its first dot", func() {
|
||||
msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1"
|
||||
Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,8 @@ type Album struct {
|
||||
MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
|
||||
FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album
|
||||
ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
|
||||
RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"`
|
||||
RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
|
||||
|
||||
// External metadata fields
|
||||
Description string `structs:"description" json:"description,omitempty" hash:"ignore"`
|
||||
@@ -141,6 +143,8 @@ type AlbumRepository interface {
|
||||
UpdateExternalInfo(*Album) error
|
||||
Get(id string) (*Album, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
GetCursor(...QueryOptions) (AlbumCursor, error)
|
||||
GetYears(libraryIDs ...int) ([]int, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
Touch(ids ...string) error
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
@@ -79,6 +80,8 @@ type ArtistIndex struct {
|
||||
}
|
||||
type ArtistIndexes []ArtistIndex
|
||||
|
||||
type ArtistCursor iter.Seq2[Artist, error]
|
||||
|
||||
type ArtistRepository interface {
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
@@ -86,6 +89,7 @@ type ArtistRepository interface {
|
||||
UpdateExternalInfo(a *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetAll(options ...QueryOptions) (Artists, error)
|
||||
GetCursor(options ...QueryOptions) (ArtistCursor, error)
|
||||
GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
|
||||
@@ -4,9 +4,12 @@ package criteria
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type Expression interface {
|
||||
@@ -20,6 +23,7 @@ type Criteria struct {
|
||||
Limit int
|
||||
LimitPercent int
|
||||
Offset int
|
||||
RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay
|
||||
}
|
||||
|
||||
// EffectiveLimit resolves the effective limit for a query. If a fixed Limit is
|
||||
@@ -83,6 +87,7 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
|
||||
Limit int `json:"limit,omitempty"`
|
||||
LimitPercent int `json:"limitPercent,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
RefreshDelay string `json:"refreshDelay,omitempty"`
|
||||
}{
|
||||
Sort: c.Sort,
|
||||
Order: c.Order,
|
||||
@@ -90,6 +95,9 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
|
||||
LimitPercent: c.LimitPercent,
|
||||
Offset: c.Offset,
|
||||
}
|
||||
if c.RefreshDelay > 0 {
|
||||
aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay)
|
||||
}
|
||||
switch rules := c.Expression.(type) {
|
||||
case Any:
|
||||
aux.Any = rules
|
||||
@@ -110,6 +118,7 @@ func (c *Criteria) UnmarshalJSON(data []byte) error {
|
||||
Limit int `json:"limit"`
|
||||
LimitPercent int `json:"limitPercent"`
|
||||
Offset int `json:"offset"`
|
||||
RefreshDelay string `json:"refreshDelay"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
@@ -131,6 +140,14 @@ func (c *Criteria) UnmarshalJSON(data []byte) error {
|
||||
c.Limit = aux.Limit
|
||||
c.Offset = aux.Offset
|
||||
|
||||
if aux.RefreshDelay != "" {
|
||||
d, err := utils.ParseDuration(aux.RefreshDelay)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid refreshDelay: %w", err)
|
||||
}
|
||||
c.RefreshDelay = d
|
||||
}
|
||||
|
||||
// Clamp LimitPercent to [0, 100]
|
||||
if aux.LimitPercent < 0 {
|
||||
log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent)
|
||||
|
||||
@@ -3,6 +3,7 @@ package criteria
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -255,6 +256,71 @@ var _ = Describe("Criteria", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("refreshDelay", func() {
|
||||
newCriteria := func(extra string) []byte {
|
||||
return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`)
|
||||
}
|
||||
|
||||
It("unmarshals a valid refreshDelay", func() {
|
||||
var c Criteria
|
||||
gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed())
|
||||
gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour))
|
||||
})
|
||||
|
||||
It("supports week units", func() {
|
||||
var c Criteria
|
||||
gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed())
|
||||
gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour))
|
||||
})
|
||||
|
||||
It("leaves RefreshDelay zero when absent", func() {
|
||||
var c Criteria
|
||||
gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed())
|
||||
gomega.Expect(c.RefreshDelay).To(gomega.BeZero())
|
||||
})
|
||||
|
||||
It("rejects an invalid refreshDelay", func() {
|
||||
var c Criteria
|
||||
err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c)
|
||||
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))
|
||||
})
|
||||
|
||||
It("rejects a negative refreshDelay", func() {
|
||||
var c Criteria
|
||||
err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c)
|
||||
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))
|
||||
})
|
||||
|
||||
It("marshals RefreshDelay back as a duration string", func() {
|
||||
c := Criteria{
|
||||
Expression: All{Is{"loved": true}},
|
||||
RefreshDelay: 24 * time.Hour,
|
||||
}
|
||||
j, err := json.Marshal(c)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`))
|
||||
})
|
||||
|
||||
It("omits refreshDelay from JSON when zero", func() {
|
||||
c := Criteria{Expression: All{Is{"loved": true}}}
|
||||
j, err := json.Marshal(c)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay"))
|
||||
})
|
||||
|
||||
It("round-trips through marshal and unmarshal", func() {
|
||||
c := Criteria{
|
||||
Expression: All{Is{"loved": true}},
|
||||
RefreshDelay: 36 * time.Hour,
|
||||
}
|
||||
j, err := json.Marshal(c)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
var c2 Criteria
|
||||
gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed())
|
||||
gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with child playlists", func() {
|
||||
var (
|
||||
topLevelInPlaylistID string
|
||||
|
||||
+16
-19
@@ -2,29 +2,26 @@ package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// TODO: Should the type be encoded in the ID?
|
||||
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return ar, nil
|
||||
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) },
|
||||
}
|
||||
al, err := ds.Album(ctx).Get(id)
|
||||
if err == nil {
|
||||
return al, nil
|
||||
for _, get := range getters {
|
||||
entity, err := get()
|
||||
if err == nil {
|
||||
return entity, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
pls, err := ds.Playlist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return pls, nil
|
||||
}
|
||||
mf, err := ds.MediaFile(ctx).Get(id)
|
||||
if err == nil {
|
||||
return mf, nil
|
||||
}
|
||||
r, err := ds.Radio(ctx).Get(id)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
return nil, err
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GetEntityByID", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
It("returns the entity matching the id", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
|
||||
entity, err := model.GetEntityByID(ctx, ds, "a1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity).To(BeAssignableToTypeOf(&model.Album{}))
|
||||
Expect(entity.(*model.Album).ID).To(Equal("a1"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when no entity matches", func() {
|
||||
_, err := model.GetEntityByID(ctx, ds, "missing")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("propagates unexpected repository errors instead of reporting not-found", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true)
|
||||
_, err := model.GetEntityByID(ctx, ds, "a1")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).ToNot(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
@@ -147,6 +147,12 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) {
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan;
|
||||
// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless.
|
||||
func (mf MediaFile) HasEmbeddedLyrics() bool {
|
||||
return mf.Lyrics != "" && mf.Lyrics != "[]"
|
||||
}
|
||||
|
||||
// String is mainly used for debugging
|
||||
func (mf MediaFile) String() string {
|
||||
return mf.Path
|
||||
@@ -308,6 +314,8 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
originalYears := make([]int, 0, len(mfs))
|
||||
originalDates := make([]string, 0, len(mfs))
|
||||
releaseDates := make([]string, 0, len(mfs))
|
||||
rgAlbumGains := make([]*float64, 0, len(mfs))
|
||||
rgAlbumPeaks := make([]*float64, 0, len(mfs))
|
||||
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
|
||||
|
||||
a.Missing = true
|
||||
@@ -338,6 +346,8 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
originalYears = append(originalYears, m.OriginalYear)
|
||||
originalDates = append(originalDates, m.OriginalDate)
|
||||
releaseDates = append(releaseDates, m.ReleaseDate)
|
||||
rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain)
|
||||
rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak)
|
||||
comments = append(comments, m.Comment)
|
||||
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
|
||||
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
|
||||
@@ -372,6 +382,8 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
a.Comment, _ = allOrNothing(comments)
|
||||
a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds)
|
||||
a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds)
|
||||
a.RGAlbumGain = mostFrequentPtr(rgAlbumGains)
|
||||
a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks)
|
||||
fixAlbumArtist(&a)
|
||||
|
||||
return a
|
||||
@@ -401,6 +413,32 @@ func minMax(items []int) (int, int) {
|
||||
return mn, mx
|
||||
}
|
||||
|
||||
// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if
|
||||
// none. It counts by dereferenced value so a genuine 0.0 is a real candidate
|
||||
// (slice.MostFrequent skips the zero value and compares pointers by identity).
|
||||
func mostFrequentPtr(items []*float64) *float64 {
|
||||
var counts map[float64]int
|
||||
var best float64
|
||||
var bestCount int
|
||||
for _, it := range items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
if counts == nil {
|
||||
counts = map[float64]int{}
|
||||
}
|
||||
counts[*it]++
|
||||
if counts[*it] > bestCount {
|
||||
bestCount = counts[*it]
|
||||
best = *it
|
||||
}
|
||||
}
|
||||
if bestCount == 0 {
|
||||
return nil
|
||||
}
|
||||
return &best
|
||||
}
|
||||
|
||||
func newer(t1, t2 time.Time) time.Time {
|
||||
if t1.After(t2) {
|
||||
return t1
|
||||
|
||||
@@ -268,6 +268,35 @@ var _ = Describe("MediaFiles", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("ReplayGain", func() {
|
||||
It("picks the most frequent non-nil album gain and peak", func() {
|
||||
mfs := MediaFiles{
|
||||
{Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)},
|
||||
{Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)},
|
||||
{Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)},
|
||||
}
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.RGAlbumGain).ToNot(BeNil())
|
||||
Expect(*album.RGAlbumGain).To(Equal(-8.0))
|
||||
Expect(album.RGAlbumPeak).ToNot(BeNil())
|
||||
Expect(*album.RGAlbumPeak).To(Equal(0.9))
|
||||
})
|
||||
It("keeps a genuine 0.0 gain instead of dropping it", func() {
|
||||
mfs := MediaFiles{
|
||||
{Path: "a", RGAlbumGain: new(0.0)},
|
||||
{Path: "b", RGAlbumGain: new(0.0)},
|
||||
}
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.RGAlbumGain).ToNot(BeNil())
|
||||
Expect(*album.RGAlbumGain).To(Equal(0.0))
|
||||
})
|
||||
It("leaves gain and peak nil when no track has a value", func() {
|
||||
mfs := MediaFiles{{Path: "a"}, {Path: "b"}}
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.RGAlbumGain).To(BeNil())
|
||||
Expect(album.RGAlbumPeak).To(BeNil())
|
||||
})
|
||||
})
|
||||
Context("Participants", func() {
|
||||
var album Album
|
||||
BeforeEach(func() {
|
||||
@@ -604,6 +633,15 @@ var _ = Describe("MediaFile", func() {
|
||||
|
||||
})
|
||||
|
||||
var _ = DescribeTable("MediaFile.HasEmbeddedLyrics",
|
||||
func(lyrics string, expected bool) {
|
||||
Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected))
|
||||
},
|
||||
Entry("empty string (never-scanned zero value)", "", false),
|
||||
Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false),
|
||||
Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true),
|
||||
)
|
||||
|
||||
var _ = Describe("MediaFile.Works", func() {
|
||||
It("returns nil when there are no work tags", func() {
|
||||
mf := MediaFile{}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
type Playlist struct {
|
||||
Annotations `structs:"-"`
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
@@ -36,6 +40,15 @@ func (pls Playlist) IsSmartPlaylist() bool {
|
||||
return pls.Rules != nil && pls.Rules.Expression != nil
|
||||
}
|
||||
|
||||
// RefreshDelay returns the playlist's own refresh window when set, falling
|
||||
// back to the global SmartPlaylistRefreshDelay.
|
||||
func (pls Playlist) RefreshDelay() time.Duration {
|
||||
if pls.IsSmartPlaylist() && pls.Rules.RefreshDelay > 0 {
|
||||
return pls.Rules.RefreshDelay
|
||||
}
|
||||
return conf.Server.SmartPlaylistRefreshDelay
|
||||
}
|
||||
|
||||
func (pls Playlist) MediaFiles() MediaFiles {
|
||||
if len(pls.Tracks) == 0 {
|
||||
return nil
|
||||
@@ -119,14 +132,18 @@ func (pls Playlist) UploadedImagePath() string {
|
||||
|
||||
type Playlists []Playlist
|
||||
|
||||
type PlaylistCursor iter.Seq2[Playlist, error]
|
||||
|
||||
type PlaylistRepository interface {
|
||||
ResourceRepository
|
||||
AnnotatedRepository
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist, cols ...string) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
GetCursor(options ...QueryOptions) (PlaylistCursor, error)
|
||||
FindByPath(path string) (*Playlist, error)
|
||||
Delete(id string) error
|
||||
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
||||
@@ -150,10 +167,15 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles {
|
||||
return mfs
|
||||
}
|
||||
|
||||
type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error]
|
||||
|
||||
type PlaylistTrackRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
GetAll(options ...QueryOptions) (PlaylistTracks, error)
|
||||
GetCursor(options ...QueryOptions) (PlaylistTrackCursor, error)
|
||||
GetAlbumIDs(options ...QueryOptions) ([]string, error)
|
||||
GetMediaFileIDs(options ...QueryOptions) ([]string, error)
|
||||
Add(mediaFileIds []string) (int, error)
|
||||
AddAlbums(albumIds []string) (int, error)
|
||||
AddArtists(artistIds []string) (int, error)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -43,4 +48,29 @@ var _ = Describe("Playlist", func() {
|
||||
Expect(pls.ToM3U8()).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RefreshDelay", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SmartPlaylistRefreshDelay = 5 * time.Second
|
||||
})
|
||||
|
||||
It("returns the global config value when rules have no refreshDelay", func() {
|
||||
pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}}
|
||||
Expect(pls.RefreshDelay()).To(Equal(5 * time.Second))
|
||||
})
|
||||
|
||||
It("returns the per-playlist value when set", func() {
|
||||
pls := model.Playlist{Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Is{"loved": true}},
|
||||
RefreshDelay: 24 * time.Hour,
|
||||
}}
|
||||
Expect(pls.RefreshDelay()).To(Equal(24 * time.Hour))
|
||||
})
|
||||
|
||||
It("returns the global value for non-smart playlists", func() {
|
||||
pls := model.Playlist{}
|
||||
Expect(pls.RefreshDelay()).To(Equal(5 * time.Second))
|
||||
})
|
||||
})
|
||||
})
|
||||
+9
-3
@@ -3,11 +3,17 @@ package model
|
||||
import "time"
|
||||
|
||||
type Scrobble struct {
|
||||
MediaFileID string
|
||||
UserID string
|
||||
SubmissionTime time.Time
|
||||
ID int64 `structs:"id" json:"id"`
|
||||
MediaFileID string `structs:"media_file_id" json:"mediaFileId"`
|
||||
UserID string `json:"-"`
|
||||
SubmissionTime int64 `structs:"submission_time" json:"submissionTime"`
|
||||
}
|
||||
|
||||
type ScrobbleRepository interface {
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Get(id string) (*Scrobble, error)
|
||||
GetAll(options ...QueryOptions) (Scrobbles, error)
|
||||
RecordScrobble(mediaFileID string, submissionTime time.Time) error
|
||||
}
|
||||
|
||||
type Scrobbles []Scrobble
|
||||
@@ -153,6 +153,7 @@ func (t Tags) Add(name TagName, v string) {
|
||||
type TagRepository interface {
|
||||
Add(libraryID int, tags ...Tag) error
|
||||
UpdateCounts() error
|
||||
GetAll(name TagName, options ...QueryOptions) (TagList, error)
|
||||
}
|
||||
|
||||
type TagName string
|
||||
|
||||
@@ -3,6 +3,7 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
@@ -31,6 +32,10 @@ type dbAlbum struct {
|
||||
Participants string `structs:"-" json:"-"`
|
||||
Tags string `structs:"-" json:"-"`
|
||||
FolderIDs string `structs:"-" json:"-"`
|
||||
// dbx maps columns to fields by name; RGAlbumGain doesn't convert to
|
||||
// rg_album_gain, so shim fields carry the read and PostScan copies them over.
|
||||
RgAlbumGain *float64 `structs:"-" json:"-"`
|
||||
RgAlbumPeak *float64 `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
func (a *dbAlbum) PostScan() error {
|
||||
@@ -58,6 +63,8 @@ func (a *dbAlbum) PostScan() error {
|
||||
}
|
||||
a.Album.FolderIDs = ids
|
||||
}
|
||||
a.Album.RGAlbumGain = a.RgAlbumGain
|
||||
a.Album.RGAlbumPeak = a.RgAlbumPeak
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -247,6 +254,29 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) {
|
||||
sq := r.selectAlbum(options...)
|
||||
cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapAlbumCursor(cursor), nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) {
|
||||
cond := And{Gt{"max_year": 0}, Eq{"missing": false}}
|
||||
if len(libraryIDs) > 0 {
|
||||
cond = append(cond, Eq{"library_id": libraryIDs})
|
||||
}
|
||||
sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year"))
|
||||
years := []int{}
|
||||
err := r.queryAllSlice(sq, &years)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
|
||||
var from dbx.NullStringMap
|
||||
err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)
|
||||
@@ -319,17 +349,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
|
||||
}
|
||||
|
||||
func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor {
|
||||
return func(yield func(model.Album, error) bool) {
|
||||
for a, err := range cursor {
|
||||
if a.Album == nil {
|
||||
yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err))
|
||||
return
|
||||
}
|
||||
if !yield(*a.Album, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.AlbumCursor(wrapCursor(cursor, func(a dbAlbum) *model.Album { return a.Album }))
|
||||
}
|
||||
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based
|
||||
|
||||
@@ -3,6 +3,7 @@ package persistence
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
@@ -67,6 +68,22 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetCursor", func() {
|
||||
It("yields the same albums as GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "name"}
|
||||
want, err := albumRepo.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want)))
|
||||
})
|
||||
|
||||
It("honors Max/Offset like GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1}
|
||||
want, err := albumRepo.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
|
||||
albums, err := albumRepo.GetAll(opts...)
|
||||
@@ -835,6 +852,65 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetYears", func() {
|
||||
It("returns distinct album years ascending, excluding zero", func() {
|
||||
years, err := albumRepo.GetYears()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Sorted ascending, no duplicates, no zero-year entries.
|
||||
Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue())
|
||||
Expect(years).ToNot(ContainElement(0))
|
||||
for i := 1; i < len(years); i++ {
|
||||
Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct
|
||||
}
|
||||
})
|
||||
|
||||
It("deduplicates repeated years", func() {
|
||||
// Regression test: verify that DISTINCT is applied in the SQL.
|
||||
// Insert two albums with the same non-zero max_year (2005).
|
||||
album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005}
|
||||
album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005}
|
||||
Expect(albumRepo.Put(album1)).To(Succeed())
|
||||
Expect(albumRepo.Put(album2)).To(Succeed())
|
||||
DeferCleanup(func() {
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}}))
|
||||
})
|
||||
|
||||
years, err := albumRepo.GetYears()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Count occurrences of 2005 in the result
|
||||
count := 0
|
||||
for _, y := range years {
|
||||
if y == 2005 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it")
|
||||
})
|
||||
|
||||
It("scopes years to the given libraries", func() {
|
||||
all, err := albumRepo.GetYears()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// A library with no albums yields no years.
|
||||
scoped, err := albumRepo.GetYears(99999)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(scoped).To(BeEmpty())
|
||||
Expect(all).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("excludes years that belong only to missing albums", func() {
|
||||
gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true}
|
||||
Expect(albumRepo.Put(gone)).To(Succeed())
|
||||
DeferCleanup(func() {
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"}))
|
||||
})
|
||||
|
||||
years, err := albumRepo.GetYears()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(years).ToNot(ContainElement(1911))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("wrapAlbumCursor", func() {
|
||||
It("does not panic when the cursor yields a dbAlbum with nil Album", func() {
|
||||
// Simulate what queryWithStableResults does on the rows.Err() path:
|
||||
@@ -854,7 +930,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
}
|
||||
}).ToNot(Panic())
|
||||
Expect(gotErr).To(HaveOccurred())
|
||||
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album"))
|
||||
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album"))
|
||||
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
|
||||
})
|
||||
|
||||
@@ -874,6 +950,33 @@ var _ = Describe("AlbumRepository", func() {
|
||||
Expect(albums[0].ID).To(Equal("a1"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReplayGain", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(func() {
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}}))
|
||||
})
|
||||
})
|
||||
It("round-trips album ReplayGain gain and peak", func() {
|
||||
Expect(albumRepo.Put(&model.Album{
|
||||
ID: "rg-1", Name: "rg", LibraryID: 1,
|
||||
RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98),
|
||||
})).To(Succeed())
|
||||
got, err := albumRepo.Get("rg-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.RGAlbumGain).ToNot(BeNil())
|
||||
Expect(*got.RGAlbumGain).To(Equal(-7.5))
|
||||
Expect(got.RGAlbumPeak).ToNot(BeNil())
|
||||
Expect(*got.RGAlbumPeak).To(Equal(0.98))
|
||||
})
|
||||
It("reads nil when ReplayGain is unset", func() {
|
||||
Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed())
|
||||
got, err := albumRepo.Get("rg-2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.RGAlbumGain).To(BeNil())
|
||||
Expect(got.RGAlbumPeak).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func _p(id, name string, sortName ...string) model.Participant {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -263,6 +264,19 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists,
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) {
|
||||
sel := r.selectArtist(options...)
|
||||
cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapArtistCursor(cursor), nil
|
||||
}
|
||||
|
||||
func wrapArtistCursor(cursor iter.Seq2[dbArtist, error]) model.ArtistCursor {
|
||||
return model.ArtistCursor(wrapCursor(cursor, func(a dbArtist) *model.Artist { return a.Artist }))
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
source := a.OrderArtistName
|
||||
if conf.Server.PreferSortTags {
|
||||
|
||||
@@ -268,6 +268,22 @@ var _ = Describe("ArtistRepository", func() {
|
||||
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("GetCursor", func() {
|
||||
It("yields the same artists as GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "name"}
|
||||
want, err := repo.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want)))
|
||||
})
|
||||
|
||||
It("honors Max/Offset like GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1}
|
||||
want, err := repo.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Basic Operations", func() {
|
||||
Describe("Count", func() {
|
||||
It("returns the number of artists in the DB", func() {
|
||||
|
||||
@@ -263,17 +263,7 @@ func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) {
|
||||
}
|
||||
|
||||
func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor {
|
||||
return func(yield func(model.Folder, error) bool) {
|
||||
for f, err := range cursor {
|
||||
if f.Folder == nil {
|
||||
yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err))
|
||||
return
|
||||
}
|
||||
if !yield(*f.Folder, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder }))
|
||||
}
|
||||
|
||||
func (r folderRepository) purgeEmpty(libraryIDs ...int) error {
|
||||
|
||||
@@ -297,7 +297,7 @@ var _ = Describe("FolderRepository", func() {
|
||||
}
|
||||
}).ToNot(Panic())
|
||||
Expect(gotErr).To(HaveOccurred())
|
||||
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder"))
|
||||
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder"))
|
||||
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
|
||||
})
|
||||
|
||||
|
||||
@@ -173,15 +173,6 @@ func (r *libraryRepository) ScanEnd(id int) error {
|
||||
Set("last_scan_started_at", time.Time{}).
|
||||
Where(Eq{"id": id})
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// https://www.sqlite.org/pragma.html#pragma_optimize
|
||||
// Use mask 0x10000 to check table sizes without running ANALYZE
|
||||
// Running ANALYZE can cause query planner issues with expression-based collation indexes
|
||||
if conf.Server.DevOptimizeDB {
|
||||
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -420,17 +420,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
|
||||
}
|
||||
|
||||
func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor {
|
||||
return func(yield func(model.MediaFile, error) bool) {
|
||||
for m, err := range cursor {
|
||||
if m.MediaFile == nil {
|
||||
yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err))
|
||||
return
|
||||
}
|
||||
if !yield(*m.MediaFile, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.MediaFileCursor(wrapCursor(cursor, func(m dbMediaFile) *model.MediaFile { return m.MediaFile }))
|
||||
}
|
||||
|
||||
// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries
|
||||
|
||||
@@ -29,6 +29,22 @@ var _ = Describe("MediaRepository", func() {
|
||||
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("GetCursor", func() {
|
||||
It("yields the same media files as GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "title"}
|
||||
want, err := mr.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want)))
|
||||
})
|
||||
|
||||
It("honors Max/Offset like GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1}
|
||||
want, err := mr.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want)))
|
||||
})
|
||||
})
|
||||
|
||||
It("gets mediafile from the DB", func() {
|
||||
actual, err := mr.Get("1004")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -1012,7 +1028,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
}
|
||||
}).ToNot(Panic())
|
||||
Expect(gotErr).To(HaveOccurred())
|
||||
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile"))
|
||||
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile"))
|
||||
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
|
||||
})
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository
|
||||
return s.Tag(ctx).(model.ResourceRepository)
|
||||
case model.Plugin:
|
||||
return s.Plugin(ctx).(model.ResourceRepository)
|
||||
case model.Scrobble:
|
||||
return s.Scrobble(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
@@ -191,6 +193,7 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
|
||||
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean playlist annotations", func() error { return s.Playlist(ctx).(*playlistRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }),
|
||||
trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }),
|
||||
trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
@@ -157,6 +158,13 @@ var (
|
||||
testUsers = model.Users{adminUser, regularUser, thirdUser}
|
||||
)
|
||||
|
||||
var (
|
||||
firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
||||
secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
||||
thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
||||
scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble}
|
||||
)
|
||||
|
||||
func p(path string) string {
|
||||
return filepath.FromSlash(path)
|
||||
}
|
||||
@@ -304,8 +312,33 @@ var _ = BeforeSuite(func() {
|
||||
songComeTogether.Starred = true
|
||||
songComeTogether.StarredAt = mf.StarredAt
|
||||
testSongs[1] = songComeTogether
|
||||
|
||||
scrobbleRepo := NewScrobbleRepository(ctx, conn).(*scrobbleRepository)
|
||||
for _, s := range scrobbles {
|
||||
_, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{
|
||||
"media_file_id": s.MediaFileID,
|
||||
"user_id": s.UserID,
|
||||
"submission_time": s.SubmissionTime,
|
||||
}))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
func GetDBXBuilder() *dbx.DB {
|
||||
return dbx.NewFromDB(db.Db(), db.Dialect)
|
||||
}
|
||||
|
||||
// collectCursor takes the cursor's underlying func type so the named cursor types
|
||||
// (model.AlbumCursor, ...) infer T.
|
||||
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
||||
GinkgoHelper()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var out []T
|
||||
for item, err := range cursor {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
@@ -50,8 +51,10 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Playlist{}, map[string]filterFunc{
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
"id": idFilter("playlist"),
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"owner_name": "owner_name",
|
||||
@@ -85,8 +88,11 @@ func (r *playlistRepository) userFilter() Sqlizer {
|
||||
}
|
||||
|
||||
func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sq := Select().Where(r.userFilter())
|
||||
return r.count(sq, options...)
|
||||
query := Select().Where(r.userFilter())
|
||||
if filtersNeedAnnotation(r.applyFilters(query, options...)) {
|
||||
query = r.withAnnotation(query, "playlist.id")
|
||||
}
|
||||
return r.count(query, options...)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Exists(id string) (bool, error) {
|
||||
@@ -183,6 +189,21 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
|
||||
return playlists, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) {
|
||||
// Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists.
|
||||
sel := r.selectPlaylist(options...).Where(r.userFilter())
|
||||
cursor, err := queryWithStableResults[dbPlaylist](r.sqlRepository, sel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapPlaylistCursor(cursor), nil
|
||||
}
|
||||
|
||||
// dbPlaylist embeds a value, not a pointer, so its model is never nil.
|
||||
func wrapPlaylistCursor(cursor iter.Seq2[dbPlaylist, error]) model.PlaylistCursor {
|
||||
return model.PlaylistCursor(wrapCursor(cursor, func(p dbPlaylist) *model.Playlist { return &p.Playlist }))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) {
|
||||
sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}).
|
||||
Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id").
|
||||
@@ -203,8 +224,9 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists,
|
||||
}
|
||||
|
||||
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(options...).Join("user on user.id = owner_id").
|
||||
sel := r.newSelect(options...).Join("user on user.id = owner_id").
|
||||
Columns(r.tableName+".*", "user.user_name as owner_name")
|
||||
return r.withAnnotation(sel, r.tableName+".id")
|
||||
}
|
||||
|
||||
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
|
||||
@@ -278,10 +300,11 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) {
|
||||
sel = r.applyLibraryFilter(sel, "f")
|
||||
// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically.
|
||||
func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder {
|
||||
query = r.applyLibraryFilter(query, "f")
|
||||
userID := loggedUser(r.ctx).ID
|
||||
tracksQuery := sel.
|
||||
return query.
|
||||
Columns(
|
||||
"coalesce(starred, 0) as starred",
|
||||
"starred_at",
|
||||
@@ -301,8 +324,11 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Join("library on f.library_id = library.id").
|
||||
Where(Eq{"playlist_id": id})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.PlaylistTracks, error) {
|
||||
tracks := dbPlaylistTracks{}
|
||||
err := r.queryAll(tracksQuery, &tracks)
|
||||
err := r.queryAll(r.tracksQuery(query, id), &tracks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ = Describe("PlaylistRepository", func() {
|
||||
@@ -23,6 +28,15 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetCursor", func() {
|
||||
It("yields the same playlists as GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "name"}
|
||||
want, err := repo.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Playlist(want)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Exists", func() {
|
||||
It("returns true for an existing playlist", func() {
|
||||
Expect(repo.Exists(plsCool.ID)).To(BeTrue())
|
||||
@@ -71,6 +85,139 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Annotations", func() {
|
||||
var plsID string
|
||||
|
||||
BeforeEach(func() {
|
||||
pls := model.Playlist{Name: "Annotated", OwnerID: "userid"}
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
plsID = pls.ID
|
||||
})
|
||||
|
||||
countAnnotations := func() int {
|
||||
var count int
|
||||
Expect(GetDBXBuilder().NewQuery(
|
||||
"SELECT count(*) FROM annotation WHERE item_type = 'playlist' AND item_id = {:id}").
|
||||
Bind(dbx.Params{"id": plsID}).Row(&count)).To(Succeed())
|
||||
return count
|
||||
}
|
||||
|
||||
It("stores and reads back starred", func() {
|
||||
Expect(repo.SetStar(true, plsID)).To(Succeed())
|
||||
|
||||
p, err := repo.Get(plsID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.Starred).To(BeTrue())
|
||||
Expect(p.StarredAt).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("stores and reads back rating and average_rating", func() {
|
||||
Expect(repo.SetRating(4, plsID)).To(Succeed())
|
||||
|
||||
p, err := repo.Get(plsID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.Rating).To(Equal(4))
|
||||
Expect(p.RatedAt).ToNot(BeNil())
|
||||
Expect(p.AverageRating).To(Equal(4.0))
|
||||
})
|
||||
|
||||
It("keeps annotations isolated per user", func() {
|
||||
Expect(repo.SetStar(true, plsID)).To(Succeed())
|
||||
|
||||
otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()),
|
||||
model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true})
|
||||
otherRepo := NewPlaylistRepository(otherCtx, GetDBXBuilder())
|
||||
|
||||
p, err := otherRepo.Get(plsID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.Starred).To(BeFalse())
|
||||
})
|
||||
|
||||
It("reads starred back through GetAll", func() {
|
||||
Expect(repo.SetStar(true, plsID)).To(Succeed())
|
||||
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID })
|
||||
Expect(idx).To(BeNumerically(">=", 0))
|
||||
Expect(all[idx].Starred).To(BeTrue())
|
||||
})
|
||||
|
||||
It("counts playlists using annotation filters", func() {
|
||||
Expect(repo.SetStar(true, plsID)).To(Succeed())
|
||||
|
||||
options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}}
|
||||
starred, err := repo.GetAll(options)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(starred).To(ContainElement(HaveField("ID", plsID)))
|
||||
|
||||
count, err := repo.CountAll(options)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(len(starred))))
|
||||
})
|
||||
|
||||
It("filters starred playlists through the registered REST filter", func() {
|
||||
Expect(repo.SetStar(true, plsID)).To(Succeed())
|
||||
|
||||
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": "true"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
starred := res.(model.Playlists)
|
||||
Expect(starred).To(ContainElement(HaveField("ID", plsID)))
|
||||
for _, p := range starred {
|
||||
Expect(p.Starred).To(BeTrue())
|
||||
}
|
||||
|
||||
res, err = repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": "false"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.(model.Playlists)).ToNot(ContainElement(HaveField("ID", plsID)))
|
||||
})
|
||||
|
||||
It("reads a playlist by id through the REST id filter without ambiguity", func() {
|
||||
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"id": plsID},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.(model.Playlists)).To(ContainElement(HaveField("ID", plsID)))
|
||||
})
|
||||
|
||||
It("does not leak an annotation row of another item_type sharing the playlist id", func() {
|
||||
// Older builds (and the star fallthrough) can leave a media_file-typed row
|
||||
// under a playlist id; the item_type-scoped join must not surface or dupe it.
|
||||
_, err := GetDBXBuilder().NewQuery(
|
||||
"INSERT INTO annotation (user_id, item_id, item_type, starred) VALUES ({:uid}, {:id}, 'media_file', 1)").
|
||||
Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
p, err := repo.Get(plsID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.Starred).To(BeFalse())
|
||||
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
matches := 0
|
||||
for _, pl := range all {
|
||||
if pl.ID == plsID {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
Expect(matches).To(Equal(1))
|
||||
})
|
||||
|
||||
It("relies on the annotation sweep, not Delete, to clean up annotations", func() {
|
||||
Expect(repo.SetStar(true, plsID)).To(Succeed())
|
||||
|
||||
Expect(repo.Delete(plsID)).To(Succeed())
|
||||
Expect(countAnnotations()).To(Equal(1))
|
||||
|
||||
Expect(repo.(*playlistRepository).cleanAnnotations()).To(Succeed())
|
||||
Expect(countAnnotations()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
It("Put/Exists/Delete", func() {
|
||||
By("saves the playlist to the DB")
|
||||
newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}
|
||||
|
||||
@@ -77,6 +77,14 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
return p
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
query := Select().
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
query = r.applyLibraryFilter(query, "f")
|
||||
return r.count(query, options...)
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
query := Select().
|
||||
LeftJoin("media_file f on f.id = media_file_id").
|
||||
@@ -116,6 +124,30 @@ func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.P
|
||||
return tracks, err
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) {
|
||||
sel := r.playlistRepo.tracksQuery(r.newSelect(options...), r.playlistId)
|
||||
cursor, err := queryWithStableResults[dbPlaylistTrack](r.sqlRepository, sel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return model.PlaylistTrackCursor(wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack {
|
||||
return t.PlaylistTrack
|
||||
})), nil
|
||||
}
|
||||
|
||||
// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data.
|
||||
func (r *playlistTrackRepository) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) {
|
||||
query := r.newSelect(options...).Columns("media_file_id").
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
query = r.applyLibraryFilter(query, "f")
|
||||
var ids []string
|
||||
if err := r.queryAllSlice(query, &ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) {
|
||||
query := r.newSelect(options...).Columns("distinct mf.album_id").
|
||||
Join("media_file mf on mf.id = media_file_id").
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PlaylistTrackRepository", func() {
|
||||
var repo model.PlaylistTrackRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlaylistRepository(ctx, GetDBXBuilder()).Tracks(plsBest.ID, true)
|
||||
})
|
||||
|
||||
Describe("GetCursor", func() {
|
||||
It("yields the same tracks as GetAll", func() {
|
||||
opts := model.QueryOptions{Sort: "id"}
|
||||
want, err := repo.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(want).To(HaveLen(2))
|
||||
|
||||
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want)))
|
||||
})
|
||||
|
||||
It("honors Max and Offset", func() {
|
||||
opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1}
|
||||
want, err := repo.GetAll(opts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(want).To(HaveLen(1))
|
||||
|
||||
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CountAll", func() {
|
||||
It("returns the number of tracks in the playlist", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
})
|
||||
|
||||
It("ignores Max and Offset", func() {
|
||||
Expect(repo.CountAll(model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetMediaFileIDs", func() {
|
||||
It("returns the song ids in playlist order", func() {
|
||||
Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id"})).
|
||||
To(Equal([]string{songDayInALife.ID, songRadioactivity.ID}))
|
||||
})
|
||||
|
||||
It("honors Max and Offset", func() {
|
||||
Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Max: 1, Offset: 1})).
|
||||
To(Equal([]string{songRadioactivity.ID}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
@@ -13,11 +14,34 @@ type scrobbleRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func fromTs(_ string, value any) Sqlizer {
|
||||
return GtOrEq{"scrobbles.submission_time": value}
|
||||
}
|
||||
|
||||
func toTs(_ string, value any) Sqlizer {
|
||||
return LtOrEq{"scrobbles.submission_time": value}
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuilder {
|
||||
user := loggedUser(r.ctx)
|
||||
|
||||
return r.newSelect(options...).
|
||||
Columns("id", "media_file_id", "submission_time").
|
||||
Where(Eq{"scrobbles.user_id": user.ID})
|
||||
}
|
||||
|
||||
func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository {
|
||||
r := &scrobbleRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "scrobbles"
|
||||
r.registerModel(&model.Scrobble{}, map[string]filterFunc{
|
||||
"from": fromTs,
|
||||
"to": toTs,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"submission_time": "submission_time",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -32,3 +56,44 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t
|
||||
_, err := r.executeSQL(insert)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(r.baseQuery(), options...)
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) {
|
||||
sel := r.baseQuery().Where(Eq{"id": id})
|
||||
var res model.Scrobble
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) {
|
||||
sel := r.baseQuery(options...)
|
||||
var scrobbles model.Scrobbles
|
||||
err := r.queryAll(sel, &scrobbles)
|
||||
return scrobbles, err
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) EntityName() string {
|
||||
return "scrobble"
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) NewInstance() any {
|
||||
return &model.Scrobble{}
|
||||
}
|
||||
|
||||
var _ model.ScrobbleRepository = (*scrobbleRepository)(nil)
|
||||
var _ model.ResourceRepository = (*scrobbleRepository)(nil)
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
@@ -15,32 +16,33 @@ import (
|
||||
|
||||
var _ = Describe("ScrobbleRepository", func() {
|
||||
var repo model.ScrobbleRepository
|
||||
var rawRepo sqlRepository
|
||||
var ctx context.Context
|
||||
var fileID string
|
||||
var userID string
|
||||
|
||||
BeforeEach(func() {
|
||||
fileID = id.NewRandom()
|
||||
userID = id.NewRandom()
|
||||
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
|
||||
db := GetDBXBuilder()
|
||||
repo = NewScrobbleRepository(ctx, db)
|
||||
|
||||
rawRepo = sqlRepository{
|
||||
ctx: ctx,
|
||||
tableName: "scrobbles",
|
||||
db: db,
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
_, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
|
||||
_, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
|
||||
_, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
|
||||
})
|
||||
|
||||
Describe("RecordScrobble", func() {
|
||||
var fileID string
|
||||
var userID string
|
||||
var rawRepo sqlRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
fileID = id.NewRandom()
|
||||
userID = id.NewRandom()
|
||||
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
|
||||
db := GetDBXBuilder()
|
||||
repo = NewScrobbleRepository(ctx, db)
|
||||
|
||||
rawRepo = sqlRepository{
|
||||
ctx: ctx,
|
||||
tableName: "scrobbles",
|
||||
db: db,
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
_, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
|
||||
_, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
|
||||
_, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
|
||||
})
|
||||
|
||||
It("records a scrobble event", func() {
|
||||
submissionTime := time.Now().UTC()
|
||||
|
||||
@@ -81,4 +83,137 @@ var _ = Describe("ScrobbleRepository", func() {
|
||||
Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix()))
|
||||
})
|
||||
})
|
||||
|
||||
Context("admin user (id userid)", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
repo = NewScrobbleRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
It("Returns the number of scrobbles in the DB for admin user", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
})
|
||||
|
||||
It("returns scrobbles in a range", func() {
|
||||
Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing scrobble for the user", func() {
|
||||
scrobble, err := repo.Get("1")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(scrobble.ID).To(Equal(int64(1)))
|
||||
Expect(scrobble.MediaFileID).To(Equal("1001"))
|
||||
Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime))
|
||||
|
||||
})
|
||||
|
||||
It("does not return a scrobble that exists for another user", func() {
|
||||
_, err := repo.Get("2")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("does not return a scrobble that does not exist", func() {
|
||||
_, err := repo.Get("444")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all scrobbles in reverse order", func() {
|
||||
scrobbles, err := repo.GetAll(model.QueryOptions{
|
||||
Sort: "submission_time",
|
||||
Order: "DESC",
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(scrobbles).To(HaveLen(2))
|
||||
|
||||
Expect(scrobbles[0].ID).To(Equal(int64(3)))
|
||||
Expect(scrobbles[0].MediaFileID).To(Equal("1002"))
|
||||
Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime))
|
||||
|
||||
Expect(scrobbles[1].ID).To(Equal(int64(1)))
|
||||
Expect(scrobbles[1].MediaFileID).To(Equal("1001"))
|
||||
Expect(scrobbles[1].SubmissionTime).To(Equal(firstScrobble.SubmissionTime))
|
||||
})
|
||||
|
||||
It("returns scrobbles in a range", func() {
|
||||
scrobbles, err := repo.GetAll(model.QueryOptions{
|
||||
Filters: squirrel.GtOrEq{"submission_time": 1}})
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(scrobbles).To(HaveLen(1))
|
||||
|
||||
Expect(scrobbles[0].ID).To(Equal(int64(3)))
|
||||
Expect(scrobbles[0].MediaFileID).To(Equal("1002"))
|
||||
Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("non-admin user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(log.NewContext(context.TODO()), regularUser)
|
||||
repo = NewScrobbleRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
It("Returns the number of scrobbles in the DB for admin user", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("returns scrobbles in a range", func() {
|
||||
Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing scrobble for the user", func() {
|
||||
scrobble, err := repo.Get("2")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(scrobble.ID).To(Equal(int64(2)))
|
||||
Expect(scrobble.MediaFileID).To(Equal("1003"))
|
||||
Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
|
||||
})
|
||||
|
||||
It("does not return a scrobble that exists for another user", func() {
|
||||
_, err := repo.Get("1")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("does not return a scrobble that does not exist", func() {
|
||||
_, err := repo.Get("444")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all scrobbles in reverse order", func() {
|
||||
scrobbles, err := repo.GetAll(model.QueryOptions{
|
||||
Sort: "submission_time",
|
||||
Order: "DESC",
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(scrobbles).To(HaveLen(1))
|
||||
|
||||
Expect(scrobbles[0].ID).To(Equal(int64(2)))
|
||||
Expect(scrobbles[0].MediaFileID).To(Equal("1003"))
|
||||
Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
|
||||
})
|
||||
|
||||
It("returns scrobbles in a range", func() {
|
||||
scrobbles, err := repo.GetAll(model.QueryOptions{
|
||||
Filters: squirrel.GtOrEq{"submission_time": 1}})
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(scrobbles).To(HaveLen(1))
|
||||
|
||||
Expect(scrobbles[0].ID).To(Equal(int64(2)))
|
||||
Expect(scrobbles[0].MediaFileID).To(Equal("1003"))
|
||||
Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
@@ -77,7 +76,7 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr
|
||||
if !pls.IsSmartPlaylist() {
|
||||
return false
|
||||
}
|
||||
if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay {
|
||||
if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < pls.RefreshDelay() {
|
||||
return false
|
||||
}
|
||||
if pls.OwnerID != usr.ID {
|
||||
|
||||
@@ -147,6 +147,50 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
|
||||
})
|
||||
})
|
||||
|
||||
Context("per-playlist refreshDelay", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
It("does NOT refresh when the per-playlist delay has not elapsed, even if global has", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
evaluatedAt := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Contains{"title": "Day"}},
|
||||
RefreshDelay: 24 * time.Hour,
|
||||
}
|
||||
pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt}
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(pls.ID) })
|
||||
|
||||
got, err := repo.GetWithTracks(pls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Not re-evaluated: EvaluatedAt unchanged, no tracks materialized
|
||||
Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second))
|
||||
Expect(got.Tracks).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("refreshes when the per-playlist delay has elapsed, even if global has not", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
|
||||
evaluatedAt := time.Now().Add(-10 * time.Minute)
|
||||
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Contains{"title": "Day"}},
|
||||
RefreshDelay: 5 * time.Minute,
|
||||
}
|
||||
pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt}
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(pls.ID) })
|
||||
|
||||
got, err := repo.GetWithTracks(pls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
|
||||
Expect(got.Tracks).To(HaveLen(1))
|
||||
Expect(got.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
|
||||
query = query.
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = "+idField+
|
||||
" AND annotation.user_id = '"+userID+"')").
|
||||
" AND annotation.item_type = ?"+
|
||||
" AND annotation.user_id = ?)", r.tableName, userID).
|
||||
Columns(
|
||||
"coalesce(starred, 0) as starred",
|
||||
"coalesce(rating, 0) as rating",
|
||||
|
||||
@@ -347,6 +347,24 @@ func (r sqlRepository) queryOne(sq Sqlizer, response any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's
|
||||
// embedded model, which a type parameter can't reach on its own.
|
||||
func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] {
|
||||
return func(yield func(T, error) bool) {
|
||||
for row, err := range cursor {
|
||||
m := toModel(row)
|
||||
if m == nil {
|
||||
var zero T
|
||||
yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err))
|
||||
return
|
||||
}
|
||||
if !yield(*m, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results
|
||||
// from a cursor, guaranteeing that the results will be stable, even if the underlying data changes.
|
||||
func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) {
|
||||
|
||||
@@ -48,6 +48,7 @@ func marshalTags(tags model.Tags) string {
|
||||
return string(res)
|
||||
}
|
||||
|
||||
// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "<name>_id" key maps to "$.<name>".
|
||||
func tagIDFilter(name string, idValue any) Sqlizer {
|
||||
name = strings.TrimSuffix(name, "_id")
|
||||
return Exists(
|
||||
|
||||
@@ -74,13 +74,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count;
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) {
|
||||
sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name})
|
||||
res := model.TagList{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *tagRepository) purgeUnused() error {
|
||||
del := Delete(r.tableName).Where(`
|
||||
del := Delete(r.tableName).Where(`
|
||||
id not in (select jt.value
|
||||
from album left join json_tree(album.tags, '$') as jt
|
||||
where atom is not null
|
||||
and key = 'id'
|
||||
UNION
|
||||
UNION
|
||||
select jt.value
|
||||
from media_file left join json_tree(media_file.tags, '$') as jt
|
||||
where atom is not null
|
||||
|
||||
+1
-19
@@ -136,7 +136,7 @@ Every plugin must include a `manifest.json` file. Example:
|
||||
|
||||
**Required fields:** `name`, `author`, `version`
|
||||
|
||||
**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental`
|
||||
**Optional fields:** `description`, `website`, `config`, `permissions`
|
||||
|
||||
#### Config Definition
|
||||
|
||||
@@ -160,24 +160,6 @@ The `config` field defines the plugin's configuration schema using [JSON Schema
|
||||
}
|
||||
```
|
||||
|
||||
#### Experimental Features
|
||||
|
||||
Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
|
||||
|
||||
- **`threads`** – Enables WebAssembly threads support (for plugins compiled with multi-threading)
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"threads": {
|
||||
"reason": "Required for concurrent audio processing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary.
|
||||
|
||||
---
|
||||
|
||||
## Capabilities
|
||||
|
||||
@@ -14,6 +14,10 @@ const (
|
||||
FuncLyricsGetLyrics = "nd_lyrics_get_lyrics"
|
||||
)
|
||||
|
||||
// maxConcurrentLyricsCalls caps in-flight lyrics calls per plugin: clients prefetch
|
||||
// lyrics for whole queues, and the resulting burst can rate-limit upstream providers.
|
||||
const maxConcurrentLyricsCalls = 2
|
||||
|
||||
func init() {
|
||||
registerCapability(
|
||||
CapabilityLyrics,
|
||||
@@ -34,6 +38,12 @@ type LyricsPlugin struct {
|
||||
// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response
|
||||
// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain).
|
||||
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
select {
|
||||
case l.plugin.lyricsSem <- struct{}{}:
|
||||
defer func() { <-l.plugin.lyricsSem }()
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
req := capabilities.GetLyricsRequest{
|
||||
Track: mediaFileToTrackInfo(l.plugin, mf),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -71,6 +73,45 @@ var _ = Describe("LyricsPlugin", Ordered, func() {
|
||||
Expect(result[0].Lang).To(Equal("xxx"))
|
||||
})
|
||||
|
||||
It("blocks new calls while the per-plugin concurrency cap is saturated", func() {
|
||||
sem := provider.plugin.lyricsSem
|
||||
for range cap(sem) {
|
||||
sem <- struct{}{}
|
||||
}
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := provider.GetLyrics(ctx, track)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
Consistently(done, "500ms").ShouldNot(Receive())
|
||||
<-sem // free one slot; the pending call should now proceed
|
||||
Eventually(done).Should(Receive(BeNil()))
|
||||
for range cap(sem) - 1 {
|
||||
<-sem
|
||||
}
|
||||
})
|
||||
|
||||
It("gives up waiting for a slot when the context is cancelled", func() {
|
||||
sem := provider.plugin.lyricsSem
|
||||
for range cap(sem) {
|
||||
sem <- struct{}{}
|
||||
}
|
||||
defer func() {
|
||||
for range cap(sem) {
|
||||
<-sem
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||
cancel()
|
||||
_, err := provider.GetLyrics(ctx, &model.MediaFile{ID: "track-1"})
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
|
||||
It("returns error when plugin returns error", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-lyrics": {"error": "service unavailable"},
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/navidrome/navidrome/plugins/host"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/tetratelabs/wazero/experimental"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@@ -377,12 +375,6 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
WithCompilationCache(m.cache).
|
||||
WithCloseOnContextDone(true)
|
||||
|
||||
// Enable experimental threads if requested in manifest
|
||||
if pkg.Manifest.HasExperimentalThreads() {
|
||||
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
|
||||
log.Debug(ctx, "Enabling experimental threads support")
|
||||
}
|
||||
|
||||
extismConfig := extism.PluginConfig{
|
||||
EnableWasi: true,
|
||||
RuntimeConfig: runtimeConfig,
|
||||
@@ -421,6 +413,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
allowedUserIDs: allowedUsers,
|
||||
allUsers: p.AllUsers,
|
||||
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
|
||||
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
|
||||
}
|
||||
m.mu.Unlock()
|
||||
loaded = true
|
||||
|
||||
@@ -24,6 +24,7 @@ type plugin struct {
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
libraries libraryAccess
|
||||
lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics)
|
||||
}
|
||||
|
||||
// instance creates a new plugin instance for the given context.
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
"permissions": {
|
||||
"$ref": "#/$defs/Permissions"
|
||||
},
|
||||
"experimental": {
|
||||
"$ref": "#/$defs/Experimental"
|
||||
},
|
||||
"config": {
|
||||
"$ref": "#/$defs/ConfigDefinition"
|
||||
}
|
||||
@@ -58,27 +55,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Experimental": {
|
||||
"type": "object",
|
||||
"description": "Experimental features that may change or be removed in future versions",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"threads": {
|
||||
"$ref": "#/$defs/ThreadsFeature"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ThreadsFeature": {
|
||||
"type": "object",
|
||||
"description": "Enable experimental WebAssembly threads support",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation for why threads support is needed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Permissions": {
|
||||
"type": "object",
|
||||
"description": "Permissions required by the plugin",
|
||||
|
||||
@@ -117,11 +117,6 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasExperimentalThreads returns true if the manifest requests experimental threads support.
|
||||
func (m *Manifest) HasExperimentalThreads() bool {
|
||||
return m.Experimental != nil && m.Experimental.Threads != nil
|
||||
}
|
||||
|
||||
// HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries.
|
||||
func (m *Manifest) HasLibraryFilesystemPermission() bool {
|
||||
return m.Permissions != nil &&
|
||||
|
||||
@@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Experimental features that may change or be removed in future versions
|
||||
type Experimental struct {
|
||||
// Threads corresponds to the JSON schema field "threads".
|
||||
Threads *ThreadsFeature `json:"threads,omitempty" yaml:"threads,omitempty" mapstructure:"threads,omitempty"`
|
||||
}
|
||||
|
||||
// HTTP access permissions for a plugin
|
||||
type HTTPPermission struct {
|
||||
// Explanation for why HTTP access is needed
|
||||
@@ -109,9 +103,6 @@ type Manifest struct {
|
||||
// A brief description of what the plugin does
|
||||
Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"`
|
||||
|
||||
// Experimental corresponds to the JSON schema field "experimental".
|
||||
Experimental *Experimental `json:"experimental,omitempty" yaml:"experimental,omitempty" mapstructure:"experimental,omitempty"`
|
||||
|
||||
// The display name of the plugin
|
||||
Name string `json:"name" yaml:"name" mapstructure:"name"`
|
||||
|
||||
@@ -242,12 +233,6 @@ func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enable experimental WebAssembly threads support
|
||||
type ThreadsFeature struct {
|
||||
// Explanation for why threads support is needed
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Users service permissions for accessing user information
|
||||
type UsersPermission struct {
|
||||
// Explanation for why users access is needed
|
||||
|
||||
@@ -117,76 +117,6 @@ var _ = Describe("Manifest", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("HasExperimentalThreads", func() {
|
||||
It("returns false when no experimental section", func() {
|
||||
m := &Manifest{}
|
||||
Expect(m.HasExperimentalThreads()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when experimental section has no threads", func() {
|
||||
m := &Manifest{
|
||||
Experimental: &Experimental{},
|
||||
}
|
||||
Expect(m.HasExperimentalThreads()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true when threads feature is present", func() {
|
||||
m := &Manifest{
|
||||
Experimental: &Experimental{
|
||||
Threads: &ThreadsFeature{},
|
||||
},
|
||||
}
|
||||
Expect(m.HasExperimentalThreads()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true when threads feature has a reason", func() {
|
||||
m := &Manifest{
|
||||
Experimental: &Experimental{
|
||||
Threads: &ThreadsFeature{
|
||||
Reason: new("Required for concurrent processing"),
|
||||
},
|
||||
},
|
||||
}
|
||||
Expect(m.HasExperimentalThreads()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("parses experimental.threads from JSON", func() {
|
||||
data := []byte(`{
|
||||
"name": "Threaded Plugin",
|
||||
"author": "Test Author",
|
||||
"version": "1.0.0",
|
||||
"experimental": {
|
||||
"threads": {
|
||||
"reason": "To use multi-threaded WASM module"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m.HasExperimentalThreads()).To(BeTrue())
|
||||
Expect(m.Experimental.Threads.Reason).ToNot(BeNil())
|
||||
Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module"))
|
||||
})
|
||||
|
||||
It("parses experimental.threads without reason from JSON", func() {
|
||||
data := []byte(`{
|
||||
"name": "Threaded Plugin",
|
||||
"author": "Test Author",
|
||||
"version": "1.0.0",
|
||||
"experimental": {
|
||||
"threads": {}
|
||||
}
|
||||
}`)
|
||||
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m.HasExperimentalThreads()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ParseManifest", func() {
|
||||
It("parses a valid manifest with users permission", func() {
|
||||
data := []byte(`{
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"fields": {
|
||||
"albumArtist": "专辑艺人",
|
||||
"duration": "时长",
|
||||
"trackNumber": "音轨号",
|
||||
"trackNumber": "曲目序号",
|
||||
"playCount": "播放次数",
|
||||
"title": "标题",
|
||||
"artist": "艺人",
|
||||
@@ -22,6 +22,8 @@
|
||||
"bitRate": "比特率",
|
||||
"bitDepth": "位深度",
|
||||
"sampleRate": "采样率",
|
||||
"albumGain": "专辑增益",
|
||||
"trackGain": "曲目增益",
|
||||
"channels": "声道",
|
||||
"disc": "碟片 %{discNumber}",
|
||||
"discSubtitle": "碟片副标题",
|
||||
@@ -142,7 +144,7 @@
|
||||
"name": "用户",
|
||||
"fields": {
|
||||
"userName": "用户名",
|
||||
"isAdmin": "是否管理员",
|
||||
"isAdmin": "是否为管理员",
|
||||
"lastLoginAt": "上次登录",
|
||||
"lastAccessAt": "上次访问",
|
||||
"updatedAt": "更新于",
|
||||
@@ -623,11 +625,11 @@
|
||||
"lastfmScrobbling": "启用 Last.fm 的个性化记录",
|
||||
"listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录",
|
||||
"replaygain": "回放增益",
|
||||
"preAmp": "前置放大器 (dB)",
|
||||
"preAmp": "回放增益 - 前置放大 (dB)",
|
||||
"gain": {
|
||||
"none": "禁用增益",
|
||||
"album": "使用专辑增益信息",
|
||||
"track": "使用歌曲增益信息"
|
||||
"none": "禁用",
|
||||
"album": "使用专辑增益",
|
||||
"track": "使用曲目增益"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+78
-1
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -211,6 +214,16 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ
|
||||
ctx := request.AddValues(s.rootCtx, requestCtx)
|
||||
ctx = auth.WithAdminUser(ctx, s.ds)
|
||||
|
||||
// A quick scan is promoted to a full one when it resumes an interrupted full scan; that happens
|
||||
// inside the scanner (possibly in a subprocess), so mirror it here for the analysis gate. Must
|
||||
// be read before the scan: ScanEnd clears the flag.
|
||||
effectiveFullScan := EffectiveFullScan(ctx, s.ds, fullScan, targets)
|
||||
if effectiveFullScan || s.includesUnscannedLibrary(ctx, targets) {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Scanner: Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the initial scan status event
|
||||
s.sendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0})
|
||||
progress := make(chan *ProgressInfo, 100)
|
||||
@@ -229,6 +242,15 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ
|
||||
if scanError != nil {
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error())
|
||||
}
|
||||
// Refresh the query-planner statistics after a successful full scan. This must run in the
|
||||
// server process: with the external scanner, an ANALYZE in the subprocess is invisible to the
|
||||
// server's pooled connections; their shared schema cache keeps the old statistics until the
|
||||
// process restarts.
|
||||
if effectiveFullScan && scanError == nil {
|
||||
if err := db.Optimize(ctx); err != nil {
|
||||
log.Error(ctx, "Scanner: Error analyzing DB", err)
|
||||
}
|
||||
}
|
||||
// If changes were detected, send a refresh event to all clients
|
||||
if s.changesDetected {
|
||||
log.Debug(ctx, "Library changes imported. Sending refresh event")
|
||||
@@ -255,18 +277,73 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ
|
||||
|
||||
// This is a global variable that is used to prevent multiple scans from running at the same time.
|
||||
// "There can be only one" - https://youtu.be/sqcLjcSloXs?si=VlsjEOjTJZ68zIyg
|
||||
var running atomic.Bool
|
||||
var (
|
||||
running atomic.Bool
|
||||
scanMaintenanceMux sync.Mutex
|
||||
)
|
||||
|
||||
func lockScan(ctx context.Context) (func(), error) {
|
||||
if !running.CompareAndSwap(false, true) {
|
||||
log.Debug(ctx, "Scanner already running, ignoring request")
|
||||
return func() {}, ErrAlreadyScanning
|
||||
}
|
||||
scanMaintenanceMux.Lock()
|
||||
return func() {
|
||||
scanMaintenanceMux.Unlock()
|
||||
running.Store(false)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LockForMaintenance prevents a scan from starting while database maintenance is running.
|
||||
func LockForMaintenance() (func(), bool) {
|
||||
if !scanMaintenanceMux.TryLock() {
|
||||
return func() {}, false
|
||||
}
|
||||
if running.Load() {
|
||||
scanMaintenanceMux.Unlock()
|
||||
return func() {}, false
|
||||
}
|
||||
return scanMaintenanceMux.Unlock, true
|
||||
}
|
||||
|
||||
// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted
|
||||
// full scan in one of the included libraries.
|
||||
func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool {
|
||||
if fullScan {
|
||||
return true
|
||||
}
|
||||
return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool {
|
||||
return library.FullScanInProgress
|
||||
})
|
||||
}
|
||||
|
||||
func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool {
|
||||
return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool {
|
||||
return library.LastScanAt.IsZero()
|
||||
})
|
||||
}
|
||||
|
||||
// anyIncludedLibrary reports whether any library included in the scan (all of them when targets is
|
||||
// empty) matches pred.
|
||||
func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool {
|
||||
libraries, err := ds.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return slices.ContainsFunc(libraries, pred)
|
||||
}
|
||||
|
||||
targeted := make(map[int]struct{}, len(targets))
|
||||
for _, target := range targets {
|
||||
targeted[target.LibraryID] = struct{}{}
|
||||
}
|
||||
return slices.ContainsFunc(libraries, func(library model.Library) bool {
|
||||
_, ok := targeted[library.ID]
|
||||
return ok && pred(library)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *controller) trackProgress(ctx context.Context, progress <-chan *ProgressInfo) ([]string, error) {
|
||||
s.count.Store(0)
|
||||
s.folderCount.Store(0)
|
||||
|
||||
@@ -55,3 +55,41 @@ var _ = Describe("Controller", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("LockForMaintenance", func() {
|
||||
It("allows only one database maintenance operation at a time", func() {
|
||||
release, ok := scanner.LockForMaintenance()
|
||||
Expect(ok).To(BeTrue())
|
||||
DeferCleanup(release)
|
||||
|
||||
_, ok = scanner.LockForMaintenance()
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("EffectiveFullScan", func() {
|
||||
var ds *tests.MockDataStore
|
||||
|
||||
BeforeEach(func() {
|
||||
libraries := &tests.MockLibraryRepo{}
|
||||
libraries.SetData(model.Libraries{
|
||||
{ID: 1, FullScanInProgress: true},
|
||||
{ID: 2},
|
||||
})
|
||||
ds = &tests.MockDataStore{MockedLibrary: libraries}
|
||||
})
|
||||
|
||||
It("detects an interrupted full scan in a targeted library", func() {
|
||||
targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}}
|
||||
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects an interrupted full scan when scanning all libraries", func() {
|
||||
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, nil)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("ignores interrupted full scans in untargeted libraries", func() {
|
||||
targets := []model.ScanTarget{{LibraryID: 2, FolderPath: "."}}
|
||||
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/run"
|
||||
@@ -161,9 +160,6 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
|
||||
|
||||
// Update last_scan_completed_at for all libraries
|
||||
s.runUpdateLibraries(ctx, &state),
|
||||
|
||||
// Optimize DB
|
||||
s.runOptimize(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
|
||||
@@ -280,15 +276,6 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scannerImpl) runOptimize(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
start := time.Now()
|
||||
db.Optimize(ctx)
|
||||
log.Debug(ctx, "Scanner: Optimized DB", "elapsed", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error {
|
||||
return func() error {
|
||||
start := time.Now()
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
@@ -80,7 +82,7 @@ var _ = Describe("ScanFolders", Ordered, func() {
|
||||
rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"})
|
||||
jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"})
|
||||
pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"})
|
||||
createFS(fstest.MapFS{
|
||||
fsys = createFS(fstest.MapFS{
|
||||
"rock/track1.mp3": rock(track(1, "Rock Track 1")),
|
||||
"rock/track2.mp3": rock(track(2, "Rock Track 2")),
|
||||
"rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")),
|
||||
@@ -122,6 +124,38 @@ var _ = Describe("ScanFolders", Ordered, func() {
|
||||
|
||||
// Verify files in the pop folder were NOT scanned
|
||||
Expect(paths).ToNot(ContainElement("pop/track6.mp3"))
|
||||
Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("1"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Planner statistics maintenance", func() {
|
||||
It("does not mark routine quick-scan changes for immediate analysis", func() {
|
||||
rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"})
|
||||
fsys = createFS(fstest.MapFS{
|
||||
"rock/track1.mp3": rock(track(1, "Rock Track 1")),
|
||||
})
|
||||
_, err := s.ScanAll(ctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||||
|
||||
fsys.Add("rock/track2.mp3", rock(track(2, "Rock Track 2")), time.Now().Add(time.Second))
|
||||
_, err = s.ScanAll(ctx, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||||
})
|
||||
|
||||
It("does not treat an interrupted scan in an untargeted library as a full scan", func() {
|
||||
otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"}
|
||||
Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed())
|
||||
Expect(ds.Library(ctx).ScanBegin(lib.ID, true)).To(Succeed())
|
||||
|
||||
lastAnalyze := "2026-07-09T12:00:00Z"
|
||||
Expect(ds.Property(ctx).Put(consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed())
|
||||
Expect(ds.Property(ctx).Put(consts.DBAnalyzePendingKey, "0")).To(Succeed())
|
||||
|
||||
_, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ds.Property(ctx).Get(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -61,6 +61,19 @@ func AlbumsByArtistID(artistId string) Options {
|
||||
})
|
||||
}
|
||||
|
||||
// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the
|
||||
// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an
|
||||
// artist's own discography never leaks into it.
|
||||
func AlbumsByContributingArtistID(artistId string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "max_year",
|
||||
Filters: And{
|
||||
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}),
|
||||
persistence.NotExists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func AlbumsByYear(fromYear, toYear int) Options {
|
||||
orderOption := ""
|
||||
if fromYear > toYear {
|
||||
@@ -90,6 +103,17 @@ func SongsByAlbum(albumId string) Options {
|
||||
})
|
||||
}
|
||||
|
||||
// SongsByArtistID matches media files where the artist participates as album or track artist, in
|
||||
// album order. Semi-joins media_file_artists; scanning the participants JSON is ~10x slower at scale.
|
||||
func SongsByArtistID(artistId string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "album",
|
||||
Filters: Expr(
|
||||
"media_file.id IN (SELECT media_file_id FROM media_file_artists WHERE artist_id = ? AND role IN (?, ?))",
|
||||
artistId, model.RoleArtist.String(), model.RoleAlbumArtist.String()),
|
||||
})
|
||||
}
|
||||
|
||||
func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options {
|
||||
options := Options{}
|
||||
ff := And{}
|
||||
@@ -138,6 +162,21 @@ func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options {
|
||||
return opts
|
||||
}
|
||||
|
||||
// ArtistsByRole restricts an artist query to artists appearing in the given role (album artist,
|
||||
// performer, composer, ...) via library_artist.stats. An unknown role is ignored (no filter).
|
||||
func ArtistsByRole(opts Options, role model.Role) Options {
|
||||
if _, ok := model.AllRoles[role.String()]; !ok {
|
||||
return opts
|
||||
}
|
||||
roleFilter := Expr("JSON_EXTRACT(library_artist.stats, '$." + role.String() + ".m') IS NOT NULL")
|
||||
if opts.Filters == nil {
|
||||
opts.Filters = roleFilter
|
||||
} else {
|
||||
opts.Filters = And{opts.Filters, roleFilter}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func ByGenre(genre string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "name",
|
||||
@@ -145,11 +184,51 @@ func ByGenre(genre string) Options {
|
||||
})
|
||||
}
|
||||
|
||||
// ByGenreID matches items (albums or songs) tagged with any of the given genre tag ids.
|
||||
func ByGenreID(genreIds []string) Sqlizer {
|
||||
return genreTagFilter(Eq{"value": genreIds})
|
||||
}
|
||||
|
||||
// ByAlbumID matches media files belonging to any of the given albums.
|
||||
func ByAlbumID(albumIds []string) Sqlizer {
|
||||
return Eq{"album_id": albumIds}
|
||||
}
|
||||
|
||||
// AlbumsByYears matches albums whose production year (max_year) is in years.
|
||||
func AlbumsByYears(years []int) Sqlizer {
|
||||
return Eq{"max_year": years}
|
||||
}
|
||||
|
||||
// SongsByYears matches media files whose year is in years.
|
||||
func SongsByYears(years []int) Sqlizer {
|
||||
return Eq{"year": years}
|
||||
}
|
||||
|
||||
// ArtistsByGenreID matches artists credited as album artist on an album with any of the given
|
||||
// genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row.
|
||||
func ArtistsByGenreID(genreIds []string) Sqlizer {
|
||||
return Expr(
|
||||
`artist.id IN (SELECT jt.value FROM album, json_tree(album.participants, '$.albumartist') jt
|
||||
WHERE jt.atom IS NOT NULL AND ?)`,
|
||||
genreTagFilter(Eq{"value": genreIds}),
|
||||
)
|
||||
}
|
||||
|
||||
// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each
|
||||
// entry against cond (its name via Like, or its tag id via Eq/IN).
|
||||
func tagIDFilter(tagName string, cond Sqlizer) Sqlizer {
|
||||
return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond})
|
||||
}
|
||||
|
||||
func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) }
|
||||
|
||||
// ByStudioID matches items (albums or songs) whose record-label tag id is in ids.
|
||||
func ByStudioID(ids []string) Sqlizer {
|
||||
return tagIDFilter("recordlabel", Eq{"value": ids})
|
||||
}
|
||||
|
||||
func filterByGenre(genre string) Sqlizer {
|
||||
return persistence.Exists(`json_tree(tags, "$.genre")`, And{
|
||||
Like{"value": genre},
|
||||
NotEq{"atom": nil},
|
||||
})
|
||||
return genreTagFilter(Like{"value": genre})
|
||||
}
|
||||
|
||||
func ByRating() Options {
|
||||
@@ -0,0 +1,362 @@
|
||||
# Jellyfin API
|
||||
|
||||
This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of
|
||||
Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets
|
||||
Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp),
|
||||
[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without
|
||||
requiring a real Jellyfin server.
|
||||
|
||||
It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music
|
||||
library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report
|
||||
playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's
|
||||
admin/dashboard APIs are out of scope.
|
||||
|
||||
## Enabling
|
||||
|
||||
The Jellyfin API is disabled by default. Enable it via `navidrome.toml`:
|
||||
|
||||
```toml
|
||||
[Jellyfin]
|
||||
Enabled = true
|
||||
# Optional: override the server name reported to clients (defaults to "Navidrome <version>")
|
||||
ServerName = "My Music Server"
|
||||
# Optional: usernames to show in the client login user-picker (default: none). See "Public user list".
|
||||
ExposedPublicUsers = "alice, bob"
|
||||
# Optional: max collection responses streaming at once (default: half the DB connection pool,
|
||||
# min 2). Each streaming response holds a DB connection for its whole duration; excess requests
|
||||
# queue rather than fail.
|
||||
MaxConcurrentStreams = 4
|
||||
```
|
||||
|
||||
or via environment variables:
|
||||
|
||||
```bash
|
||||
ND_JELLYFIN_ENABLED=true
|
||||
ND_JELLYFIN_SERVERNAME="My Music Server"
|
||||
ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob"
|
||||
```
|
||||
|
||||
Once enabled, the API is mounted at:
|
||||
|
||||
```
|
||||
http://<host>:<port>/jellyfin
|
||||
```
|
||||
|
||||
All the paths below are relative to that base URL (e.g. `System/Info/Public` means
|
||||
`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**,
|
||||
since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths.
|
||||
|
||||
## Authentication
|
||||
|
||||
Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome
|
||||
username/password, and get back an `AccessToken` (a Navidrome JWT). That token is then sent on
|
||||
every subsequent request as the `X-Emby-Token` header (or embedded in the
|
||||
`X-Emby-Authorization`/`Authorization` header's `Token="..."` field, or as an `api_key`/`ApiKey`
|
||||
query param — all forms are accepted, matching what different clients do).
|
||||
|
||||
`POST /Users/AuthenticateByName` is rate-limited per IP with the same limiter as the native
|
||||
`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force
|
||||
surface.
|
||||
|
||||
### Public user list (login picker)
|
||||
|
||||
`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the
|
||||
password) instead of a blank username field. It's **unauthenticated**, so by default it exposes
|
||||
**no** users. Set `Jellyfin.ExposedPublicUsers` to a comma-separated list of usernames to advertise:
|
||||
|
||||
```toml
|
||||
[Jellyfin]
|
||||
ExposedPublicUsers = "alice, bob"
|
||||
```
|
||||
|
||||
Only the named users are listed (never the full user table), resolved live per request; a configured
|
||||
name that doesn't exist is skipped and logged at `Warn`. Each entry is a minimal DTO (`Name`, `Id`)
|
||||
with no `Policy`/`Configuration`, so admin status isn't leaked to unauthenticated callers, and no
|
||||
avatar (`PrimaryImageTag` omitted — Navidrome has no per-user profile images).
|
||||
|
||||
## Players and sessions
|
||||
|
||||
Every authenticated request registers (or refreshes) the calling device as a Navidrome player,
|
||||
mirroring Subsonic's `getPlayer` — so a Jellyfin client shows up in the players list (and scrobbling
|
||||
has a player) as soon as it makes any authenticated call, not only when it reports playback. The
|
||||
player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the player name is
|
||||
`Client [Device]`. Those field values are URL-decoded, since some clients percent-encode them
|
||||
(Jellify sends `Device="Pixel%208%20Pro"`, Finamp sends it raw). A request that carries no
|
||||
client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is
|
||||
skipped, so it doesn't create a nameless player.
|
||||
|
||||
## ID encoding
|
||||
|
||||
Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id
|
||||
is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients
|
||||
parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`,
|
||||
which chokes on Navidrome's base-62 nanoids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a raw MD5 id
|
||||
from an old migrated library is itself valid hex, correctness depends on every emit path encoding
|
||||
and every receive path decoding — see `dto/ids.go`.
|
||||
|
||||
## Multi-library behavior
|
||||
|
||||
Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each
|
||||
Navidrome library the current user can access is exposed as its own top-level Jellyfin
|
||||
"CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view.
|
||||
Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the
|
||||
authenticated user has access to; a library (or item within it) the user cannot access returns
|
||||
`404`, never `403`, so ids can't be used as an existence oracle.
|
||||
|
||||
### Browsing filters
|
||||
|
||||
`GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id
|
||||
for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into
|
||||
an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or
|
||||
tracks — Finamp's artist screen sends these *alongside* `ParentId=<libraryId>`); `AlbumIds` (an
|
||||
album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreIds` (a
|
||||
genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists`
|
||||
and `MusicArtist` queries accept it too, matching artists credited on an album of that genre);
|
||||
`SearchTerm`;
|
||||
favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`;
|
||||
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
|
||||
returns direct children only (no tracks — no track is a library's direct child).
|
||||
|
||||
## Implemented endpoints
|
||||
|
||||
| Area | Endpoints |
|
||||
|---|---|
|
||||
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` |
|
||||
| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` |
|
||||
| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` |
|
||||
| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) |
|
||||
| Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` |
|
||||
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Items/{itemId}/InstantMix` |
|
||||
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) |
|
||||
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` |
|
||||
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` |
|
||||
| Lyrics | `GET Audio/{itemId}/Lyrics` |
|
||||
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` |
|
||||
| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` |
|
||||
| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) |
|
||||
| AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` |
|
||||
|
||||
Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level
|
||||
as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint
|
||||
that isn't in the table above, check the server logs for these lines to see exactly what it's
|
||||
requesting.
|
||||
|
||||
## Playlist management
|
||||
|
||||
Playlists are the main writable surface of this API:
|
||||
|
||||
- **Container expansion.** When creating (`POST Playlists`), adding to (`POST Playlists/{id}/Items`)
|
||||
or replacing (`POST Playlists/{id}`) a playlist, the `Ids` may contain **containers** — album,
|
||||
artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before
|
||||
the write, matching how Jellyfin clients populate these lists. A bare song id passes through.
|
||||
- **Id list encoding.** `POST`/`DELETE Playlists/{id}/Items` accept the id list both ways clients
|
||||
spell it: repeated params (`ids=X&ids=Y`, how Jellify's `@jellyfin/sdk` serializes arrays) and a
|
||||
single comma-separated value (`ids=X,Y`, Finamp). Reading only the first value would add just one
|
||||
track of an expanded album.
|
||||
- **Update** (`POST Playlists/{id}`): with `Ids` present, the track list is **replaced** (Finamp
|
||||
uses this for reordering) — an explicit empty `Ids` (`[]`) **clears** the playlist, while an
|
||||
omitted `Ids` leaves the tracks untouched and only updates `Name`/`IsPublic`. `IsPublic` maps to
|
||||
Navidrome's `Public` flag, surfaced to clients as `OpenAccess` on `GET Playlists/{id}`.
|
||||
- **Cover art**: `POST Items/{id}/Images/Primary` uploads a playlist cover (raw or base64 body,
|
||||
JPEG/PNG/WebP/GIF detected by magic number, extension from `Content-Type`); `DELETE` removes it.
|
||||
Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning,
|
||||
so a non-playlist id returns `501`. Uploads honor the same gates as the native endpoint: they're
|
||||
bounded by `MaxImageUploadSize` and require `EnableArtworkUpload` for non-admins.
|
||||
- **`PlaylistItemId`**: `GET Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the
|
||||
playlist-track row id, distinct from the song id) so a client can echo it back via
|
||||
`DELETE Playlists/{id}/Items?EntryIds=...` to remove one occurrence of a song that appears more
|
||||
than once in the same playlist.
|
||||
|
||||
Ownership is enforced by `core/playlists`: a non-owner editing/deleting a playlist gets `403` if
|
||||
it is visible to them (public) or `404` if it is not (private) — the API never reveals that
|
||||
someone else's private playlist exists.
|
||||
|
||||
## Images
|
||||
|
||||
The `GET Items/{itemId}/Images/{type}` route is intentionally **public** (artwork isn't sensitive,
|
||||
matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is
|
||||
therefore resolved under an **elevated admin context** — the same approach `core/artwork`'s cache
|
||||
warmer uses — so user-scoped items like private playlists still resolve their cover instead of
|
||||
falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to
|
||||
their Navidrome `ArtworkID`.
|
||||
|
||||
## Finamp saved-queue id truncation
|
||||
|
||||
Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that
|
||||
when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16
|
||||
bytes. Navidrome ids are longer (nanoid ids can exceed 128 bits, so they cannot be mapped into
|
||||
GUIDs), which means Finamp silently stores only the first 16 characters of each id and asks for
|
||||
those **truncated ids** back when restoring the queue — item lookups, then streaming, images,
|
||||
favorites and playback reports for the restored tracks.
|
||||
|
||||
This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome
|
||||
id family uses — is resolved to the full id by unique-prefix lookup (an indexed range scan;
|
||||
ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as
|
||||
requested**, because Finamp matches restored items back to its stored ids, and the other item
|
||||
endpoints accept truncated ids transparently.
|
||||
|
||||
**Proper fix (upstream):** Finamp's `packIds()`/`_unpackIds()` (`lib/models/finamp_models.dart`)
|
||||
should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the
|
||||
queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids,
|
||||
so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer
|
||||
can be removed.
|
||||
|
||||
## Streaming and transcoding
|
||||
|
||||
The stream endpoints reuse the same transcode-decision pipeline as the Subsonic `/stream` endpoint:
|
||||
|
||||
- **`GET Audio/{id}/stream[.{container}]` / `universal`** — the target format comes from the
|
||||
`.{container}` path suffix, the `container` param, or (when neither is present) `audioCodec`.
|
||||
`audioBitRate`/`maxStreamingBitrate` are bits/sec, per Jellyfin convention. `static=true`
|
||||
forces direct play (raw), never a transcode.
|
||||
- **`GET Items/{id}/File` / `Download`** — always the original file bytes, matching real Jellyfin.
|
||||
Finamp plays through `File` when its transcoding setting is off, so an undecodable format (e.g.
|
||||
DSF) can't be rescued server-side on this path.
|
||||
- **`GET Audio/{id}/main.m3u8`** — the endpoint Finamp plays through when its transcoding setting
|
||||
is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive
|
||||
transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused.
|
||||
Segment codec honors `audioCodec` but is limited to what HLS packed-audio can carry (`aac`,
|
||||
`mp3`); anything else falls back to `aac`. Seeking re-reads from the start, like Subsonic
|
||||
transcoded streams.
|
||||
- **Server-forced transcoding.** A format/bitrate configured on the registered player (Settings →
|
||||
Players) is applied to `stream`, `universal` and `main.m3u8` — same override semantics as
|
||||
Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are
|
||||
advertised and served but packed-audio players won't decode them.
|
||||
|
||||
## AudioMuse-AI compatible endpoints
|
||||
|
||||
Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin)
|
||||
— e.g. [Symfonium](https://symfonium.app/) can use these endpoints for sonic mixes when
|
||||
connected as a Jellyfin client.
|
||||
Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no
|
||||
external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity`
|
||||
plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension.
|
||||
|
||||
- `GET /AudioMuseAI/info` — returns `{"Version": <navidrome version>, "AvailableEndpoints": [...]}` (200).
|
||||
`AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty.
|
||||
- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404.
|
||||
- `GET /AudioMuseAI/similar_tracks?item_id=<id>&n=10&eliminate_duplicates=true` — 404 when no provider is
|
||||
loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match
|
||||
or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist.
|
||||
- `GET /AudioMuseAI/find_path?start_song_id=<id>&end_song_id=<id>&max_steps=25` — 404 when no provider is
|
||||
loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (200), or 400
|
||||
with `start_song_id and end_song_id are required.` when either id is missing.
|
||||
|
||||
`item_id`/`start_song_id`/`end_song_id` are the hex-encoded ids Navidrome hands Jellyfin clients.
|
||||
`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features
|
||||
(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library
|
||||
setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries
|
||||
the caller can access, since hops through inaccessible libraries are filtered out of the result.
|
||||
|
||||
## curl walkthrough
|
||||
|
||||
This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the
|
||||
library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist.
|
||||
|
||||
```bash
|
||||
BASE=http://localhost:4533/jellyfin
|
||||
|
||||
# 1. Handshake (no auth required)
|
||||
curl -s "$BASE/System/Info/Public" | jq .
|
||||
|
||||
# 2. Login - capture the AccessToken
|
||||
TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken)
|
||||
|
||||
AUTH=(-H "X-Emby-Token: $TOKEN")
|
||||
|
||||
# 3. List the user's views (one per accessible library)
|
||||
curl -s "${AUTH[@]}" "$BASE/UserViews" | jq .
|
||||
|
||||
# 4. Browse artists
|
||||
curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq .
|
||||
ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id')
|
||||
|
||||
# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum)
|
||||
ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id')
|
||||
|
||||
# 6. List the album's songs
|
||||
USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id)
|
||||
SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \
|
||||
| jq -r '.Items[0].Id')
|
||||
|
||||
# 7. Ask for playback info, then stream the song
|
||||
curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq .
|
||||
curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio
|
||||
|
||||
# 8. Favorite the song
|
||||
curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq .
|
||||
|
||||
# 9. Report playback start/stop (also drives scrobbling)
|
||||
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||||
-d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing"
|
||||
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||||
-d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped"
|
||||
|
||||
# 10. Create a playlist from a whole album (the album id is expanded to its tracks)
|
||||
PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||||
-d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id)
|
||||
|
||||
# 11. Make it public, then remove one entry
|
||||
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||||
-d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID"
|
||||
ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId')
|
||||
curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID"
|
||||
|
||||
# 12. Delete the playlist
|
||||
curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in
|
||||
[`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and
|
||||
real repositories (only artwork/streaming/ffmpeg are stubbed), with per-`Describe` snapshot
|
||||
isolation — mirroring the Subsonic `server/subsonic/e2e` suite. Run it with:
|
||||
|
||||
```bash
|
||||
make test PKG=./server/jellyfin/...
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Genres are global.** `GET Genres`/`MusicGenres` is not scoped to the current user's
|
||||
libraries (genre tags aren't per-library entities in Navidrome's model).
|
||||
- **Artist item-access relies on list-time scoping.** Unlike albums and songs (which each
|
||||
belong to exactly one library and are checked against `user.HasLibraryAccess` on every
|
||||
fetch), an artist can have content across multiple libraries via `library_artist`, so there's
|
||||
no single library id to gate a direct `GET Items/{artistId}` or favorite/rating call against.
|
||||
Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist`
|
||||
*list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client
|
||||
that already has an artist id from elsewhere is not re-checked against library membership.
|
||||
- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is
|
||||
populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)**
|
||||
blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a
|
||||
multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and
|
||||
stores it per image, so its placeholder approximates the art. Ours satisfies the protocol
|
||||
(Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash
|
||||
warning) but renders as a flat color while art loads. A proper implementation would compute the
|
||||
real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it
|
||||
keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback
|
||||
for art that hasn't been rendered yet.
|
||||
- **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a
|
||||
`ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a
|
||||
working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up
|
||||
would broadcast real session/playstate and library-change events over it (via `server/events`),
|
||||
mirroring Jellyfin's session messages.
|
||||
- **Lyrics.** `GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in
|
||||
100ns ticks, word-level `Cues` when present), resolved through the full `core/lyrics` pipeline
|
||||
(embedded, `.lrc` sidecars, plugins per `LyricsPriority`) behind a 5-minute TTL cache that also
|
||||
caches misses — Jellify fetches for every played track, Feishin per song change, so lyric-less
|
||||
tracks are the hot path. No lyrics → 404 (never an empty 200), which all three clients degrade
|
||||
gracefully. Finamp gates its lyrics view on a `Lyric` `MediaStream` (not `HasLyrics`, which is
|
||||
just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel
|
||||
check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per
|
||||
track so sidecar/plugin lyrics also light up. Feishin additionally requires server version
|
||||
≥ 10.9 — the reason `jellyfinVersion` is 10.9.11.
|
||||
Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader`
|
||||
is singleflighted), and the load runs detached from the request context with a one-minute bound,
|
||||
so a cancelled request or hung plugin can't fail or pin the load for other waiters.
|
||||
Follow-up: tracks whose only lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in
|
||||
lists (request-time sources can't be known at list time without per-row I/O).
|
||||
@@ -0,0 +1,121 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
// resolveAnnotated finds which annotated repo owns id, returning the resource name used in
|
||||
// refreshResource events. Albums and songs 404 when the user can't access their library; artists
|
||||
// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
|
||||
// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
|
||||
// already been written, so callers must return without writing the annotation.
|
||||
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) {
|
||||
ctx := r.Context()
|
||||
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
api.internalError(w, r, err)
|
||||
return nil, ""
|
||||
}
|
||||
u, _ := request.UserFrom(ctx)
|
||||
switch e := entity.(type) {
|
||||
case *model.Album:
|
||||
if u.HasLibraryAccess(e.LibraryID) {
|
||||
return api.ds.Album(ctx), "album"
|
||||
}
|
||||
case *model.Artist:
|
||||
return api.ds.Artist(ctx), "artist"
|
||||
case *model.MediaFile:
|
||||
if u.HasLibraryAccess(e.LibraryID) {
|
||||
return api.ds.MediaFile(ctx), "song"
|
||||
}
|
||||
case *model.Playlist:
|
||||
return api.ds.Playlist(ctx), "playlist"
|
||||
}
|
||||
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
|
||||
// fetches this per item to render played/favourite indicators; resolveItemByID enforces the
|
||||
// library-access gate.
|
||||
func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
|
||||
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
|
||||
item, ok := api.resolveItemByID(r.Context(), id, nil)
|
||||
if !ok {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := item.UserData
|
||||
if data == nil {
|
||||
// Items without annotations still return a valid empty UserData.
|
||||
data = dto.UserData(model.Annotations{}, id)
|
||||
}
|
||||
api.ok(w, r, data)
|
||||
}
|
||||
|
||||
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
|
||||
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
|
||||
repo, resource := api.resolveAnnotated(w, r, id)
|
||||
if repo == nil {
|
||||
return
|
||||
}
|
||||
if err := repo.SetStar(starred, id); err != nil {
|
||||
api.internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
||||
encodedID := dto.EncodeID(id)
|
||||
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
|
||||
}
|
||||
|
||||
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) }
|
||||
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
api.setFavorite(w, r, false)
|
||||
}
|
||||
|
||||
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
|
||||
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
|
||||
repo, resource := api.resolveAnnotated(w, r, id)
|
||||
if repo == nil {
|
||||
return
|
||||
}
|
||||
if err := repo.SetRating(rating, id); err != nil {
|
||||
api.internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
||||
encodedID := dto.EncodeID(id)
|
||||
d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID}
|
||||
if rating > 0 {
|
||||
jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData
|
||||
d.Rating = &jfRating
|
||||
}
|
||||
api.ok(w, r, d)
|
||||
}
|
||||
|
||||
// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to
|
||||
// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since
|
||||
// SetRating(0) is the delete path.
|
||||
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
||||
jfRating := req.Params(r).Float64Or("rating", 0)
|
||||
jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating
|
||||
rating := int(math.Round(jfRating / 2))
|
||||
if jfRating > 0 {
|
||||
rating = max(rating, 1)
|
||||
}
|
||||
api.setItemRating(w, r, rating)
|
||||
}
|
||||
|
||||
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
||||
api.setItemRating(w, r, 0)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Annotations", func() {
|
||||
var api *Router
|
||||
var ds *tests.MockDataStore
|
||||
var broker *fakeEventBroker
|
||||
// alice has access to library 1 only.
|
||||
ctxUser := func() context.Context {
|
||||
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
broker = &fakeEventBroker{}
|
||||
api = &Router{ds: ds, broker: broker}
|
||||
})
|
||||
|
||||
Describe("markFavorite / unmarkFavorite", func() {
|
||||
It("stars a song and returns IsFavorite=true", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var d dto.UserItemDataDto
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||
Expect(d.IsFavorite).To(BeTrue())
|
||||
Expect(mfRepo.Data["s1"].Starred).To(BeTrue())
|
||||
})
|
||||
|
||||
It("stars an album and returns IsFavorite=true", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var d dto.UserItemDataDto
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||
Expect(d.IsFavorite).To(BeTrue())
|
||||
Expect(albumRepo.Data["a1"].Starred).To(BeTrue())
|
||||
})
|
||||
|
||||
It("stars an artist without checking library access (artists span multiple libraries)", func() {
|
||||
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
|
||||
w := httptest.NewRecorder()
|
||||
// alice only has access to library 1, but artists aren't gated per-library.
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "ar1")
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var d dto.UserItemDataDto
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||
Expect(d.IsFavorite).To(BeTrue())
|
||||
Expect(artistRepo.Data["ar1"].Starred).To(BeTrue())
|
||||
})
|
||||
|
||||
It("stars a visible playlist", func() {
|
||||
playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo)
|
||||
playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID("p1"))
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(playlistRepo.Starred["p1"]).To(BeTrue())
|
||||
})
|
||||
|
||||
It("unstars a song and returns IsFavorite=false", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.unmarkFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var d dto.UserItemDataDto
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||
Expect(d.IsFavorite).To(BeFalse())
|
||||
Expect(mfRepo.Data["s1"].Starred).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns 404 and does not star an album in a library the user can't access", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(albumRepo.Data["a1"].Starred).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns 404 and does not star a song in a library the user can't access", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(mfRepo.Data["s1"].Starred).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns 404 when the id doesn't match any entity", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "missing")
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() {
|
||||
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "x1")
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
It("emits a refreshResource event when starring a song", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
|
||||
})
|
||||
|
||||
It("emits a refreshResource event when starring an album", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`))
|
||||
})
|
||||
|
||||
It("does not emit an event when the item is not accessible", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
|
||||
invoke(api.markFavorite, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(broker.Events).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("setRating / removeRating", func() {
|
||||
It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data["s1"].Rating).To(Equal(4))
|
||||
var d dto.UserItemDataDto
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||
Expect(d.Rating).NotTo(BeNil())
|
||||
Expect(*d.Rating).To(Equal(8.0))
|
||||
})
|
||||
|
||||
It("rates an album", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(albumRepo.Data["a1"].Rating).To(Equal(5))
|
||||
})
|
||||
|
||||
It("rates a visible playlist", func() {
|
||||
playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo)
|
||||
playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("p1")+"/Rating?Rating=8", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID("p1"))
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(playlistRepo.Ratings["p1"]).To(Equal(4))
|
||||
})
|
||||
|
||||
It("removes a rating", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.removeRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
|
||||
var d dto.UserItemDataDto
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||
Expect(d.Rating).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns 404 and does not rate an album in a library the user can't access", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(albumRepo.Data["a1"].Rating).To(Equal(0))
|
||||
})
|
||||
|
||||
It("rounds an odd rating to the nearest star instead of truncating", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data["s1"].Rating).To(Equal(5))
|
||||
})
|
||||
|
||||
It("stores the minimum star for Rating=1 instead of clearing the rating", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data["s1"].Rating).To(Equal(1))
|
||||
})
|
||||
|
||||
It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data["s1"].Rating).To(Equal(4))
|
||||
})
|
||||
|
||||
It("clamps a Rating above 10 to Navidrome's max (5)", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data["s1"].Rating).To(Equal(5))
|
||||
})
|
||||
|
||||
It("clamps a negative Rating to Navidrome's min (0)", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
|
||||
})
|
||||
|
||||
It("emits a refreshResource event when rating a song", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeEventBroker struct {
|
||||
http.Handler
|
||||
Events []events.Event
|
||||
}
|
||||
|
||||
func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
|
||||
f.Events = append(f.Events, event)
|
||||
}
|
||||
|
||||
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
|
||||
f.Events = append(f.Events, event)
|
||||
}
|
||||
|
||||
var _ events.Broker = (*fakeEventBroker)(nil)
|
||||
@@ -0,0 +1,238 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer stream.MediaStreamer
|
||||
transcodeDecider stream.TranscodeDecider
|
||||
players core.Players
|
||||
scrobbler scrobbler.PlayTracker
|
||||
playlists playlists.Playlists
|
||||
provider external.Provider
|
||||
sonic sonic.Engine
|
||||
lyrics lyrics.Lyrics
|
||||
broker events.Broker
|
||||
lyricsCache cache.SimpleCache[string, model.LyricList]
|
||||
similarFlight singleflight.Group
|
||||
serverIDMu sync.Mutex
|
||||
serverIDVal string
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
|
||||
transcodeDecider stream.TranscodeDecider, players core.Players,
|
||||
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider,
|
||||
sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router {
|
||||
r := &Router{
|
||||
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
|
||||
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
|
||||
sonic: sonicSvc, lyrics: lyricsSvc, broker: broker,
|
||||
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
|
||||
SizeLimit: 1000,
|
||||
DefaultTTL: 5 * time.Minute,
|
||||
}),
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
||||
func (api *Router) routes() http.Handler {
|
||||
inner := chi.NewRouter()
|
||||
|
||||
// Read query params case-insensitively, like real Jellyfin. Must precede all routes so every
|
||||
// handler and the api_key check see folded keys.
|
||||
inner.Use(normalizeQueryKeys)
|
||||
|
||||
// Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase.
|
||||
|
||||
// Public (no auth): handshake + login.
|
||||
inner.Get("/system/info/public", api.getPublicSystemInfo)
|
||||
inner.Get("/system/ping", api.ping)
|
||||
inner.Post("/system/ping", api.ping)
|
||||
inner.Get("/quickconnect/enabled", api.quickConnectEnabled)
|
||||
// Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated
|
||||
// brute-force surface, so it must share the same per-IP throttle when one is configured.
|
||||
if conf.Server.AuthRequestLimit > 0 {
|
||||
limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
|
||||
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
|
||||
} else {
|
||||
inner.Post("/users/authenticatebyname", api.authenticateByName)
|
||||
}
|
||||
inner.Get("/users/public", api.getPublicUsers)
|
||||
|
||||
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.
|
||||
// Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy,
|
||||
// and an unbounded burst (a client fetching artwork across a large library) can exhaust memory.
|
||||
inner.Group(func(r chi.Router) {
|
||||
r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
|
||||
conf.Server.DevArtworkThrottleBacklogTimeout))
|
||||
r.Get("/items/{itemId}/images/{type}", api.getItemImage)
|
||||
r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage)
|
||||
})
|
||||
|
||||
inner.Group(func(r chi.Router) {
|
||||
r.Use(api.authenticate)
|
||||
// Register/refresh the calling device as a player on every authenticated request, like
|
||||
// Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a
|
||||
// player) even before the first playback report.
|
||||
r.Use(api.withPlayer)
|
||||
r.Get("/system/info", api.getSystemInfo)
|
||||
r.Get("/userviews", api.getUserViews)
|
||||
r.Get("/users/{userId}/views", api.getUserViews)
|
||||
r.Get("/users/me", api.getCurrentUser)
|
||||
r.Get("/users/{userId}", api.getCurrentUser)
|
||||
|
||||
// Cursor-backed collections: each streams straight from the DB, holding a connection for the
|
||||
// whole client-paced response, so enough slow clients would take the entire pool and stall the
|
||||
// scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess
|
||||
// requests queue rather than fail.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams))
|
||||
r.Get("/items", api.getItems)
|
||||
r.Get("/users/{userId}/items", api.getItems)
|
||||
r.Get("/users/{userId}/items/latest", api.getLatest)
|
||||
r.Get("/artists", api.getArtists)
|
||||
r.Get("/artists/albumartists", api.getAlbumArtists)
|
||||
r.Get("/playlists/{playlistId}/items", api.getPlaylistItems)
|
||||
})
|
||||
|
||||
r.Get("/items/{itemId}", api.getItem)
|
||||
r.Get("/users/{userId}/items/{itemId}", api.getItem)
|
||||
r.Delete("/items/{itemId}", api.deleteItem)
|
||||
|
||||
// /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the
|
||||
// /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses.
|
||||
r.Post("/userfavoriteitems/{itemId}", api.markFavorite)
|
||||
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
|
||||
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
|
||||
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
|
||||
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
|
||||
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
|
||||
|
||||
// Per-item play/favorite/rating state. Jellify uses the /UserItems form;
|
||||
// /Users/{userId}/Items is the legacy spelling.
|
||||
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
||||
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
||||
|
||||
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
||||
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
||||
r.Get("/items/{itemId}/instantmix", api.getInstantMix)
|
||||
r.Get("/genres", api.getGenres)
|
||||
r.Get("/musicgenres", api.getGenres)
|
||||
r.Get("/studios", api.getStudios)
|
||||
r.Get("/items/filters", api.getQueryFiltersLegacy)
|
||||
|
||||
r.Post("/playlists", api.createPlaylist)
|
||||
r.Get("/playlists/{playlistId}", api.getPlaylist)
|
||||
r.Post("/playlists/{playlistId}", api.updatePlaylist)
|
||||
r.Post("/playlists/{playlistId}/items", api.addToPlaylist)
|
||||
r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist)
|
||||
r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers)
|
||||
r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser)
|
||||
|
||||
// Cover upload/delete: only playlists are writable (see postItemImage); the GET routes
|
||||
// above stay public.
|
||||
r.Post("/items/{itemId}/images/{type}", api.postItemImage)
|
||||
r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage)
|
||||
|
||||
r.Get("/audio/{itemId}/stream", api.streamAudio)
|
||||
r.Get("/audio/{itemId}/stream.{container}", api.streamAudio)
|
||||
r.Get("/audio/{itemId}/universal", api.streamAudio)
|
||||
r.Get("/audio/{itemId}/main.m3u8", api.streamHls)
|
||||
r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
|
||||
r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
|
||||
r.Get("/audio/{itemId}/lyrics", api.getLyrics)
|
||||
// Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of
|
||||
// /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file.
|
||||
r.Get("/items/{itemId}/file", api.streamFile)
|
||||
r.Get("/items/{itemId}/download", api.streamFile)
|
||||
|
||||
r.Post("/sessions/playing", api.reportPlaybackStart)
|
||||
r.Post("/sessions/playing/progress", api.reportPlaybackProgress)
|
||||
r.Post("/sessions/playing/stopped", api.reportPlaybackStopped)
|
||||
r.Post("/sessions/capabilities", api.postCapabilities)
|
||||
r.Post("/sessions/capabilities/full", api.postCapabilities)
|
||||
|
||||
// Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect.
|
||||
r.Get("/socket", api.handleSocket)
|
||||
|
||||
r.Get("/audiomuseai/info", api.audioMuseInfo)
|
||||
r.Get("/audiomuseai/health", api.audioMuseHealth)
|
||||
r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks)
|
||||
r.Get("/audiomuseai/find_path", api.audioMuseFindPath)
|
||||
})
|
||||
|
||||
// Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected
|
||||
// traffic, and this just surfaces what's missing.
|
||||
inner.NotFound(api.notFound)
|
||||
inner.MethodNotAllowed(api.notFound)
|
||||
|
||||
// Real Jellyfin clients route case-insensitively; chi does not.
|
||||
return caseInsensitivePaths(inner)
|
||||
}
|
||||
|
||||
// ok writes payload as JSON — the single entry point for every handler. Collections are routed to
|
||||
// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped
|
||||
// on any item(s): real Jellyfin always sets it, and it's constant per request.
|
||||
//
|
||||
// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray).
|
||||
func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
switch p := payload.(type) {
|
||||
case itemsResult:
|
||||
api.writeItems(w, r, p)
|
||||
return
|
||||
case dto.QueryResult:
|
||||
api.writeItems(w, r, materialized(p))
|
||||
return
|
||||
case dto.BaseItemDto:
|
||||
p.ServerId = api.serverID(r.Context())
|
||||
payload = p
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
log.Error(r.Context(), "Jellyfin API: error encoding response", err)
|
||||
}
|
||||
}
|
||||
|
||||
// notFound handles unmatched routes and unsupported methods, logging them so unimplemented
|
||||
// endpoints surface instead of returning chi's default plain-text 404/405.
|
||||
func (api *Router) notFound(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
|
||||
// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output,
|
||||
// file paths) never reaches the client.
|
||||
func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Router", func() {
|
||||
It("serves the public handshake through the mounted handler", func() {
|
||||
ds := &tests.MockDataStore{}
|
||||
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
|
||||
api.ServeHTTP(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("returns 404 JSON for unknown routes", func() {
|
||||
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Nonexistent/Route", nil)
|
||||
api.ServeHTTP(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
|
||||
Expect(w.Body.String()).To(Equal("{}"))
|
||||
})
|
||||
|
||||
It("returns 404 JSON for a known path with an unsupported method", func() {
|
||||
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("PATCH", "/System/Info/Public", nil)
|
||||
api.ServeHTTP(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(w.Body.String()).To(Equal("{}"))
|
||||
})
|
||||
|
||||
It("registers a player on a general authenticated request, not just playback reports", func() {
|
||||
ds := &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo)
|
||||
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
|
||||
token, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
fp := &fakePlayers{}
|
||||
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Users/Me", nil)
|
||||
r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Jellify", Device="Phone", DeviceId="dev-1", Version="1.0"`)
|
||||
r.Header.Set("X-Emby-Token", token)
|
||||
api.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(fp.registerCalls).To(Equal(1))
|
||||
Expect(fp.lastClient).To(Equal("Jellify"))
|
||||
})
|
||||
|
||||
It("rate-limits AuthenticateByName by IP when a login limit is configured", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.AuthRequestLimit = 2
|
||||
conf.Server.AuthWindowLength = time.Minute
|
||||
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
login := func() int {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`))
|
||||
r.RemoteAddr = "10.0.0.1:1234"
|
||||
api.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
// The bad credentials would be 401; the limiter cuts in on the 3rd attempt with 429.
|
||||
Expect(login()).To(Equal(http.StatusUnauthorized))
|
||||
Expect(login()).To(Equal(http.StatusUnauthorized))
|
||||
Expect(login()).To(Equal(http.StatusTooManyRequests))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
// audioMuseEndpoints is what /AudioMuseAI/info advertises; it omits info itself, like the plugin,
|
||||
// and is sorted the same way (the plugin builds it with OrderBy).
|
||||
var audioMuseEndpoints = []string{
|
||||
"GET /AudioMuseAI/find_path",
|
||||
"GET /AudioMuseAI/health",
|
||||
"GET /AudioMuseAI/similar_tracks",
|
||||
}
|
||||
|
||||
type audioMuseInfoResponse struct {
|
||||
Version string `json:"Version"`
|
||||
AvailableEndpoints []string `json:"AvailableEndpoints"`
|
||||
}
|
||||
|
||||
func (api *Router) audioMuseInfo(w http.ResponseWriter, r *http.Request) {
|
||||
endpoints := []string{} // non-nil so an empty list serializes as [], not null
|
||||
if api.sonic != nil && api.sonic.HasProvider() {
|
||||
endpoints = audioMuseEndpoints
|
||||
}
|
||||
api.ok(w, r, audioMuseInfoResponse{
|
||||
Version: consts.Version,
|
||||
AvailableEndpoints: endpoints,
|
||||
})
|
||||
}
|
||||
|
||||
// audioMuseHealth is a liveness probe: 200 with an empty body when a sonic provider is loaded, else
|
||||
// 404 — mirroring the reference plugin, which returns 200 when its backend is reachable.
|
||||
func (api *Router) audioMuseHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if api.sonic == nil || !api.sonic.HasProvider() {
|
||||
api.notFound(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
type audioMuseSimilarTrack struct {
|
||||
Author string `json:"author"`
|
||||
Distance float64 `json:"distance"`
|
||||
ItemID string `json:"item_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
// 404 without a provider, like the Subsonic sonicSimilarity handlers.
|
||||
if api.sonic == nil || !api.sonic.HasProvider() {
|
||||
api.notFound(w, r)
|
||||
return
|
||||
}
|
||||
p := req.Params(r)
|
||||
tracks := []audioMuseSimilarTrack{}
|
||||
|
||||
itemID := p.StringOr("item_id", "")
|
||||
if itemID == "" {
|
||||
api.ok(w, r, tracks)
|
||||
return
|
||||
}
|
||||
|
||||
id := api.resolveItemID(ctx, dto.DecodeID(itemID))
|
||||
n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit
|
||||
eliminateDuplicates := p.BoolOr("eliminate_duplicates", true)
|
||||
|
||||
matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, n)
|
||||
if err != nil {
|
||||
api.ok(w, r, tracks)
|
||||
return
|
||||
}
|
||||
|
||||
u, _ := request.UserFrom(ctx)
|
||||
seenArtists := make(map[string]bool, len(matches))
|
||||
for _, m := range matches {
|
||||
mf := m.MediaFile
|
||||
if !u.HasLibraryAccess(mf.LibraryID) {
|
||||
continue
|
||||
}
|
||||
if eliminateDuplicates {
|
||||
key := strings.ToLower(mf.Artist)
|
||||
if seenArtists[key] {
|
||||
continue
|
||||
}
|
||||
seenArtists[key] = true
|
||||
}
|
||||
tracks = append(tracks, audioMuseSimilarTrack{
|
||||
Author: mf.Artist,
|
||||
Distance: m.Similarity,
|
||||
ItemID: dto.EncodeID(mf.ID),
|
||||
Title: mf.Title,
|
||||
})
|
||||
}
|
||||
api.ok(w, r, tracks)
|
||||
}
|
||||
|
||||
type audioMusePathTrack struct {
|
||||
Author string `json:"author"`
|
||||
ItemID string `json:"item_id"`
|
||||
Title string `json:"title"`
|
||||
Tempo *float64 `json:"tempo,omitempty"`
|
||||
}
|
||||
|
||||
type audioMusePathResponse struct {
|
||||
Path []audioMusePathTrack `json:"path"`
|
||||
TotalDistance float64 `json:"total_distance"`
|
||||
}
|
||||
|
||||
func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if api.sonic == nil || !api.sonic.HasProvider() {
|
||||
api.notFound(w, r)
|
||||
return
|
||||
}
|
||||
p := req.Params(r)
|
||||
|
||||
startID := p.StringOr("start_song_id", "")
|
||||
endID := p.StringOr("end_song_id", "")
|
||||
if startID == "" || endID == "" {
|
||||
http.Error(w, "start_song_id and end_song_id are required.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := audioMusePathResponse{Path: []audioMusePathTrack{}}
|
||||
maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count
|
||||
matches, err := api.sonic.FindSonicPath(ctx,
|
||||
api.resolveItemID(ctx, dto.DecodeID(startID)),
|
||||
api.resolveItemID(ctx, dto.DecodeID(endID)),
|
||||
maxSteps)
|
||||
if err != nil {
|
||||
api.ok(w, r, resp)
|
||||
return
|
||||
}
|
||||
|
||||
u, _ := request.UserFrom(ctx)
|
||||
for _, m := range matches {
|
||||
mf := m.MediaFile
|
||||
if !u.HasLibraryAccess(mf.LibraryID) {
|
||||
continue
|
||||
}
|
||||
track := audioMusePathTrack{
|
||||
Author: mf.Artist,
|
||||
ItemID: dto.EncodeID(mf.ID),
|
||||
Title: mf.Title,
|
||||
}
|
||||
if mf.BPM != nil {
|
||||
tempo := float64(*mf.BPM)
|
||||
track.Tempo = &tempo
|
||||
}
|
||||
resp.Path = append(resp.Path, track)
|
||||
resp.TotalDistance += m.Similarity
|
||||
}
|
||||
api.ok(w, r, resp)
|
||||
}
|
||||
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user