mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into subsonic-folder
This commit is contained in:
commit
efc2e8b76d
@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
return err == nil && sk != ""
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
|
||||
@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
|
||||
@ -39,7 +39,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
|
||||
// Bug 2 variant: cover.* basenames tie across album-root and per-disc folders;
|
||||
// compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder
|
||||
// files first. Flip from PIt to It once it prefers shorter/parent paths.
|
||||
// files first.
|
||||
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
@ -50,7 +50,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.jpg
|
||||
// └── cover.jpg ← should win (album-root fallback)
|
||||
PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() {
|
||||
It("prefers the album-root cover over per-disc covers", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
@ -71,7 +71,6 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
// Bug 2: folder.jpg basenames tie across album-root and per-disc folders;
|
||||
// the lexicographic full-path tiebreaker in compareImageFiles ranks
|
||||
// "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg".
|
||||
// Flip from PIt to It once compareImageFiles prefers shorter/parent paths.
|
||||
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
@ -82,7 +81,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── folder.jpg ← should win (album-root fallback)
|
||||
PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() {
|
||||
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
@ -100,15 +99,14 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
|
||||
// Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder
|
||||
// lookup whenever an album lives entirely under a single subfolder, so an
|
||||
// album-root cover is never considered. Flip from PIt to It once the guard
|
||||
// accepts single-folder albums whose parent isn't already in the folder set.
|
||||
// album-root cover is never considered.
|
||||
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── disc1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug)
|
||||
PIt("uses the parent-folder cover (currently ignored — bug)", func() {
|
||||
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
||||
|
||||
@ -118,19 +118,22 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
folderIDSet[id] = true
|
||||
}
|
||||
|
||||
// For multi-disc albums (2+ folders), check if all folders share a common parent
|
||||
// that is not already included. This finds cover art in the album root folder
|
||||
// (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/").
|
||||
// We skip single-folder albums to avoid pulling images from the artist folder.
|
||||
// Check if all folders share a common parent that is not already included.
|
||||
// This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg"
|
||||
// when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/").
|
||||
// For single-folder albums, the parent is only included when the folder has no
|
||||
// images of its own (indicating a disc subfolder needing parent artwork).
|
||||
if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" {
|
||||
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
} else if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if parentFolder != nil {
|
||||
folders = append(folders, *parentFolder)
|
||||
if len(folders) >= 2 || !anyFolderHasImages(folders) {
|
||||
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
} else if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if parentFolder != nil && parentFolder.Path != "." {
|
||||
folders = append(folders, *parentFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,10 +159,19 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
return paths, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
func anyFolderHasImages(folders []model.Folder) bool {
|
||||
for _, f := range folders {
|
||||
if len(f.ImageFiles) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// commonParentFolder returns the shared parent folder ID when all folders have the
|
||||
// same parent and that parent is not already in folderIDSet. Returns "" otherwise.
|
||||
func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string {
|
||||
if len(folders) < 2 {
|
||||
if len(folders) == 0 {
|
||||
return ""
|
||||
}
|
||||
parentID := folders[0].ParentID
|
||||
@ -174,11 +186,8 @@ func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) str
|
||||
return parentID
|
||||
}
|
||||
|
||||
// compareImageFiles compares two image file paths for sorting.
|
||||
// It extracts the base filename (without extension) and compares case-insensitively.
|
||||
// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1".
|
||||
// Note: This function is called O(n log n) times during sorting, but in practice albums
|
||||
// typically have only 1-20 image files, making the repeated string operations negligible.
|
||||
// compareImageFiles sorts image paths by: base filename (natural order),
|
||||
// then path depth (shallower first), then full path (stable tiebreaker).
|
||||
func compareImageFiles(a, b string) int {
|
||||
// Case-insensitive comparison
|
||||
a = strings.ToLower(a)
|
||||
@ -188,9 +197,10 @@ func compareImageFiles(a, b string) int {
|
||||
baseA := strings.TrimSuffix(path.Base(a), path.Ext(a))
|
||||
baseB := strings.TrimSuffix(path.Base(b), path.Ext(b))
|
||||
|
||||
// Compare base names first, then full paths if equal
|
||||
// Compare base names first, then prefer shallower paths, then full path as tiebreaker
|
||||
return cmp.Or(
|
||||
natural.Compare(baseA, baseB),
|
||||
cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")),
|
||||
natural.Compare(a, b),
|
||||
)
|
||||
}
|
||||
|
||||
@ -213,9 +213,42 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("does not query parent for single-folder albums", func() {
|
||||
// A single-folder album's parent is typically the artist folder,
|
||||
// which should not be searched for cover art
|
||||
It("does not include top-level parent for multi-folder albums", func() {
|
||||
// Two album parts under the same artist folder — parent is artist-level
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: ".",
|
||||
Name: "AlbumPart1",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: ".",
|
||||
Name: "AlbumPart2",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "artistFolder",
|
||||
Path: ".",
|
||||
Name: "Artist",
|
||||
ImageFiles: []string{"artist.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not query parent for single-folder albums that already have images", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
@ -232,10 +265,37 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
// Get should not have been called (single folder, no parent lookup)
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("includes parent images for single-disc-subfolder albums", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "disc1",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "albumFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
|
||||
@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
|
||||
s, ok := b.loader()
|
||||
if !ok {
|
||||
return errors.New("scrobbler not available")
|
||||
}
|
||||
return s.PlaybackReport(ctx, info)
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) sendWakeSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
|
||||
@ -23,6 +23,7 @@ type Scrobbler interface {
|
||||
IsAuthorized(ctx context.Context, userId string) bool
|
||||
NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
|
||||
Scrobble(ctx context.Context, userId string, s Scrobble) error
|
||||
PlaybackReport(ctx context.Context, info PlaybackSession) error
|
||||
}
|
||||
|
||||
type Constructor func(ds model.DataStore) Scrobbler
|
||||
|
||||
78
core/scrobbler/nowplaying_worker.go
Normal file
78
core/scrobbler/nowplaying_worker.go
Normal file
@ -0,0 +1,78 @@
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
|
||||
p.npMu.Lock()
|
||||
defer p.npMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
|
||||
p.npQueue[playerId] = nowPlayingEntry{
|
||||
ctx: ctx,
|
||||
userId: userId,
|
||||
track: track,
|
||||
position: position,
|
||||
}
|
||||
p.sendNowPlayingSignal()
|
||||
}
|
||||
|
||||
func (p *playTracker) sendNowPlayingSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
case p.npSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) nowPlayingWorker() {
|
||||
defer close(p.workerDone)
|
||||
for {
|
||||
select {
|
||||
case <-p.shutdown:
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
case <-p.npSignal:
|
||||
}
|
||||
|
||||
p.npMu.Lock()
|
||||
if len(p.npQueue) == 0 {
|
||||
p.npMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep a copy of the entries to process and clear the queue
|
||||
entries := p.npQueue
|
||||
p.npQueue = make(map[string]nowPlayingEntry)
|
||||
p.npMu.Unlock()
|
||||
|
||||
// Process entries without holding lock
|
||||
for _, entry := range entries {
|
||||
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, userId) {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
|
||||
err := s.NowPlaying(ctx, userId, t, position)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ const (
|
||||
StatePlaying = "playing"
|
||||
StatePaused = "paused"
|
||||
StateStopped = "stopped"
|
||||
StateExpired = "expired"
|
||||
)
|
||||
|
||||
var ValidStates = map[string]bool{
|
||||
@ -31,9 +32,10 @@ var ValidStates = map[string]bool{
|
||||
StateStopped: true,
|
||||
}
|
||||
|
||||
type NowPlayingInfo struct {
|
||||
type PlaybackSession struct {
|
||||
MediaFile model.MediaFile
|
||||
Start time.Time
|
||||
UserId string
|
||||
Username string
|
||||
PlayerId string
|
||||
PlayerName string
|
||||
@ -65,8 +67,13 @@ type nowPlayingEntry struct {
|
||||
position int
|
||||
}
|
||||
|
||||
type playbackReportEntry struct {
|
||||
ctx context.Context
|
||||
info PlaybackSession
|
||||
}
|
||||
|
||||
type PlayTracker interface {
|
||||
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
|
||||
GetNowPlaying(ctx context.Context) ([]PlaybackSession, error)
|
||||
Submit(ctx context.Context, submissions []Submission) error
|
||||
ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
|
||||
}
|
||||
@ -81,7 +88,7 @@ type PluginLoader interface {
|
||||
type playTracker struct {
|
||||
ds model.DataStore
|
||||
broker events.Broker
|
||||
playMap cache.SimpleCache[string, NowPlayingInfo]
|
||||
playMap cache.SimpleCache[string, PlaybackSession]
|
||||
builtinScrobblers map[string]Scrobbler
|
||||
pluginScrobblers map[string]Scrobbler
|
||||
pluginLoader PluginLoader
|
||||
@ -91,6 +98,10 @@ type playTracker struct {
|
||||
npSignal chan struct{}
|
||||
shutdown chan struct{}
|
||||
workerDone chan struct{}
|
||||
prQueue []playbackReportEntry
|
||||
prMu sync.Mutex
|
||||
prSignal chan struct{}
|
||||
prWorkerDone chan struct{}
|
||||
}
|
||||
|
||||
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
|
||||
@ -106,7 +117,7 @@ func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
}
|
||||
|
||||
func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
|
||||
m := cache.NewSimpleCache[string, NowPlayingInfo]()
|
||||
m := cache.NewSimpleCache[string, PlaybackSession]()
|
||||
p := &playTracker{
|
||||
ds: ds,
|
||||
playMap: m,
|
||||
@ -118,12 +129,24 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
npSignal: make(chan struct{}, 1),
|
||||
shutdown: make(chan struct{}),
|
||||
workerDone: make(chan struct{}),
|
||||
prSignal: make(chan struct{}, 1),
|
||||
prWorkerDone: make(chan struct{}),
|
||||
}
|
||||
if conf.Server.EnableNowPlaying {
|
||||
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
|
||||
enableNowPlaying := conf.Server.EnableNowPlaying
|
||||
m.OnExpiration(func(_ string, info PlaybackSession) {
|
||||
log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state",
|
||||
info.State, "username", info.Username, "userId", info.UserId)
|
||||
if enableNowPlaying {
|
||||
broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username})
|
||||
if info.State != StateStopped {
|
||||
log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
|
||||
info.State = StateExpired
|
||||
info.LastReport = time.Now()
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
}
|
||||
})
|
||||
|
||||
var enabled []string
|
||||
for name, constructor := range constructors {
|
||||
@ -138,13 +161,15 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
}
|
||||
log.Debug("List of builtin scrobblers enabled", "names", enabled)
|
||||
go p.nowPlayingWorker()
|
||||
go p.playbackReportWorker()
|
||||
return p
|
||||
}
|
||||
|
||||
// stopNowPlayingWorker stops the background worker. This is primarily for testing.
|
||||
func (p *playTracker) stopNowPlayingWorker() {
|
||||
// stopBackgroundWorkers stops the background workers. This is primarily for testing.
|
||||
func (p *playTracker) stopBackgroundWorkers() {
|
||||
close(p.shutdown)
|
||||
<-p.workerDone // Wait for worker to finish
|
||||
<-p.workerDone // Wait for nowPlaying worker to finish
|
||||
<-p.prWorkerDone // Wait for playbackReport worker to finish
|
||||
}
|
||||
|
||||
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers.
|
||||
@ -247,9 +272,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info := NowPlayingInfo{
|
||||
info := PlaybackSession{
|
||||
MediaFile: *mf,
|
||||
Start: now,
|
||||
UserId: user.ID,
|
||||
Username: user.UserName,
|
||||
PlayerId: clientId,
|
||||
PlayerName: client,
|
||||
@ -260,8 +286,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
}
|
||||
err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
|
||||
case StatePlaying, StatePaused:
|
||||
info, getErr := p.playMap.Get(clientId)
|
||||
@ -270,9 +297,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info = NowPlayingInfo{
|
||||
info = PlaybackSession{
|
||||
MediaFile: *mf,
|
||||
Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
|
||||
UserId: user.ID,
|
||||
Username: user.UserName,
|
||||
PlayerId: clientId,
|
||||
PlayerName: client,
|
||||
@ -286,17 +314,21 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
if params.State == StatePlaying {
|
||||
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)
|
||||
err := p.playMap.AddWithTTL(clientId, info, ttl)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, info)
|
||||
|
||||
case StateStopped:
|
||||
var loadedMF *model.MediaFile
|
||||
if !params.IgnoreScrobble && player.ScrobbleEnabled {
|
||||
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
loadedMF = mf
|
||||
trackDurationMs := int64(mf.Duration * 1000)
|
||||
threshold := min(trackDurationMs*50/100, 240_000)
|
||||
if params.PositionMs >= threshold {
|
||||
@ -307,6 +339,31 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
p.dispatchScrobble(ctx, mf, now)
|
||||
}
|
||||
}
|
||||
stoppedInfo := PlaybackSession{
|
||||
UserId: user.ID,
|
||||
Username: user.UserName,
|
||||
PlayerId: clientId,
|
||||
PlayerName: client,
|
||||
State: params.State,
|
||||
PositionMs: params.PositionMs,
|
||||
PlaybackRate: params.PlaybackRate,
|
||||
LastReport: now,
|
||||
}
|
||||
if info, getErr := p.playMap.Get(clientId); getErr == nil {
|
||||
stoppedInfo.MediaFile = info.MediaFile
|
||||
stoppedInfo.Start = info.Start
|
||||
} else {
|
||||
mf := loadedMF
|
||||
if mf == nil {
|
||||
var mfErr error
|
||||
mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
|
||||
if mfErr != nil {
|
||||
return mfErr
|
||||
}
|
||||
}
|
||||
stoppedInfo.MediaFile = *mf
|
||||
}
|
||||
p.enqueuePlaybackReport(ctx, stoppedInfo)
|
||||
p.playMap.Remove(clientId)
|
||||
}
|
||||
|
||||
@ -324,77 +381,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
|
||||
p.npMu.Lock()
|
||||
defer p.npMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
|
||||
p.npQueue[playerId] = nowPlayingEntry{
|
||||
ctx: ctx,
|
||||
userId: userId,
|
||||
track: track,
|
||||
position: position,
|
||||
}
|
||||
p.sendNowPlayingSignal()
|
||||
}
|
||||
|
||||
func (p *playTracker) sendNowPlayingSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
case p.npSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) nowPlayingWorker() {
|
||||
defer close(p.workerDone)
|
||||
for {
|
||||
select {
|
||||
case <-p.shutdown:
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
case <-p.npSignal:
|
||||
}
|
||||
|
||||
p.npMu.Lock()
|
||||
if len(p.npQueue) == 0 {
|
||||
p.npMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep a copy of the entries to process and clear the queue
|
||||
entries := p.npQueue
|
||||
p.npQueue = make(map[string]nowPlayingEntry)
|
||||
p.npMu.Unlock()
|
||||
|
||||
// Process entries without holding lock
|
||||
for _, entry := range entries {
|
||||
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, userId) {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
|
||||
err := s.NowPlaying(ctx, userId, t, position)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
|
||||
func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
|
||||
res := p.playMap.Values()
|
||||
slices.SortFunc(res, func(a, b NowPlayingInfo) int {
|
||||
slices.SortFunc(res, func(a, b PlaybackSession) int {
|
||||
return b.Start.Compare(a.Start)
|
||||
})
|
||||
for i := range res {
|
||||
|
||||
@ -48,7 +48,7 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
|
||||
var _ = Describe("PlayTracker", func() {
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
var tracker PlayTracker
|
||||
var tracker *playTracker
|
||||
var eventBroker *fakeEventBroker
|
||||
var track model.MediaFile
|
||||
var album model.Album
|
||||
@ -71,7 +71,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
eventBroker = &fakeEventBroker{}
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
|
||||
tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests
|
||||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
@ -96,12 +96,12 @@ var _ = Describe("PlayTracker", func() {
|
||||
|
||||
AfterEach(func() {
|
||||
// Stop the worker goroutine to prevent data races between tests
|
||||
tracker.(*playTracker).stopNowPlayingWorker()
|
||||
tracker.stopBackgroundWorkers()
|
||||
})
|
||||
|
||||
It("does not register disabled scrobblers", func() {
|
||||
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
|
||||
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
|
||||
Expect(tracker.builtinScrobblers).To(HaveKey("fake"))
|
||||
Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled"))
|
||||
})
|
||||
|
||||
Describe("GetNowPlaying", func() {
|
||||
@ -138,8 +138,8 @@ var _ = Describe("PlayTracker", func() {
|
||||
|
||||
Describe("Expiration events", func() {
|
||||
It("sends event when entry expires", func() {
|
||||
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
|
||||
_ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
|
||||
info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
|
||||
_ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
|
||||
Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0))
|
||||
eventList := eventBroker.getEvents()
|
||||
evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount)
|
||||
@ -150,10 +150,48 @@ var _ = Describe("PlayTracker", func() {
|
||||
It("does not send event when disabled", func() {
|
||||
conf.Server.EnableNowPlaying = false
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
|
||||
_ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
|
||||
info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
|
||||
_ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
|
||||
Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("sends expired playback report when session expires", func() {
|
||||
info := PlaybackSession{
|
||||
MediaFile: track,
|
||||
Start: time.Now(),
|
||||
UserId: "u-1",
|
||||
Username: "user",
|
||||
PlayerId: "player-3",
|
||||
PlayerName: "test-player",
|
||||
State: StatePlaying,
|
||||
PositionMs: 5000,
|
||||
}
|
||||
_ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond)
|
||||
Eventually(func() *PlaybackSession {
|
||||
return fake.LastPlaybackReport.Load()
|
||||
}).ShouldNot(BeNil())
|
||||
report := fake.LastPlaybackReport.Load()
|
||||
Expect(report.State).To(Equal(StateExpired))
|
||||
Expect(report.MediaFile.ID).To(Equal("123"))
|
||||
Expect(report.PlayerId).To(Equal("player-3"))
|
||||
})
|
||||
|
||||
It("does not send expired report when session was already stopped", func() {
|
||||
info := PlaybackSession{
|
||||
MediaFile: track,
|
||||
Start: time.Now(),
|
||||
UserId: "u-1",
|
||||
Username: "user",
|
||||
PlayerId: "player-4",
|
||||
PlayerName: "test-player",
|
||||
State: StateStopped,
|
||||
PositionMs: 180000,
|
||||
}
|
||||
_ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond)
|
||||
Consistently(func() *PlaybackSession {
|
||||
return fake.LastPlaybackReport.Load()
|
||||
}).Should(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Submit", func() {
|
||||
@ -375,7 +413,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
BeforeEach(func() {
|
||||
eventBroker = &fakeEventBroker{}
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = fake
|
||||
tracker.builtinScrobblers["fake"] = fake
|
||||
})
|
||||
|
||||
It("broadcasts NowPlayingCount on every state change", func() {
|
||||
@ -420,7 +458,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
It("does NOT broadcast when EnableNowPlaying is false", func() {
|
||||
conf.Server.EnableNowPlaying = false
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = fake
|
||||
tracker.builtinScrobblers["fake"] = fake
|
||||
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
@ -697,6 +735,96 @@ var _ = Describe("PlayTracker", func() {
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PlaybackReport dispatch", func() {
|
||||
It("dispatches PlaybackReport for starting state", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
|
||||
ClientId: "client-1", ClientName: "Test Player",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() bool {
|
||||
return fake.PlaybackReportCalled.Load()
|
||||
}).Should(BeTrue())
|
||||
|
||||
info := fake.LastPlaybackReport.Load()
|
||||
Expect(info).ToNot(BeNil())
|
||||
Expect(info.MediaFile.ID).To(Equal("123"))
|
||||
Expect(info.State).To(Equal(StateStarting))
|
||||
Expect(info.PositionMs).To(Equal(int64(0)))
|
||||
Expect(info.PlaybackRate).To(Equal(1.0))
|
||||
Expect(info.PlayerId).To(Equal("client-1"))
|
||||
Expect(info.PlayerName).To(Equal("Test Player"))
|
||||
})
|
||||
|
||||
It("dispatches PlaybackReport for playing state", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
|
||||
ClientId: "client-1", ClientName: "Test Player",
|
||||
})
|
||||
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: 30000, State: StatePlaying, PlaybackRate: 1.5,
|
||||
ClientId: "client-1", ClientName: "Test Player",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
info := fake.LastPlaybackReport.Load()
|
||||
Expect(info.State).To(Equal(StatePlaying))
|
||||
Expect(info.PositionMs).To(Equal(int64(30000)))
|
||||
Expect(info.PlaybackRate).To(Equal(1.5))
|
||||
})
|
||||
|
||||
It("dispatches PlaybackReport for paused state", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
|
||||
ClientId: "client-1", ClientName: "Test Player",
|
||||
})
|
||||
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: 45000, State: StatePaused, PlaybackRate: 1.0,
|
||||
ClientId: "client-1", ClientName: "Test Player",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
info := fake.LastPlaybackReport.Load()
|
||||
Expect(info.State).To(Equal(StatePaused))
|
||||
Expect(info.PositionMs).To(Equal(int64(45000)))
|
||||
})
|
||||
|
||||
It("dispatches PlaybackReport for stopped state", func() {
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
|
||||
ClientId: "client-1", ClientName: "Test Player",
|
||||
})
|
||||
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: StateStopped, PlaybackRate: 1.0,
|
||||
ClientId: "client-1", ClientName: "Test Player",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
||||
info := fake.LastPlaybackReport.Load()
|
||||
Expect(info.State).To(Equal(StateStopped))
|
||||
Expect(info.PositionMs).To(Equal(int64(100000)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin scrobbler logic", func() {
|
||||
@ -712,8 +840,8 @@ var _ = Describe("PlayTracker", func() {
|
||||
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
|
||||
|
||||
// Bypass buffering for both built-in and plugin scrobblers
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = fake
|
||||
tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
|
||||
tracker.builtinScrobblers["fake"] = fake
|
||||
tracker.pluginScrobblers["plugin1"] = pluginFake
|
||||
})
|
||||
|
||||
It("registers and uses plugin scrobbler for NowPlaying", func() {
|
||||
@ -830,7 +958,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
pTracker.stopNowPlayingWorker()
|
||||
pTracker.stopBackgroundWorkers()
|
||||
})
|
||||
|
||||
It("uses the new plugin instance after reload (simulating config update)", func() {
|
||||
@ -937,15 +1065,17 @@ var _ = DescribeTable("remainingTTL",
|
||||
)
|
||||
|
||||
type fakeScrobbler struct {
|
||||
Authorized bool
|
||||
nowPlayingCalled atomic.Bool
|
||||
ScrobbleCalled atomic.Bool
|
||||
userID atomic.Pointer[string]
|
||||
username atomic.Pointer[string]
|
||||
track atomic.Pointer[model.MediaFile]
|
||||
position atomic.Int32
|
||||
LastScrobble atomic.Pointer[Scrobble]
|
||||
Error error
|
||||
Authorized bool
|
||||
nowPlayingCalled atomic.Bool
|
||||
ScrobbleCalled atomic.Bool
|
||||
PlaybackReportCalled atomic.Bool
|
||||
userID atomic.Pointer[string]
|
||||
username atomic.Pointer[string]
|
||||
track atomic.Pointer[model.MediaFile]
|
||||
position atomic.Int32
|
||||
LastScrobble atomic.Pointer[Scrobble]
|
||||
LastPlaybackReport atomic.Pointer[PlaybackSession]
|
||||
Error error
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) GetNowPlayingCalled() bool {
|
||||
@ -998,6 +1128,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
|
||||
f.PlaybackReportCalled.Store(true)
|
||||
if f.Error != nil {
|
||||
return f.Error
|
||||
}
|
||||
uid := info.UserId
|
||||
f.userID.Store(&uid)
|
||||
f.LastPlaybackReport.Store(&info)
|
||||
return nil
|
||||
}
|
||||
|
||||
func _p(id, name string, sortName ...string) model.Participant {
|
||||
p := model.Participant{Artist: model.Artist{ID: id, Name: name}}
|
||||
if len(sortName) > 0 {
|
||||
@ -1053,3 +1194,7 @@ func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, t
|
||||
func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
|
||||
return m.wrapped.Scrobble(ctx, userId, s)
|
||||
}
|
||||
|
||||
func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
|
||||
return m.wrapped.PlaybackReport(ctx, info)
|
||||
}
|
||||
|
||||
64
core/scrobbler/playbackreport_worker.go
Normal file
64
core/scrobbler/playbackreport_worker.go
Normal file
@ -0,0 +1,64 @@
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) {
|
||||
p.prMu.Lock()
|
||||
defer p.prMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
p.prQueue = append(p.prQueue, playbackReportEntry{
|
||||
ctx: ctx,
|
||||
info: info,
|
||||
})
|
||||
p.sendPlaybackReportSignal()
|
||||
}
|
||||
|
||||
func (p *playTracker) sendPlaybackReportSignal() {
|
||||
select {
|
||||
case p.prSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) playbackReportWorker() {
|
||||
defer close(p.prWorkerDone)
|
||||
for {
|
||||
select {
|
||||
case <-p.shutdown:
|
||||
return
|
||||
case <-p.prSignal:
|
||||
}
|
||||
|
||||
p.prMu.Lock()
|
||||
if len(p.prQueue) == 0 {
|
||||
p.prMu.Unlock()
|
||||
continue
|
||||
}
|
||||
entries := p.prQueue
|
||||
p.prQueue = nil
|
||||
p.prMu.Unlock()
|
||||
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
for _, entry := range entries {
|
||||
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) {
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, info.UserId) {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs)
|
||||
err := s.PlaybackReport(ctx, info)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
18
go.mod
18
go.mod
@ -1,6 +1,6 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.26.0
|
||||
go 1.26
|
||||
|
||||
// Fork to implement raw tags support
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a
|
||||
@ -35,12 +35,12 @@ require (
|
||||
github.com/kardianos/service v1.2.4
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.42
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.2
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/onsi/ginkgo/v2 v2.28.3
|
||||
github.com/onsi/gomega v1.40.0
|
||||
github.com/pelletier/go-toml/v2 v2.3.1
|
||||
github.com/pmezard/go-difflib v1.0.0
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
@ -70,7 +70,7 @@ require (
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
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
|
||||
@ -81,7 +81,7 @@ require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
@ -93,7 +93,7 @@ require (
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
@ -138,7 +138,7 @@ require (
|
||||
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.2 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
|
||||
32
go.sum
32
go.sum
@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga
|
||||
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M=
|
||||
github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg=
|
||||
github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0=
|
||||
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
@ -175,8 +175,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.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
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=
|
||||
@ -193,12 +193,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps=
|
||||
github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4=
|
||||
github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
|
||||
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@ -409,8 +409,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
|
||||
gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
@ -8,6 +8,7 @@ type FieldInfo struct {
|
||||
IsTag bool
|
||||
IsRole bool
|
||||
Numeric bool
|
||||
Boolean bool
|
||||
|
||||
tagAlias string // If set, a tag name from mappings.yml that resolves to this field
|
||||
name string // Canonical name, populated by LookupField from the map key
|
||||
@ -21,7 +22,7 @@ func (f FieldInfo) Name() string {
|
||||
var fieldMap = map[string]FieldInfo{
|
||||
"title": {},
|
||||
"album": {},
|
||||
"hascoverart": {},
|
||||
"hascoverart": {Boolean: true},
|
||||
"tracknumber": {},
|
||||
"discnumber": {},
|
||||
"year": {},
|
||||
@ -31,8 +32,8 @@ var fieldMap = map[string]FieldInfo{
|
||||
"releaseyear": {},
|
||||
"releasedate": {},
|
||||
"size": {},
|
||||
"compilation": {},
|
||||
"missing": {},
|
||||
"compilation": {Boolean: true},
|
||||
"missing": {Boolean: true},
|
||||
"explicitstatus": {},
|
||||
"dateadded": {},
|
||||
"datemodified": {},
|
||||
@ -54,7 +55,7 @@ var fieldMap = map[string]FieldInfo{
|
||||
"samplerate": {},
|
||||
"bpm": {},
|
||||
"channels": {},
|
||||
"loved": {},
|
||||
"loved": {Boolean: true},
|
||||
"dateloved": {},
|
||||
"lastplayed": {},
|
||||
"daterated": {},
|
||||
@ -62,13 +63,13 @@ var fieldMap = map[string]FieldInfo{
|
||||
"rating": {},
|
||||
"averagerating": {Numeric: true},
|
||||
"albumrating": {},
|
||||
"albumloved": {},
|
||||
"albumloved": {Boolean: true},
|
||||
"albumplaycount": {},
|
||||
"albumlastplayed": {},
|
||||
"albumdateloved": {},
|
||||
"albumdaterated": {},
|
||||
"artistrating": {},
|
||||
"artistloved": {},
|
||||
"artistloved": {Boolean: true},
|
||||
"artistplaycount": {},
|
||||
"artistlastplayed": {},
|
||||
"artistdateloved": {},
|
||||
|
||||
@ -52,5 +52,6 @@ var _ = Describe("fields", func() {
|
||||
gomega.Expect(field.Name()).To(gomega.Equal("task3_producer"))
|
||||
gomega.Expect(field.IsRole).To(gomega.BeTrue())
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@ package criteria
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -38,6 +39,7 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
normalizeBoolFields(m)
|
||||
switch opName {
|
||||
case "is":
|
||||
return Is(m)
|
||||
@ -70,13 +72,47 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
|
||||
case "notinplaylist":
|
||||
return NotInPlaylist(m)
|
||||
case "ismissing":
|
||||
normalizeAllBoolFields(m)
|
||||
return IsMissing(m)
|
||||
case "ispresent":
|
||||
normalizeAllBoolFields(m)
|
||||
return IsPresent(m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAllBoolFields(m map[string]any) {
|
||||
for k, v := range m {
|
||||
m[k] = normalizeBoolValue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBoolFields(m map[string]any) {
|
||||
for field, value := range m {
|
||||
info, ok := LookupField(field)
|
||||
if ok && info.Boolean {
|
||||
m[field] = normalizeBoolValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBoolValue(v any) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
if b, err := strconv.ParseBool(val); err == nil {
|
||||
return b
|
||||
}
|
||||
case float64:
|
||||
if val == 1 {
|
||||
return true
|
||||
}
|
||||
if val == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
|
||||
var items unmarshalConjunctionType
|
||||
err := json.Unmarshal(rawValue, &items)
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
|
||||
type conjunction interface {
|
||||
@ -181,20 +178,6 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) {
|
||||
|
||||
func (ip IsPresent) fields() map[string]any { return ip }
|
||||
|
||||
func IsTruthy(v any) bool {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case float64:
|
||||
return val != 0
|
||||
case string:
|
||||
b, err := strconv.ParseBool(val)
|
||||
return err == nil && b
|
||||
default:
|
||||
return v != nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractPlaylistIds(inputRule any) (ids []string) {
|
||||
var id string
|
||||
var ok bool
|
||||
|
||||
@ -31,6 +31,7 @@ var _ = Describe("Operators", func() {
|
||||
},
|
||||
Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`),
|
||||
Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`),
|
||||
Entry("is [string does not coerce non-boolean field]", Is{"title": "true"}, `{"is":{"title":"true"}}`),
|
||||
Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`),
|
||||
Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`),
|
||||
Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`),
|
||||
@ -51,4 +52,78 @@ var _ = Describe("Operators", func() {
|
||||
Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`),
|
||||
Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`),
|
||||
)
|
||||
|
||||
Describe("Boolean string coercion at unmarshal time (issue #4826)", func() {
|
||||
It("coerces string 'true' to bool for boolean fields", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"is":{"loved":"true"}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true}))
|
||||
})
|
||||
|
||||
It("coerces string 'false' to bool for boolean fields", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"is":{"loved":"false"}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false}))
|
||||
})
|
||||
|
||||
It("does not coerce string values for non-boolean fields", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"is":{"title":"true"}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(Is{"title": "true"}))
|
||||
})
|
||||
|
||||
It("coerces numeric 1 to bool true for boolean fields", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"is":{"loved":1}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true}))
|
||||
})
|
||||
|
||||
It("coerces numeric 0 to bool false for boolean fields", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"is":{"loved":0}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false}))
|
||||
})
|
||||
|
||||
It("coerces in nested any/all groups", func() {
|
||||
var c Criteria
|
||||
err := json.Unmarshal([]byte(`{"all":[{"contains":{"title":"love"}},{"any":[{"is":{"loved":"true"}}]}]}`), &c)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
all := c.Expression.(All)
|
||||
nested := all[1].(Any)
|
||||
gomega.Expect(nested[0]).To(gomega.Equal(Is{"loved": true}))
|
||||
})
|
||||
|
||||
It("coerces isMissing string 'true' to bool", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"isMissing":{"genre":"true"}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": true}))
|
||||
})
|
||||
|
||||
It("coerces isMissing numeric 0 to bool false", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"isMissing":{"genre":0}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": false}))
|
||||
})
|
||||
|
||||
It("coerces isPresent string 'false' to bool", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"isPresent":{"genre":"false"}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": false}))
|
||||
})
|
||||
|
||||
It("coerces isPresent numeric 1 to bool true", func() {
|
||||
var obj UnmarshalConjunctionType
|
||||
err := json.Unmarshal([]byte(`[{"isPresent":{"genre":1}}]`), &obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -220,7 +220,11 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er
|
||||
return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field)
|
||||
}
|
||||
|
||||
negate := checkAbsence == criteria.IsTruthy(value)
|
||||
b, ok := value.(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value)
|
||||
}
|
||||
negate := checkAbsence == b
|
||||
return jsonExpr(info, nil, negate), nil
|
||||
}
|
||||
|
||||
|
||||
@ -149,6 +149,11 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
||||
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields")))
|
||||
})
|
||||
|
||||
It("returns an error when isMissing has a non-boolean value", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where()
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
|
||||
})
|
||||
|
||||
Describe("sort", func() {
|
||||
It("sorts by regular fields", func() {
|
||||
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
|
||||
|
||||
@ -281,6 +281,71 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
|
||||
|
||||
Expect(pls.Tracks).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("matches loved tracks when loved value is a string in nested group (issue #4826)", func() {
|
||||
// songComeTogether (ID "1002") is starred in test fixtures
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Any{
|
||||
criteria.Is{"loved": "true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
trackIDs := make([]string, len(pls.Tracks))
|
||||
for i, t := range pls.Tracks {
|
||||
trackIDs[i] = t.MediaFileID
|
||||
}
|
||||
Expect(trackIDs).To(ContainElement("1002"))
|
||||
Expect(len(pls.Tracks)).To(BeNumerically(">=", 1))
|
||||
})
|
||||
|
||||
It("returns same results for string and bool loved values (issue #4826)", func() {
|
||||
boolRules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Any{
|
||||
criteria.Is{"loved": true},
|
||||
},
|
||||
},
|
||||
}
|
||||
boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules}
|
||||
Expect(repo.Put(&boolPls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(boolPls.ID) })
|
||||
|
||||
stringRules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Any{
|
||||
criteria.Is{"loved": "true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules}
|
||||
Expect(repo.Put(&stringPls)).To(Succeed())
|
||||
testPlaylistID = stringPls.ID
|
||||
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
boolResult, err := repo.GetWithTracks(boolPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
stringResult, err := repo.GetWithTracks(stringPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
boolIDs := make([]string, len(boolResult.Tracks))
|
||||
for i, t := range boolResult.Tracks {
|
||||
boolIDs[i] = t.MediaFileID
|
||||
}
|
||||
stringIDs := make([]string, len(stringResult.Tracks))
|
||||
for i, t := range stringResult.Tracks {
|
||||
stringIDs[i] = t.MediaFileID
|
||||
}
|
||||
Expect(stringIDs).To(ConsistOf(boolIDs))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlists with Tag Criteria", func() {
|
||||
|
||||
@ -5,7 +5,7 @@ package capabilities
|
||||
// ListenBrainz, or custom scrobbling backends.
|
||||
//
|
||||
// All methods are required - plugins implementing this capability must provide
|
||||
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
|
||||
// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
|
||||
//
|
||||
//nd:capability name=scrobbler required=true
|
||||
type Scrobbler interface {
|
||||
@ -20,6 +20,10 @@ type Scrobbler interface {
|
||||
// Scrobble submits a completed scrobble to the scrobbling service.
|
||||
//nd:export name=nd_scrobbler_scrobble
|
||||
Scrobble(ScrobbleRequest) error
|
||||
|
||||
// PlaybackReport sends a playback state report to the scrobbling service.
|
||||
//nd:export name=nd_scrobbler_playback_report
|
||||
PlaybackReport(PlaybackReportRequest) error
|
||||
}
|
||||
|
||||
// IsAuthorizedRequest is the request for authorization check.
|
||||
@ -96,6 +100,26 @@ type ScrobbleRequest struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// PlaybackReportRequest is the request for playback report notifications.
|
||||
type PlaybackReportRequest struct {
|
||||
// Username is the username of the user.
|
||||
Username string `json:"username"`
|
||||
// Track is the track being played.
|
||||
Track TrackInfo `json:"track"`
|
||||
// State is the current playback state (starting/playing/paused/stopped/expired).
|
||||
State string `json:"state"`
|
||||
// PositionMs is the current playback position in milliseconds.
|
||||
PositionMs int64 `json:"positionMs"`
|
||||
// PlaybackRate is the playback speed (1.0 = normal).
|
||||
PlaybackRate float64 `json:"playbackRate"`
|
||||
// PlayerId is the unique client identifier.
|
||||
PlayerId string `json:"playerId"`
|
||||
// PlayerName is the human-readable player name.
|
||||
PlayerName string `json:"playerName"`
|
||||
// Timestamp is the Unix timestamp when this report was generated.
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ScrobblerError represents an error type for scrobbling operations.
|
||||
type ScrobblerError string
|
||||
|
||||
|
||||
@ -18,6 +18,11 @@ exports:
|
||||
input:
|
||||
$ref: '#/components/schemas/ScrobbleRequest'
|
||||
contentType: application/json
|
||||
nd_scrobbler_playback_report:
|
||||
description: PlaybackReport sends a playback state report to the scrobbling service.
|
||||
input:
|
||||
$ref: '#/components/schemas/PlaybackReportRequest'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
ArtistRef:
|
||||
@ -59,6 +64,45 @@ components:
|
||||
- username
|
||||
- track
|
||||
- position
|
||||
PlaybackReportRequest:
|
||||
description: PlaybackReportRequest is the request for playback report notifications.
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: Username is the username of the user.
|
||||
track:
|
||||
$ref: '#/components/schemas/TrackInfo'
|
||||
description: Track is the track being played.
|
||||
state:
|
||||
type: string
|
||||
description: State is the current playback state (starting/playing/paused/stopped/expired).
|
||||
positionMs:
|
||||
type: integer
|
||||
format: int64
|
||||
description: PositionMs is the current playback position in milliseconds.
|
||||
playbackRate:
|
||||
type: number
|
||||
format: float
|
||||
description: PlaybackRate is the playback speed (1.0 = normal).
|
||||
playerId:
|
||||
type: string
|
||||
description: PlayerId is the unique client identifier.
|
||||
playerName:
|
||||
type: string
|
||||
description: PlayerName is the human-readable player name.
|
||||
timestamp:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Timestamp is the Unix timestamp when this report was generated.
|
||||
required:
|
||||
- username
|
||||
- track
|
||||
- state
|
||||
- positionMs
|
||||
- playbackRate
|
||||
- playerId
|
||||
- playerName
|
||||
- timestamp
|
||||
ScrobbleRequest:
|
||||
description: ScrobbleRequest is the request for submitting a scrobble.
|
||||
properties:
|
||||
|
||||
@ -52,6 +52,26 @@ type NowPlayingRequest struct {
|
||||
Position int32 `json:"position"`
|
||||
}
|
||||
|
||||
// PlaybackReportRequest is the request for playback report notifications.
|
||||
type PlaybackReportRequest struct {
|
||||
// Username is the username of the user.
|
||||
Username string `json:"username"`
|
||||
// Track is the track being played.
|
||||
Track TrackInfo `json:"track"`
|
||||
// State is the current playback state (starting/playing/paused/stopped/expired).
|
||||
State string `json:"state"`
|
||||
// PositionMs is the current playback position in milliseconds.
|
||||
PositionMs int64 `json:"positionMs"`
|
||||
// PlaybackRate is the playback speed (1.0 = normal).
|
||||
PlaybackRate float64 `json:"playbackRate"`
|
||||
// PlayerId is the unique client identifier.
|
||||
PlayerId string `json:"playerId"`
|
||||
// PlayerName is the human-readable player name.
|
||||
PlayerName string `json:"playerName"`
|
||||
// Timestamp is the Unix timestamp when this report was generated.
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ScrobbleRequest is the request for submitting a scrobble.
|
||||
type ScrobbleRequest struct {
|
||||
// Username is the username of the user.
|
||||
@ -106,7 +126,7 @@ type TrackInfo struct {
|
||||
// ListenBrainz, or custom scrobbling backends.
|
||||
//
|
||||
// All methods are required - plugins implementing this capability must provide
|
||||
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
|
||||
// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
|
||||
type Scrobbler interface {
|
||||
// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
|
||||
IsAuthorized(IsAuthorizedRequest) (bool, error)
|
||||
@ -114,11 +134,14 @@ type Scrobbler interface {
|
||||
NowPlaying(NowPlayingRequest) error
|
||||
// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
|
||||
Scrobble(ScrobbleRequest) error
|
||||
// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service.
|
||||
PlaybackReport(PlaybackReportRequest) error
|
||||
} // Internal implementation holders
|
||||
var (
|
||||
isAuthorizedImpl func(IsAuthorizedRequest) (bool, error)
|
||||
nowPlayingImpl func(NowPlayingRequest) error
|
||||
scrobbleImpl func(ScrobbleRequest) error
|
||||
isAuthorizedImpl func(IsAuthorizedRequest) (bool, error)
|
||||
nowPlayingImpl func(NowPlayingRequest) error
|
||||
scrobbleImpl func(ScrobbleRequest) error
|
||||
playbackReportImpl func(PlaybackReportRequest) error
|
||||
)
|
||||
|
||||
// Register registers a scrobbler implementation.
|
||||
@ -127,6 +150,7 @@ func Register(impl Scrobbler) {
|
||||
isAuthorizedImpl = impl.IsAuthorized
|
||||
nowPlayingImpl = impl.NowPlaying
|
||||
scrobbleImpl = impl.Scrobble
|
||||
playbackReportImpl = impl.PlaybackReport
|
||||
}
|
||||
|
||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||
@ -201,3 +225,24 @@ func _NdScrobblerScrobble() int32 {
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
//go:wasmexport nd_scrobbler_playback_report
|
||||
func _NdScrobblerPlaybackReport() int32 {
|
||||
if playbackReportImpl == nil {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
|
||||
var input PlaybackReportRequest
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
if err := playbackReportImpl(input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -49,6 +49,26 @@ type NowPlayingRequest struct {
|
||||
Position int32 `json:"position"`
|
||||
}
|
||||
|
||||
// PlaybackReportRequest is the request for playback report notifications.
|
||||
type PlaybackReportRequest struct {
|
||||
// Username is the username of the user.
|
||||
Username string `json:"username"`
|
||||
// Track is the track being played.
|
||||
Track TrackInfo `json:"track"`
|
||||
// State is the current playback state (starting/playing/paused/stopped/expired).
|
||||
State string `json:"state"`
|
||||
// PositionMs is the current playback position in milliseconds.
|
||||
PositionMs int64 `json:"positionMs"`
|
||||
// PlaybackRate is the playback speed (1.0 = normal).
|
||||
PlaybackRate float64 `json:"playbackRate"`
|
||||
// PlayerId is the unique client identifier.
|
||||
PlayerId string `json:"playerId"`
|
||||
// PlayerName is the human-readable player name.
|
||||
PlayerName string `json:"playerName"`
|
||||
// Timestamp is the Unix timestamp when this report was generated.
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ScrobbleRequest is the request for submitting a scrobble.
|
||||
type ScrobbleRequest struct {
|
||||
// Username is the username of the user.
|
||||
@ -103,7 +123,7 @@ type TrackInfo struct {
|
||||
// ListenBrainz, or custom scrobbling backends.
|
||||
//
|
||||
// All methods are required - plugins implementing this capability must provide
|
||||
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
|
||||
// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
|
||||
type Scrobbler interface {
|
||||
// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
|
||||
IsAuthorized(IsAuthorizedRequest) (bool, error)
|
||||
@ -111,6 +131,8 @@ type Scrobbler interface {
|
||||
NowPlaying(NowPlayingRequest) error
|
||||
// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
|
||||
Scrobble(ScrobbleRequest) error
|
||||
// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service.
|
||||
PlaybackReport(PlaybackReportRequest) error
|
||||
}
|
||||
|
||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||
|
||||
@ -62,6 +62,35 @@ pub struct NowPlayingRequest {
|
||||
#[serde(default)]
|
||||
pub position: i32,
|
||||
}
|
||||
/// PlaybackReportRequest is the request for playback report notifications.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaybackReportRequest {
|
||||
/// Username is the username of the user.
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
/// Track is the track being played.
|
||||
#[serde(default)]
|
||||
pub track: TrackInfo,
|
||||
/// State is the current playback state (starting/playing/paused/stopped/expired).
|
||||
#[serde(default)]
|
||||
pub state: String,
|
||||
/// PositionMs is the current playback position in milliseconds.
|
||||
#[serde(default)]
|
||||
pub position_ms: i64,
|
||||
/// PlaybackRate is the playback speed (1.0 = normal).
|
||||
#[serde(default)]
|
||||
pub playback_rate: f64,
|
||||
/// PlayerId is the unique client identifier.
|
||||
#[serde(default)]
|
||||
pub player_id: String,
|
||||
/// PlayerName is the human-readable player name.
|
||||
#[serde(default)]
|
||||
pub player_name: String,
|
||||
/// Timestamp is the Unix timestamp when this report was generated.
|
||||
#[serde(default)]
|
||||
pub timestamp: i64,
|
||||
}
|
||||
/// ScrobbleRequest is the request for submitting a scrobble.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -158,7 +187,7 @@ impl Error {
|
||||
/// ListenBrainz, or custom scrobbling backends.
|
||||
///
|
||||
/// All methods are required - plugins implementing this capability must provide
|
||||
/// all three functions: IsAuthorized, NowPlaying, and Scrobble.
|
||||
/// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
|
||||
pub trait Scrobbler {
|
||||
/// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
|
||||
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error>;
|
||||
@ -166,6 +195,8 @@ pub trait Scrobbler {
|
||||
fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>;
|
||||
/// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
|
||||
fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error>;
|
||||
/// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service.
|
||||
fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
/// Register all exports for the Scrobbler capability.
|
||||
@ -197,5 +228,13 @@ macro_rules! register_scrobbler {
|
||||
$crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?;
|
||||
Ok(())
|
||||
}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn nd_scrobbler_playback_report(
|
||||
req: extism_pdk::Json<$crate::scrobbler::PlaybackReportRequest>
|
||||
) -> extism_pdk::FnResult<()> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
$crate::scrobbler::Scrobbler::playback_report(&plugin, req.into_inner())?;
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
@ -16,9 +17,10 @@ const CapabilityScrobbler Capability = "Scrobbler"
|
||||
|
||||
// Scrobbler function names (snake_case as per design)
|
||||
const (
|
||||
FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized"
|
||||
FuncScrobblerNowPlaying = "nd_scrobbler_now_playing"
|
||||
FuncScrobblerScrobble = "nd_scrobbler_scrobble"
|
||||
FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized"
|
||||
FuncScrobblerNowPlaying = "nd_scrobbler_now_playing"
|
||||
FuncScrobblerScrobble = "nd_scrobbler_scrobble"
|
||||
FuncScrobblerPlaybackReport = "nd_scrobbler_playback_report"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@ -27,6 +29,7 @@ func init() {
|
||||
FuncScrobblerIsAuthorized,
|
||||
FuncScrobblerNowPlaying,
|
||||
FuncScrobblerScrobble,
|
||||
FuncScrobblerPlaybackReport,
|
||||
)
|
||||
}
|
||||
|
||||
@ -182,5 +185,25 @@ func mapScrobblerError(err error) error {
|
||||
}
|
||||
}
|
||||
|
||||
// PlaybackReport sends a playback state report to the scrobbler
|
||||
func (s *ScrobblerPlugin) PlaybackReport(ctx context.Context, info scrobbler.PlaybackSession) error {
|
||||
input := capabilities.PlaybackReportRequest{
|
||||
Username: info.Username,
|
||||
Track: mediaFileToTrackInfo(s.plugin, &info.MediaFile),
|
||||
State: info.State,
|
||||
PositionMs: info.PositionMs,
|
||||
PlaybackRate: info.PlaybackRate,
|
||||
PlayerId: info.PlayerId,
|
||||
PlayerName: info.PlayerName,
|
||||
Timestamp: info.LastReport.Unix(),
|
||||
}
|
||||
|
||||
err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerPlaybackReport, input)
|
||||
if errors.Is(err, errFunctionNotFound) || errors.Is(err, errNotImplemented) {
|
||||
return nil
|
||||
}
|
||||
return mapScrobblerError(err)
|
||||
}
|
||||
|
||||
// Verify interface implementation at compile time
|
||||
var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil)
|
||||
|
||||
@ -229,6 +229,62 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PlaybackReport", func() {
|
||||
It("successfully calls the plugin", func() {
|
||||
info := scrobbler.PlaybackSession{
|
||||
MediaFile: model.MediaFile{
|
||||
ID: "track-1",
|
||||
Title: "Test Song",
|
||||
Album: "Test Album",
|
||||
Artist: "Test Artist",
|
||||
AlbumArtist: "Test Album Artist",
|
||||
Duration: 180,
|
||||
TrackNumber: 1,
|
||||
DiscNumber: 1,
|
||||
Participants: model.Participants{
|
||||
model.RoleArtist: {{Artist: model.Artist{ID: "artist-1", Name: "Test Artist"}}},
|
||||
model.RoleAlbumArtist: {{Artist: model.Artist{ID: "album-artist-1", Name: "Test Album Artist"}}},
|
||||
},
|
||||
},
|
||||
Username: "testuser",
|
||||
PlayerId: "player-1",
|
||||
PlayerName: "Test Player",
|
||||
State: "playing",
|
||||
PositionMs: 30000,
|
||||
PlaybackRate: 1.0,
|
||||
LastReport: time.Now(),
|
||||
}
|
||||
|
||||
err := s.PlaybackReport(ctxWithUser(), info)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Context("when plugin returns error", Ordered, func() {
|
||||
var retryScrobbler scrobbler.Scrobbler
|
||||
|
||||
BeforeAll(func() {
|
||||
mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-scrobbler": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"},
|
||||
}, "test-scrobbler"+PackageExtension)
|
||||
|
||||
var ok bool
|
||||
retryScrobbler, ok = mgr.LoadScrobbler("test-scrobbler")
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater", func() {
|
||||
info := scrobbler.PlaybackSession{
|
||||
MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"},
|
||||
State: "playing",
|
||||
LastReport: time.Now(),
|
||||
}
|
||||
err := retryScrobbler.PlaybackReport(ctxWithUser(), info)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PluginNames", func() {
|
||||
It("returns plugin names with Scrobbler capability", func() {
|
||||
names := scrobblerManager.PluginNames("Scrobbler")
|
||||
|
||||
14
plugins/testdata/test-scrobbler/main.go
vendored
14
plugins/testdata/test-scrobbler/main.go
vendored
@ -53,6 +53,20 @@ func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlaybackReport receives a playback state report.
|
||||
func (t *testScrobbler) PlaybackReport(input scrobbler.PlaybackReportRequest) error {
|
||||
if err := checkConfigError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
artistName := ""
|
||||
if len(input.Track.Artists) > 0 {
|
||||
artistName = input.Track.Artists[0].Name
|
||||
}
|
||||
pdk.Log(pdk.LogInfo, "PlaybackReport: "+input.Track.Title+" by "+artistName+" state="+input.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkConfigError checks if the plugin is configured to return an error.
|
||||
// If "error" config is set, it returns the appropriate ScrobblerError.
|
||||
// Error types: "not_authorized", "retry_later", "unrecoverable"
|
||||
|
||||
@ -74,23 +74,22 @@ var _ = Describe("Watcher", func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
})
|
||||
|
||||
It("creates separate targets for different folders", func() {
|
||||
It("creates separate targets for different folders", FlakeAttempts(3), func() {
|
||||
// Send notifications for different folders
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"}
|
||||
|
||||
// Wait for watcher to process and trigger scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
// Wait for a scan that collected both targets
|
||||
Eventually(func() []model.ScanTarget {
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return calls[0].Targets
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2))
|
||||
|
||||
// Verify two targets
|
||||
// Verify targets
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].Targets).To(HaveLen(2))
|
||||
|
||||
// Extract folder paths
|
||||
folderPaths := make(map[string]bool)
|
||||
for _, target := range calls[0].Targets {
|
||||
Expect(target.LibraryID).To(Equal(1))
|
||||
@ -107,7 +106,7 @@ var _ = Describe("Watcher", func() {
|
||||
// Wait for watcher to process and trigger scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Verify the target
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
@ -117,20 +116,15 @@ var _ = Describe("Watcher", func() {
|
||||
})
|
||||
|
||||
It("deduplicates folder and file within same folder", func() {
|
||||
// Send notification for a folder
|
||||
// Send multiple notifications for the same folder
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Send notification for same folder (as if file change was detected there)
|
||||
// In practice, watchLibrary() would walk up from file path to folder
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Send another for same folder
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
||||
|
||||
// Wait for watcher to process and trigger scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Verify only one target despite multiple file/folder changes
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
@ -151,32 +145,27 @@ var _ = Describe("Watcher", func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
})
|
||||
|
||||
It("resets timer on each change (debouncing)", func() {
|
||||
It("resets timer on each change (debouncing)", FlakeAttempts(3), func() {
|
||||
// Send first notification
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
||||
|
||||
// Wait a bit less than half the watcher wait time to ensure timer doesn't fire
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// No scan should have been triggered yet
|
||||
Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
|
||||
// Verify no scan fires during a window shorter than the debounce wait
|
||||
Consistently(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0))
|
||||
|
||||
// Send another notification (resets timer)
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
||||
|
||||
// Wait a bit less than half the watcher wait time again
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
// Again, no scan should fire within a short window
|
||||
Consistently(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0))
|
||||
|
||||
// Still no scan
|
||||
Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
|
||||
|
||||
// Wait for full timer to expire after last notification (plus margin)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
// Now scan should have been triggered
|
||||
// Now wait for the debounce timer to expire and trigger scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
})
|
||||
|
||||
It("triggers scan after quiet period", func() {
|
||||
@ -189,7 +178,7 @@ var _ = Describe("Watcher", func() {
|
||||
// Wait for quiet period
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
@ -211,7 +200,7 @@ var _ = Describe("Watcher", func() {
|
||||
// Wait for scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Should scan the library root
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
@ -223,13 +212,12 @@ var _ = Describe("Watcher", func() {
|
||||
It("deduplicates empty and dot paths", func() {
|
||||
// Send notifications with empty and dot paths
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
|
||||
|
||||
// Wait for scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Should have only one target
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
@ -264,20 +252,19 @@ var _ = Describe("Watcher", func() {
|
||||
It("creates separate targets for different libraries", func() {
|
||||
// Send notifications for both libraries
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"}
|
||||
|
||||
// Wait for scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Verify two targets for different libraries
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].Targets).To(HaveLen(2))
|
||||
// Wait for a scan that collected both targets
|
||||
Eventually(func() []model.ScanTarget {
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return calls[0].Targets
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2))
|
||||
|
||||
// Verify library IDs are different
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
libraryIDs := make(map[int]bool)
|
||||
for _, target := range calls[0].Targets {
|
||||
libraryIDs[target.LibraryID] = true
|
||||
|
||||
@ -2,6 +2,7 @@ package scheduler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -28,7 +29,7 @@ var _ = Describe("Scheduler", func() {
|
||||
s.c.Stop() // Stop the scheduler after tests
|
||||
})
|
||||
|
||||
It("adds and executes a job", func() {
|
||||
It("adds and executes a job", FlakeAttempts(3), func() {
|
||||
done := make(chan struct{})
|
||||
|
||||
id, err := s.Add("@every 50ms", func() {
|
||||
@ -38,7 +39,7 @@ var _ = Describe("Scheduler", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeZero())
|
||||
|
||||
Eventually(done).Should(BeClosed())
|
||||
Eventually(done, 5*time.Second).Should(BeClosed())
|
||||
})
|
||||
|
||||
It("adds a job with random ~ syntax", func() {
|
||||
|
||||
@ -517,4 +517,134 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlist Boolean String Normalization (issue #4826)", Ordered, func() {
|
||||
var songID string
|
||||
var boolPlaylistID, stringPlaylistID, nestedPlaylistID string
|
||||
|
||||
BeforeAll(func() {
|
||||
setupTestDB()
|
||||
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
songID = songs[0].ID
|
||||
|
||||
// Star the song via the Subsonic API
|
||||
resp := doReq("star", "id", songID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
// Force immediate refresh for all smart playlists
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
|
||||
// Create smart playlist with boolean true
|
||||
boolPls := &model.Playlist{
|
||||
Name: "Bool Loved",
|
||||
OwnerID: adminUser.ID,
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed())
|
||||
boolPlaylistID = boolPls.ID
|
||||
|
||||
// Create smart playlist with string "true"
|
||||
stringPls := &model.Playlist{
|
||||
Name: "String Loved",
|
||||
OwnerID: adminUser.ID,
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed())
|
||||
stringPlaylistID = stringPls.ID
|
||||
|
||||
// Create smart playlist with string "true" in nested any group (exact issue #4826 scenario)
|
||||
nestedPls := &model.Playlist{
|
||||
Name: "Nested String Loved",
|
||||
OwnerID: adminUser.ID,
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{
|
||||
criteria.Any{
|
||||
criteria.Is{"loved": "true"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(nestedPls)).To(Succeed())
|
||||
nestedPlaylistID = nestedPls.ID
|
||||
})
|
||||
|
||||
It("smart playlist with bool loved=true returns starred song", func() {
|
||||
resp := doReq("getPlaylist", "id", boolPlaylistID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
||||
entryIDs := make([]string, len(resp.Playlist.Entry))
|
||||
for i, e := range resp.Playlist.Entry {
|
||||
entryIDs[i] = e.Id
|
||||
}
|
||||
Expect(entryIDs).To(ContainElement(songID))
|
||||
})
|
||||
|
||||
It("smart playlist with string loved='true' returns same results as bool (issue #4826)", func() {
|
||||
boolResp := doReq("getPlaylist", "id", boolPlaylistID)
|
||||
stringResp := doReq("getPlaylist", "id", stringPlaylistID)
|
||||
|
||||
Expect(stringResp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount))
|
||||
})
|
||||
|
||||
It("nested any group with string loved='true' returns starred song (issue #4826)", func() {
|
||||
resp := doReq("getPlaylist", "id", nestedPlaylistID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
||||
entryIDs := make([]string, len(resp.Playlist.Entry))
|
||||
for i, e := range resp.Playlist.Entry {
|
||||
entryIDs[i] = e.Id
|
||||
}
|
||||
Expect(entryIDs).To(ContainElement(songID))
|
||||
})
|
||||
|
||||
It("isPresent with string 'true' matches songs that have the tag", func() {
|
||||
pls := &model.Playlist{
|
||||
Name: "Genre Present String",
|
||||
OwnerID: adminUser.ID,
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
|
||||
|
||||
resp := doReq("getPlaylist", "id", pls.ID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
||||
})
|
||||
|
||||
It("isMissing with string 'true' excludes songs that have the tag", func() {
|
||||
pls := &model.Playlist{
|
||||
Name: "Genre Missing String",
|
||||
OwnerID: adminUser.ID,
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
|
||||
|
||||
resp := doReq("getPlaylist", "id", pls.ID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(0)))
|
||||
})
|
||||
|
||||
It("isMissing with string 'true' returns same results as bool true", func() {
|
||||
boolPls := &model.Playlist{
|
||||
Name: "Genre Missing Bool",
|
||||
OwnerID: adminUser.ID,
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed())
|
||||
|
||||
stringPls := &model.Playlist{
|
||||
Name: "Genre Missing String2",
|
||||
OwnerID: adminUser.ID,
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed())
|
||||
|
||||
boolResp := doReq("getPlaylist", "id", boolPls.ID)
|
||||
stringResp := doReq("getPlaylist", "id", stringPls.ID)
|
||||
Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -212,7 +212,7 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
|
||||
response := newResponse()
|
||||
response.NowPlaying = &responses.NowPlaying{}
|
||||
var i int32
|
||||
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry {
|
||||
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.PlaybackSession) responses.NowPlayingEntry {
|
||||
i++
|
||||
return responses.NowPlayingEntry{
|
||||
Child: childFromMediaFile(ctx, np.MediaFile),
|
||||
|
||||
@ -193,7 +193,7 @@ type fakePlayTracker struct {
|
||||
Error error
|
||||
}
|
||||
|
||||
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) {
|
||||
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) {
|
||||
return nil, f.Error
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user