mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(scrobbler): add per-user scrobble filter (#5964)
* feat(scrobbler): add scrobble_filter column to user * feat(scrobbler): validate scrobble filter criteria on user save * refactor(persistence): make smart playlist join helpers package-level * feat(scrobbler): add MediaFileRepository.MatchesCriteria * feat(scrobbler): filter external scrobbles with per-user criteria * feat(ui): add scrobble filter field to user form * fix(scrobbler): default scrobble_filter to empty string for existing users * refactor(scrobbler): also gate playback reports on the scrobble filter Playback reports carry the same track metadata to plugin scrobblers, so a filtered track leaked through that third dispatch path. Skip the filter evaluation entirely when no scrobbler is active. * refactor(persistence): move criteria join building into criteria_sql.go The join set a criteria needs was decided in criteria_sql.go but built in smart_playlist_repository.go, so both callers had to pair the two by hand. * refactor(persistence): unexport smartPlaylistCriteria methods The type never leaves the package, so the exported names advertised an API that callers outside persistence could never reach. Also disambiguates where/orderBy from squirrel's SelectBuilder methods of the same name. * fix(ui): cap the scrobble filter field width fullWidth stretched it across the whole page next to 256px inputs. Bounded at 40em, with two rows and a resize handle so JSON rules stay readable. * refactor(ui): move scrobble filter input in UserEdit component * feat(ui): add pt-BR translations for the scrobble filter * fix(scrobbler): take the filter verdict before incPlay incPlay mutates play counts and dates a filter can test on, so evaluating at dispatch time let one play decide differently on either side of the increment: a track could be scrobbled despite matching, or lose only its stopped report and strand presence plugins. Reject limit/offset too, rather than silently ignoring part of a rule copied from a smart playlist. * fix(scrobbler): filter the report from an expired session The expiry callback runs with a stub user carrying no filter, so evaluating there always returned false and leaked the track to plugin scrobblers. That is the normal path for clients that never send stopped, such as legacy Subsonic now-playing. Carry the last verdict on the session instead. * refactor(scrobbler): skip the now-playing enqueue instead of threading the verdict Queuing an entry only to drop it at dispatch also cancelled a pending announcement for the previous, unfiltered track, since the queue is keyed by player and a new entry replaces the old one. * fix(scrobbler): evaluate the filter regardless of active scrobblers The verdict is stored on the session and dispatched at expiry, so skipping evaluation when no scrobbler was active let a plugin enabled mid-session receive a filtered track. The empty-filter guard above already gives servers without scrobbling the same free path, so the shortcut only ever applied to users who had a filter set.
This commit is contained in:
parent
5b87a60b5d
commit
82fde00ecc
@ -2,6 +2,7 @@ package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
@ -11,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
@ -43,6 +45,10 @@ type PlaybackSession struct {
|
||||
PositionMs int64
|
||||
PlaybackRate float64
|
||||
LastReport time.Time
|
||||
|
||||
// Verdict from the last report, for the expiry callback: its context carries only
|
||||
// a stub user, so it cannot evaluate the filter itself.
|
||||
filtered bool
|
||||
}
|
||||
|
||||
type Submission struct {
|
||||
@ -68,8 +74,9 @@ type nowPlayingEntry struct {
|
||||
}
|
||||
|
||||
type playbackReportEntry struct {
|
||||
ctx context.Context
|
||||
info PlaybackSession
|
||||
ctx context.Context
|
||||
info PlaybackSession
|
||||
filtered bool
|
||||
}
|
||||
|
||||
type PlayTracker interface {
|
||||
@ -145,7 +152,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
|
||||
info.State = StateExpired
|
||||
info.LastReport = time.Now()
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
p.enqueuePlaybackReport(ctx, info, info.filtered)
|
||||
}
|
||||
})
|
||||
|
||||
@ -273,6 +280,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// One verdict per report, reused by every dispatch below, so a filter reading
|
||||
// annotations cannot decide differently on either side of incPlay.
|
||||
var filtered bool
|
||||
|
||||
switch params.State {
|
||||
case StateStarting:
|
||||
// Clients may send starting/playing unordered; a late "starting" must not downgrade
|
||||
@ -285,8 +296,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered = p.isFilteredOut(ctx, mf)
|
||||
info := PlaybackSession{
|
||||
MediaFile: *mf,
|
||||
filtered: filtered,
|
||||
Start: now,
|
||||
UserId: user.ID,
|
||||
Username: user.UserName,
|
||||
@ -309,7 +322,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
p.enqueuePlaybackReport(ctx, info, filtered)
|
||||
|
||||
case StatePlaying, StatePaused:
|
||||
info, getErr := p.playMap.Get(clientId)
|
||||
@ -331,6 +344,8 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
info.PositionMs = params.PositionMs
|
||||
info.PlaybackRate = params.PlaybackRate
|
||||
info.LastReport = now
|
||||
filtered = p.isFilteredOut(ctx, &info.MediaFile)
|
||||
info.filtered = filtered
|
||||
ttl := 30 * time.Minute
|
||||
if params.State == StatePlaying {
|
||||
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
|
||||
@ -342,16 +357,19 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
p.enqueuePlaybackReport(ctx, info, filtered)
|
||||
|
||||
case StateStopped:
|
||||
var loadedMF *model.MediaFile
|
||||
haveVerdict := false
|
||||
if !params.IgnoreScrobble && player.ScrobbleEnabled {
|
||||
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
loadedMF = mf
|
||||
filtered = p.isFilteredOut(ctx, mf)
|
||||
haveVerdict = true
|
||||
trackDurationMs := int64(mf.Duration * 1000)
|
||||
threshold := min(trackDurationMs*50/100, 240_000)
|
||||
if params.PositionMs >= threshold {
|
||||
@ -359,7 +377,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
|
||||
}
|
||||
p.dispatchScrobble(ctx, mf, now)
|
||||
p.dispatchScrobble(ctx, mf, now, filtered)
|
||||
}
|
||||
}
|
||||
p.sessionsMu.Lock()
|
||||
@ -397,7 +415,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
}
|
||||
stoppedInfo.MediaFile = *mf
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, stoppedInfo)
|
||||
if !haveVerdict {
|
||||
filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile)
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, stoppedInfo, filtered)
|
||||
}
|
||||
|
||||
if conf.Server.EnableNowPlaying {
|
||||
@ -413,7 +434,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
// scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying.
|
||||
if player.ScrobbleEnabled &&
|
||||
(params.State == StateStarting || params.State == StatePlaying) {
|
||||
if info, err := p.playMap.Get(clientId); err == nil {
|
||||
if filtered {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for filtered track", "mediaId", params.MediaId)
|
||||
} else if info, err := p.playMap.Get(clientId); err == nil {
|
||||
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
|
||||
}
|
||||
}
|
||||
@ -452,6 +475,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
|
||||
log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err)
|
||||
continue
|
||||
}
|
||||
filtered := p.isFilteredOut(ctx, mf)
|
||||
err = p.incPlay(ctx, mf, s.Timestamp)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err)
|
||||
@ -460,7 +484,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
|
||||
event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID)
|
||||
log.Info(ctx, "Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp)
|
||||
if player.ScrobbleEnabled {
|
||||
p.dispatchScrobble(ctx, mf, s.Timestamp)
|
||||
p.dispatchScrobble(ctx, mf, s.Timestamp, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -494,11 +518,36 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) {
|
||||
// Take this verdict before incPlay mutates what a filter reads, and independently of
|
||||
// which scrobblers are active: it can be stored on a session and dispatched much later.
|
||||
// Any parse or query failure fails open, because filtering must not break scrobbling.
|
||||
func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) bool {
|
||||
u, _ := request.UserFrom(ctx)
|
||||
if u.ScrobbleFilter == "" {
|
||||
return false
|
||||
}
|
||||
var c criteria.Criteria
|
||||
if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil {
|
||||
log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err)
|
||||
return false
|
||||
}
|
||||
match, err := p.ds.MediaFile(ctx).MatchesCriteria(t.ID, c)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err)
|
||||
return false
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time, filtered bool) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
if filtered {
|
||||
log.Debug(ctx, "Ignoring external Scrobble for filtered track", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
u, _ := request.UserFrom(ctx)
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@ -46,6 +47,26 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// flipOnPlayRepo reports one filter verdict before the play is counted and another
|
||||
// after, reproducing a filter that reads annotations incPlay mutates.
|
||||
type flipOnPlayRepo struct {
|
||||
model.MediaFileRepository
|
||||
before, after bool
|
||||
played atomic.Bool
|
||||
}
|
||||
|
||||
func (r *flipOnPlayRepo) IncPlayCount(id string, ts time.Time) error {
|
||||
r.played.Store(true)
|
||||
return r.MediaFileRepository.IncPlayCount(id, ts)
|
||||
}
|
||||
|
||||
func (r *flipOnPlayRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) {
|
||||
if r.played.Load() {
|
||||
return r.after, nil
|
||||
}
|
||||
return r.before, nil
|
||||
}
|
||||
|
||||
// slowMediaFileRepo widens the window between a report's session check and its
|
||||
// write, making check-then-write races reproducible.
|
||||
type slowMediaFileRepo struct {
|
||||
@ -320,6 +341,185 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble filter", func() {
|
||||
var repo *tests.MockMediaFileRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1",
|
||||
ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`})
|
||||
repo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
||||
})
|
||||
|
||||
It("does not send a matching track to the agent", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("still increments play counts for a filtered track", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(track.PlayCount).To(Equal(int64(1)))
|
||||
Expect(album.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("sends a non-matching track to the agent", func() {
|
||||
repo.MatchesCriteriaValue = false
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("fails open when evaluation errors", func() {
|
||||
repo.MatchesCriteriaErr = errors.New("boom")
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("fails open when the stored filter is not valid JSON", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{broken`})
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not send now-playing for a filtered track", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("does not send playback reports for a filtered track", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("sends playback reports for a non-matching track", func() {
|
||||
repo.MatchesCriteriaValue = false
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("evaluates the filter even when no scrobbler is active yet", func() {
|
||||
// The verdict is stored on the session and dispatched at expiry, by which
|
||||
// time a plugin scrobbler may have been enabled.
|
||||
tracker.builtinScrobblers = map[string]Scrobbler{}
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StatePlaying, ClientId: "player-12", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
stored, getErr := tracker.playMap.Get("player-12")
|
||||
Expect(getErr).ToNot(HaveOccurred())
|
||||
Expect(stored.filtered).To(BeTrue())
|
||||
})
|
||||
|
||||
Context("when incPlay itself flips the filter", func() {
|
||||
// A filter on playCount or lastPlayed changes verdict the moment incPlay
|
||||
// commits, so the verdict has to be taken before it, not at dispatch time.
|
||||
var flip *flipOnPlayRepo
|
||||
|
||||
install := func(before, after bool) {
|
||||
flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(ctx), before: before, after: after}
|
||||
ds.(*tests.MockDataStore).MockedMediaFile = flip
|
||||
}
|
||||
|
||||
It("does not scrobble a track the filter matched before the play was counted", func() {
|
||||
install(true, false)
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("still sends the stopped report when the filter only starts matching after the play", func() {
|
||||
install(false, true)
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("does not report an expired session for a filtered track", func() {
|
||||
// The expiry callback runs with a stub user, so it cannot evaluate the
|
||||
// filter itself and must reuse the verdict stored on the session.
|
||||
info := PlaybackSession{
|
||||
MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1",
|
||||
PlayerId: "player-9", PlayerName: "test-player", State: StatePlaying, filtered: true,
|
||||
}
|
||||
_ = tracker.playMap.AddWithTTL("player-9", info, 10*time.Millisecond)
|
||||
|
||||
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("still reports an expired session for a track that is not filtered", func() {
|
||||
info := PlaybackSession{
|
||||
MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1",
|
||||
PlayerId: "player-10", PlayerName: "test-player", State: StatePlaying, filtered: false,
|
||||
}
|
||||
_ = tracker.playMap.AddWithTTL("player-10", info, 10*time.Millisecond)
|
||||
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("stores the verdict on the session so expiry can reuse it", func() {
|
||||
repo.MatchesCriteriaValue = true
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", State: StatePlaying, ClientId: "player-11", ClientName: "player"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
stored, getErr := tracker.playMap.Get("player-11")
|
||||
Expect(getErr).ToNot(HaveOccurred())
|
||||
Expect(stored.filtered).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not scrobble a Submit whose filter matched before the play was counted", func() {
|
||||
install(true, false)
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReportPlayback", func() {
|
||||
const defaultClientId = "client-1"
|
||||
|
||||
|
||||
@ -6,13 +6,14 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) {
|
||||
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession, filtered bool) {
|
||||
p.prMu.Lock()
|
||||
defer p.prMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
p.prQueue = append(p.prQueue, playbackReportEntry{
|
||||
ctx: ctx,
|
||||
info: info,
|
||||
ctx: ctx,
|
||||
info: info,
|
||||
filtered: filtered,
|
||||
})
|
||||
p.sendPlaybackReportSignal()
|
||||
}
|
||||
@ -44,12 +45,16 @@ func (p *playTracker) playbackReportWorker() {
|
||||
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
for _, entry := range entries {
|
||||
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers)
|
||||
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers, entry.filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) {
|
||||
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler, filtered bool) {
|
||||
if filtered {
|
||||
log.Debug(ctx, "Ignoring external PlaybackReport for filtered track", "track", info.MediaFile.Title, "state", info.State)
|
||||
return
|
||||
}
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, info.UserId) {
|
||||
continue
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
alter table user add column scrobble_filter varchar default '' not null;
|
||||
|
||||
-- +goose Down
|
||||
alter table user drop column scrobble_filter;
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
@ -548,6 +549,9 @@ type MediaFileRepository interface {
|
||||
// filters as GetAll. Sort/Order are ignored.
|
||||
GetRandom(options ...QueryOptions) (MediaFiles, error)
|
||||
GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
|
||||
// MatchesCriteria reports whether the media file matches the criteria's rule
|
||||
// expression, using the logged user's annotations. Limit and offset are ignored.
|
||||
MatchesCriteria(id string, c criteria.Criteria) (bool, error)
|
||||
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
||||
// GetAllIDs returns just the media_file IDs for the same row set as GetAll.
|
||||
GetAllIDs(options ...QueryOptions) ([]string, error)
|
||||
|
||||
@ -14,6 +14,8 @@ type User struct {
|
||||
LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
// Smart-playlist criteria JSON; matching songs are not sent to external scrobblers
|
||||
ScrobbleFilter string `structs:"scrobble_filter" json:"scrobbleFilter"`
|
||||
|
||||
// Library associations (many-to-many relationship)
|
||||
Libraries Libraries `structs:"-" json:"libraries,omitempty"`
|
||||
|
||||
@ -129,7 +129,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{
|
||||
"random": {order: "random()"},
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
|
||||
func (c smartPlaylistCriteria) where() (squirrel.Sqlizer, error) {
|
||||
if c.Criteria.Expression == nil {
|
||||
return squirrel.Expr("1 = 1"), nil
|
||||
}
|
||||
@ -786,7 +786,7 @@ func fieldJoinType(name string) smartPlaylistJoinType {
|
||||
return field.joinType
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
|
||||
func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType {
|
||||
var joins smartPlaylistJoinType
|
||||
_ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
|
||||
for field := range criteria.Fields(expr) {
|
||||
@ -797,15 +797,50 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
|
||||
return joins
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
|
||||
joins := c.ExpressionJoins()
|
||||
func (c smartPlaylistCriteria) requiredJoins() smartPlaylistJoinType {
|
||||
joins := c.expressionJoins()
|
||||
for _, name := range c.Criteria.SortFieldNames() {
|
||||
joins |= fieldJoinType(name)
|
||||
}
|
||||
return joins
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) OrderBy() string {
|
||||
// applyExpressionJoins adds every join the criteria's WHERE clause resolves against.
|
||||
func (c smartPlaylistCriteria) applyExpressionJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder {
|
||||
return c.applyJoins(sq, c.expressionJoins(), userID)
|
||||
}
|
||||
|
||||
// applyRequiredJoins adds the WHERE joins plus any the ORDER BY resolves against.
|
||||
func (c smartPlaylistCriteria) applyRequiredJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder {
|
||||
return c.applyJoins(sq, c.requiredJoins(), userID)
|
||||
}
|
||||
|
||||
// applyJoins joins the media_file annotation unconditionally — annotation fields
|
||||
// COALESCE a missing row to a default, so the row has to be reachable to be absent.
|
||||
func (c smartPlaylistCriteria) applyJoins(sq squirrel.SelectBuilder, joins smartPlaylistJoinType, userID string) squirrel.SelectBuilder {
|
||||
sq = sq.LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file.id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = ?)", userID)
|
||||
if joins.has(smartPlaylistJoinAlbumAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
|
||||
"album_annotation.item_id = media_file.album_id"+
|
||||
" AND album_annotation.item_type = 'album'"+
|
||||
" AND album_annotation.user_id = ?)", userID)
|
||||
}
|
||||
if joins.has(smartPlaylistJoinArtistAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
|
||||
"artist_annotation.item_id = media_file.artist_id"+
|
||||
" AND artist_annotation.item_type = 'artist'"+
|
||||
" AND artist_annotation.user_id = ?)", userID)
|
||||
}
|
||||
if joins.has(smartPlaylistJoinAlbum) {
|
||||
sq = sq.LeftJoin("album ON album.id = media_file.album_id")
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
func (c smartPlaylistCriteria) orderBy() string {
|
||||
sortFields := c.Criteria.OrderByFields()
|
||||
parts := make([]string, 0, len(sortFields))
|
||||
for _, sf := range sortFields {
|
||||
|
||||
@ -124,7 +124,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.
|
||||
|
||||
// Build the full query matching buildSmartPlaylistQuery + addCriteria
|
||||
sq := squirrel.Select("media_file.id").From("media_file")
|
||||
cond, err := cSQL.Where()
|
||||
cond, err := cSQL.where()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
@ -132,7 +132,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.
|
||||
if expr.Limit > 0 {
|
||||
sq = sq.Limit(uint64(expr.Limit))
|
||||
}
|
||||
if order := cSQL.OrderBy(); order != "" {
|
||||
if order := cSQL.orderBy(); order != "" {
|
||||
sq = sq.OrderBy(order)
|
||||
}
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
|
||||
DescribeTable("expressions",
|
||||
func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -204,7 +204,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
sqlizer, err := newSmartPlaylistCriteria(
|
||||
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
|
||||
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
|
||||
).Where()
|
||||
).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -217,7 +217,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
sqlizer, err := newSmartPlaylistCriteria(
|
||||
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
|
||||
withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
|
||||
).Where()
|
||||
).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -228,7 +228,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
})
|
||||
|
||||
It("builds relative date expressions", func() {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -238,7 +238,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
})
|
||||
|
||||
It("builds negated relative date expressions", func() {
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -248,76 +248,76 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
})
|
||||
|
||||
It("returns an error for unknown fields", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).where()
|
||||
|
||||
Expect(err).To(MatchError("invalid field in criteria: unknown"))
|
||||
})
|
||||
|
||||
It("returns an error when isMissing is used with a regular field", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
|
||||
})
|
||||
|
||||
It("returns an error when isPresent is used with a regular field", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
|
||||
})
|
||||
|
||||
It("returns an error when isMissing has a non-boolean value", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
|
||||
})
|
||||
|
||||
It("returns an error for a range over a tag/role field", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field")))
|
||||
})
|
||||
|
||||
It("returns a clear error for a malformed range value", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where()
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair")))
|
||||
})
|
||||
|
||||
Describe("sort", func() {
|
||||
It("sorts by regular fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).orderBy()).To(Equal("media_file.title asc"))
|
||||
})
|
||||
|
||||
It("sorts by tag fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
|
||||
})
|
||||
|
||||
It("sorts by role fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).orderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
|
||||
})
|
||||
|
||||
It("sorts by albumtype alias", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).orderBy()).To(Equal("random() asc"))
|
||||
})
|
||||
|
||||
It("sorts by album columns bare, with no COALESCE default", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).OrderBy()).
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).orderBy()).
|
||||
To(Equal("album.created_at desc, media_file.track_number asc"))
|
||||
})
|
||||
|
||||
It("sorts by multiple fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).orderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
|
||||
})
|
||||
|
||||
It("reverts order when order is desc", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).orderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
|
||||
})
|
||||
|
||||
It("ignores invalid sort fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc"))
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).orderBy()).To(Equal("media_file.title asc"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -362,7 +362,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.Contains{"artist": "Kraftwerk"},
|
||||
criteria.Contains{"artist": "Pink Floyd"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -376,7 +376,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.Contains{"artist": "Beatles"},
|
||||
criteria.Contains{"composer": "Lennon"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
@ -391,7 +391,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.NotContains{"artist": "Beatles"},
|
||||
criteria.NotContains{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
@ -406,7 +406,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
for i := range anyExprs {
|
||||
anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)}
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -424,7 +424,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.Contains{"artist": "Beatles"},
|
||||
criteria.Contains{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -440,7 +440,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.Contains{"genre": "Metal"},
|
||||
criteria.Contains{"genre": "Punk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -454,7 +454,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.Contains{"genre": "Rock"},
|
||||
criteria.Contains{"mood": "Happy"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
@ -467,7 +467,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.NotContains{"genre": "Rock"},
|
||||
criteria.NotContains{"genre": "Metal"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
@ -482,7 +482,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.Contains{"genre": "Rock"},
|
||||
criteria.Contains{"genre": "Metal"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -499,7 +499,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.IsNot{"artist": "Beatles"},
|
||||
criteria.IsNot{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -515,7 +515,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.NotContains{"artist": "Beatles"},
|
||||
criteria.NotContains{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -530,7 +530,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.NotContains{"genre": "Rock"},
|
||||
criteria.NotContains{"genre": "Metal"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -547,7 +547,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.Contains{"artist": "Beatles"},
|
||||
criteria.IsNot{"artist": "Kraftwerk"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
@ -562,7 +562,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.IsNot{"artist": "Beatles"},
|
||||
criteria.IsNot{"composer": "Lennon"},
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
@ -575,7 +575,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
for i := range allExprs {
|
||||
allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)}
|
||||
}
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where()
|
||||
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).where()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
@ -591,14 +591,14 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}
|
||||
cSQL := newSmartPlaylistCriteria(c)
|
||||
|
||||
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("includes expression-based joins", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
|
||||
|
||||
Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(newSmartPlaylistCriteria(c).expressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects nested album and artist joins", func() {
|
||||
@ -607,7 +607,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
criteria.Any{criteria.Gt{"artistPlayCount": 10}},
|
||||
}}
|
||||
|
||||
joins := newSmartPlaylistCriteria(c).RequiredJoins()
|
||||
joins := newSmartPlaylistCriteria(c).requiredJoins()
|
||||
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
||||
})
|
||||
@ -615,20 +615,20 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
It("detects join types from sort fields with direction prefixes", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"}
|
||||
|
||||
Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
||||
Expect(newSmartPlaylistCriteria(c).requiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("keeps a sort-only album join out of the expression joins", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"}
|
||||
cSQL := newSmartPlaylistCriteria(c)
|
||||
|
||||
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue())
|
||||
Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone))
|
||||
Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("distinguishes the album join from the album annotation join", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
|
||||
joins := newSmartPlaylistCriteria(c).RequiredJoins()
|
||||
joins := newSmartPlaylistCriteria(c).requiredJoins()
|
||||
|
||||
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
|
||||
Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse())
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pocketbase/dbx"
|
||||
@ -517,6 +518,23 @@ var mediaFileSearchConfig = searchConfig{
|
||||
MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"},
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) MatchesCriteria(id string, c criteria.Criteria) (bool, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
rulesSQL := newSmartPlaylistCriteria(c, withSmartPlaylistOwner(*usr))
|
||||
cond, err := rulesSQL.where()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
sq := Select("count(*) as count").From("media_file")
|
||||
sq = rulesSQL.applyExpressionJoins(sq, usr.ID)
|
||||
sq = sq.Where(And{Eq{"media_file.id": id}, cond})
|
||||
var res struct{ Count int64 }
|
||||
if err := r.queryOne(sq, &res); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.Count > 0, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
var opts model.QueryOptions
|
||||
if len(options) > 0 {
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -1129,4 +1130,31 @@ var _ = Describe("MediaRepository", func() {
|
||||
Expect(NewMediaFileRepository(rctx, GetDBXBuilder()).Exists(songAntenna.ID)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MatchesCriteria", func() {
|
||||
It("returns true when the track matches", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}}
|
||||
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(match).To(BeTrue())
|
||||
})
|
||||
It("returns false when the track does not match", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}}
|
||||
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(match).To(BeFalse())
|
||||
})
|
||||
It("treats missing annotations as their COALESCE default", func() {
|
||||
// unrated track: rating coalesces to 0, so "rating < 4" matches
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}}
|
||||
match, err := mr.MatchesCriteria(songDayInALife.ID, c)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(match).To(BeTrue())
|
||||
})
|
||||
It("returns an error for an invalid field", func() {
|
||||
c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}}
|
||||
_, err := mr.MatchesCriteria(songDayInALife.ID, c)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -119,13 +119,11 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ
|
||||
return nil
|
||||
}
|
||||
|
||||
exprJoins := rulesSQL.ExpressionJoins()
|
||||
countSq := Select("count(*) as count").From("media_file")
|
||||
countSq = r.addMediaFileAnnotationJoin(countSq, userID)
|
||||
countSq = r.addSmartPlaylistJoins(countSq, exprJoins, userID)
|
||||
countSq = rulesSQL.applyExpressionJoins(countSq, userID)
|
||||
countSq = r.applyLibraryFilter(countSq, "media_file")
|
||||
|
||||
cond, err := rulesSQL.Where()
|
||||
cond, err := rulesSQL.where()
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
|
||||
return err
|
||||
@ -146,50 +144,18 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ
|
||||
// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria,
|
||||
// including the joins its fields require and library filtering.
|
||||
func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder {
|
||||
orderBy := rulesSQL.OrderBy()
|
||||
orderBy := rulesSQL.orderBy()
|
||||
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
|
||||
From("media_file")
|
||||
sq = r.addMediaFileAnnotationJoin(sq, userID)
|
||||
|
||||
requiredJoins := rulesSQL.RequiredJoins()
|
||||
sq = r.addSmartPlaylistJoins(sq, requiredJoins, userID)
|
||||
sq = rulesSQL.applyRequiredJoins(sq, userID)
|
||||
sq = r.applyLibraryFilter(sq, "media_file")
|
||||
return sq
|
||||
}
|
||||
|
||||
// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include
|
||||
// user-specific annotations in the smart playlist criteria evaluation.
|
||||
func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder {
|
||||
return sq.LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file.id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = ?)", userID)
|
||||
}
|
||||
|
||||
// addSmartPlaylistJoins adds the left joins required by the criteria's fields.
|
||||
func (r *playlistRepository) addSmartPlaylistJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
|
||||
if joins.has(smartPlaylistJoinAlbumAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
|
||||
"album_annotation.item_id = media_file.album_id"+
|
||||
" AND album_annotation.item_type = 'album'"+
|
||||
" AND album_annotation.user_id = ?)", userID)
|
||||
}
|
||||
if joins.has(smartPlaylistJoinArtistAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
|
||||
"artist_annotation.item_id = media_file.artist_id"+
|
||||
" AND artist_annotation.item_type = 'artist'"+
|
||||
" AND artist_annotation.user_id = ?)", userID)
|
||||
}
|
||||
if joins.has(smartPlaylistJoinAlbum) {
|
||||
sq = sq.LeftJoin("album ON album.id = media_file.album_id")
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
// addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the
|
||||
// smart playlist criteria.
|
||||
func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) {
|
||||
cond, err := cSQL.Where()
|
||||
cond, err := cSQL.where()
|
||||
if err != nil {
|
||||
return sql, err
|
||||
}
|
||||
@ -197,7 +163,7 @@ func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCr
|
||||
if cSQL.Criteria.Limit > 0 {
|
||||
sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset))
|
||||
}
|
||||
if order := cSQL.OrderBy(); order != "" {
|
||||
if order := cSQL.orderBy(); order != "" {
|
||||
sql = sql.OrderBy(order)
|
||||
}
|
||||
return sql, nil
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@ -252,6 +253,9 @@ func (r *userRepository) Save(entity any) (string, error) {
|
||||
if err := validateUsernameUnique(r, u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateScrobbleFilter(u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
err := r.Put(u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -284,6 +288,9 @@ func (r *userRepository) Update(id string, entity any, _ ...string) error {
|
||||
if err := validateUsernameUnique(r, u); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateScrobbleFilter(u); err != nil {
|
||||
return err
|
||||
}
|
||||
err := r.Put(u)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
@ -331,6 +338,33 @@ func validateUsernameUnique(r model.UserRepository, u *model.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateScrobbleFilter(u *model.User) error {
|
||||
u.ScrobbleFilter = strings.TrimSpace(u.ScrobbleFilter)
|
||||
if u.ScrobbleFilter == "" {
|
||||
return nil
|
||||
}
|
||||
var c criteria.Criteria
|
||||
if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil {
|
||||
return invalidScrobbleFilter()
|
||||
}
|
||||
// A filter is a per-track test, so a result-set size means nothing here. Reject it
|
||||
// rather than silently ignoring part of a rule copied from a smart playlist.
|
||||
if c.Limit > 0 || c.LimitPercent > 0 || c.Offset > 0 || c.RefreshDelay > 0 {
|
||||
return invalidScrobbleFilter()
|
||||
}
|
||||
// Building the WHERE clause is what validates field names and operators
|
||||
if _, err := newSmartPlaylistCriteria(c).where(); err != nil {
|
||||
return invalidScrobbleFilter()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidScrobbleFilter() error {
|
||||
return &rest.ValidationError{Errors: map[string]string{
|
||||
"scrobbleFilter": "resources.user.validation.invalidScrobbleFilter",
|
||||
}}
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(id string) error {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
|
||||
@ -70,6 +70,26 @@ var _ = Describe("UserRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(actual.Password).To(Equal("newpass"))
|
||||
})
|
||||
It("persists and reads back the scrobble filter", func() {
|
||||
usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User",
|
||||
ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`}
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
saved, err := repo.Get("u-filter")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`))
|
||||
})
|
||||
It("reads back a user row inserted without scrobble_filter", func() {
|
||||
// Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable
|
||||
_, err := GetDBXBuilder().NewQuery(
|
||||
"insert into user (id, user_name, name, email, password, created_at, updated_at) " +
|
||||
"values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
saved, err := repo.Get("u-rawsql")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(saved.ScrobbleFilter).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("validatePasswordChange", func() {
|
||||
@ -607,6 +627,52 @@ var _ = Describe("UserRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("validateScrobbleFilter", func() {
|
||||
It("accepts an empty filter", func() {
|
||||
u := &model.User{}
|
||||
Expect(validateScrobbleFilter(u)).To(Succeed())
|
||||
})
|
||||
It("trims a whitespace-only filter to empty", func() {
|
||||
u := &model.User{ScrobbleFilter: " "}
|
||||
Expect(validateScrobbleFilter(u)).To(Succeed())
|
||||
Expect(u.ScrobbleFilter).To(Equal(""))
|
||||
})
|
||||
It("accepts valid criteria JSON", func() {
|
||||
u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`}
|
||||
Expect(validateScrobbleFilter(u)).To(Succeed())
|
||||
})
|
||||
It("rejects malformed JSON", func() {
|
||||
u := &model.User{ScrobbleFilter: `{not json`}
|
||||
var vErr *rest.ValidationError
|
||||
err := validateScrobbleFilter(u)
|
||||
Expect(errors.As(err, &vErr)).To(BeTrue())
|
||||
Expect(vErr.Errors).To(HaveKey("scrobbleFilter"))
|
||||
})
|
||||
It("rejects criteria without rules", func() {
|
||||
u := &model.User{ScrobbleFilter: `{"sort":"title"}`}
|
||||
Expect(validateScrobbleFilter(u)).ToNot(Succeed())
|
||||
})
|
||||
It("rejects selection options that mean nothing for a single track", func() {
|
||||
for _, f := range []string{
|
||||
`{"all":[{"lt":{"rating":4}}],"limit":100}`,
|
||||
`{"all":[{"lt":{"rating":4}}],"limitPercent":10}`,
|
||||
`{"all":[{"lt":{"rating":4}}],"offset":5}`,
|
||||
`{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`,
|
||||
} {
|
||||
u := &model.User{ScrobbleFilter: f}
|
||||
Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f)
|
||||
}
|
||||
})
|
||||
It("accepts a sort, which cannot change a single-track match", func() {
|
||||
u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`}
|
||||
Expect(validateScrobbleFilter(u)).To(Succeed())
|
||||
})
|
||||
It("rejects unknown fields", func() {
|
||||
u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`}
|
||||
Expect(validateScrobbleFilter(u)).ToNot(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("filters", func() {
|
||||
It("qualifies id filter with table name", func() {
|
||||
r := repo.(*userRepository)
|
||||
|
||||
@ -155,11 +155,13 @@
|
||||
"newPassword": "Nova Senha",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Últ. Acesso",
|
||||
"libraries": "Bibliotecas"
|
||||
"libraries": "Bibliotecas",
|
||||
"scrobbleFilter": "Filtro de scrobble"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Alterações no seu nome só serão refletidas no próximo login",
|
||||
"libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão"
|
||||
"libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão",
|
||||
"scrobbleFilter": "Músicas que correspondem a estas regras de playlist inteligente não são enviadas ao Last.fm, ListenBrainz ou plugins de scrobble. Usa a mesma sintaxe JSON e o mesmo comportamento das playlists inteligentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixe vazio para enviar scrobbles de todas as músicas. As contagens de reprodução locais não são afetadas."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Novo usuário criado",
|
||||
@ -173,7 +175,8 @@
|
||||
"adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores"
|
||||
"librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores",
|
||||
"invalidScrobbleFilter": "Devem ser regras válidas de playlist inteligente. Limite, deslocamento e intervalo de atualização não são suportados."
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
@ -31,6 +32,8 @@ type MockMediaFileRepo struct {
|
||||
// Add fields for cross-library move detection tests
|
||||
FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
|
||||
FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
|
||||
MatchesCriteriaValue bool
|
||||
MatchesCriteriaErr error
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) SetError(err bool) {
|
||||
@ -369,5 +372,12 @@ func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) {
|
||||
if m.MatchesCriteriaErr != nil {
|
||||
return false, m.MatchesCriteriaErr
|
||||
}
|
||||
return m.MatchesCriteriaValue, nil
|
||||
}
|
||||
|
||||
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)
|
||||
var _ model.ResourceRepository = (*MockMediaFileRepo)(nil)
|
||||
|
||||
@ -155,11 +155,13 @@
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"token": "Token",
|
||||
"libraries": "Libraries"
|
||||
"libraries": "Libraries",
|
||||
"scrobbleFilter": "Scrobble filter"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Changes to your name will only be reflected on next login",
|
||||
"libraries": "Select specific libraries for this user, or leave empty to use default libraries"
|
||||
"libraries": "Select specific libraries for this user, or leave empty to use default libraries",
|
||||
"scrobbleFilter": "Songs matching these smart playlist rules are not sent to Last.fm, ListenBrainz or scrobbler plugins. Uses the same JSON syntax and behavior as smart playlists. Example: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leave empty to scrobble everything. Local play counts are not affected."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "User created",
|
||||
@ -167,7 +169,8 @@
|
||||
"deleted": "User deleted"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "At least one library must be selected for non-admin users"
|
||||
"librariesRequired": "At least one library must be selected for non-admin users",
|
||||
"invalidScrobbleFilter": "Must be valid smart playlist rules. Limit, offset and refresh delay are not supported."
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Enter your ListenBrainz user token.",
|
||||
|
||||
@ -172,6 +172,17 @@ const UserEdit = (props) => {
|
||||
</FormDataConsumer>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
source="scrobbleFilter"
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
spellCheck={false}
|
||||
style={{ maxWidth: '40em' }}
|
||||
inputProps={{ style: { resize: 'vertical' } }}
|
||||
helperText={translate('resources.user.helperTexts.scrobbleFilter')}
|
||||
/>
|
||||
|
||||
<DateField variant="body1" source="lastLoginAt" showTime />
|
||||
<DateField variant="body1" source="lastAccessAt" showTime />
|
||||
<DateField variant="body1" source="updatedAt" showTime />
|
||||
|
||||
@ -127,6 +127,12 @@ describe('<UserEdit />', () => {
|
||||
expect(screen.getByTestId('date-field-createdAt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the scrobble filter input', () => {
|
||||
render(<UserEdit id="user1" permissions="admin" />)
|
||||
|
||||
expect(screen.getByTestId('text-input-scrobbleFilter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render username input for non-admin users', () => {
|
||||
render(<UserEdit id="user1" permissions="user" />)
|
||||
|
||||
|
||||
@ -15,5 +15,15 @@ export const validateUserForm = (values, translate) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (values.scrobbleFilter && values.scrobbleFilter.trim() !== '') {
|
||||
try {
|
||||
JSON.parse(values.scrobbleFilter)
|
||||
} catch {
|
||||
errors.scrobbleFilter = translate(
|
||||
'resources.user.validation.invalidScrobbleFilter',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
@ -67,4 +67,27 @@ describe('User Validation Utilities', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrobbleFilter validation', () => {
|
||||
it('accepts an empty filter', () => {
|
||||
const errors = validateUserForm({ isAdmin: true }, mockTranslate)
|
||||
expect(errors.scrobbleFilter).toBeUndefined()
|
||||
})
|
||||
it('accepts valid JSON', () => {
|
||||
const errors = validateUserForm(
|
||||
{ isAdmin: true, scrobbleFilter: '{"all":[{"lt":{"rating":4}}]}' },
|
||||
mockTranslate,
|
||||
)
|
||||
expect(errors.scrobbleFilter).toBeUndefined()
|
||||
})
|
||||
it('rejects malformed JSON', () => {
|
||||
const errors = validateUserForm(
|
||||
{ isAdmin: true, scrobbleFilter: '{broken' },
|
||||
mockTranslate,
|
||||
)
|
||||
expect(errors.scrobbleFilter).toEqual(
|
||||
'resources.user.validation.invalidScrobbleFilter',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user