🏡 index : ironmagma/quintodrome.git

//go:build !windows

package plugins

import (
	"context"
	"errors"
	"time"

	"github.com/navidrome/navidrome/core/scrobbler"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

// ctxWithUser returns a fresh context with the test user.
// Must be called within each test, not in BeforeAll, because the context
// from BeforeAll gets cancelled before tests run.
func ctxWithUser() context.Context {
	return request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", UserName: "testuser"})
}

var _ = Describe("ScrobblerPlugin", Ordered, func() {
	var (
		scrobblerManager *Manager
		s                scrobbler.Scrobbler
	)

	BeforeAll(func() {
		// Load the scrobbler via a new manager with the test-scrobbler plugin
		scrobblerManager, _ = createTestManagerWithPlugins(nil, "test-scrobbler"+PackageExtension)

		var ok bool
		s, ok = scrobblerManager.LoadScrobbler("test-scrobbler")
		Expect(ok).To(BeTrue())
	})

	Describe("LoadScrobbler", func() {
		It("returns a scrobbler for a plugin with Scrobbler capability", func() {
			Expect(s).ToNot(BeNil())
		})

		It("returns false for a plugin without Scrobbler capability", func() {
			_, ok := testManager.LoadScrobbler("test-metadata-agent")
			Expect(ok).To(BeFalse())
		})

		It("returns false for non-existent plugin", func() {
			_, ok := scrobblerManager.LoadScrobbler("non-existent")
			Expect(ok).To(BeFalse())
		})
	})

	Describe("IsAuthorized", func() {
		It("returns true when plugin is configured to authorize", func() {
			result := s.IsAuthorized(ctxWithUser(), "user-1")
			Expect(result).To(BeTrue())
		})

		Context("when plugin is configured to not authorize", Ordered, func() {
			var notAuthScrobbler scrobbler.Scrobbler

			BeforeAll(func() {
				mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{
					"test-scrobbler": {"authorized": "false"},
				}, "test-scrobbler"+PackageExtension)

				var ok bool
				notAuthScrobbler, ok = mgr.LoadScrobbler("test-scrobbler")
				Expect(ok).To(BeTrue())
			})

			It("returns false", func() {
				result := notAuthScrobbler.IsAuthorized(ctxWithUser(), "user-1")
				Expect(result).To(BeFalse())
			})
		})
	})

	Describe("isUserAllowed", func() {
		It("returns true when allUsers is true", func() {
			sp := &ScrobblerPlugin{allUsers: true}
			Expect(sp.isUserAllowed("any-user")).To(BeTrue())
		})

		It("returns false when allowedUserIDs is empty and allUsers is false", func() {
			sp := &ScrobblerPlugin{allUsers: false, allowedUserIDs: []string{}}
			Expect(sp.isUserAllowed("user-1")).To(BeFalse())
		})

		It("returns false when allowedUserIDs is nil and allUsers is false", func() {
			sp := &ScrobblerPlugin{allUsers: false}
			Expect(sp.isUserAllowed("user-1")).To(BeFalse())
		})

		It("returns true when user is in allowedUserIDs", func() {
			sp := &ScrobblerPlugin{
				allUsers:       false,
				allowedUserIDs: []string{"user-1", "user-2"},
				userIDMap:      map[string]struct{}{"user-1": {}, "user-2": {}},
			}
			Expect(sp.isUserAllowed("user-1")).To(BeTrue())
		})

		It("returns false when user is not in allowedUserIDs", func() {
			sp := &ScrobblerPlugin{
				allUsers:       false,
				allowedUserIDs: []string{"user-1", "user-2"},
				userIDMap:      map[string]struct{}{"user-1": {}, "user-2": {}},
			}
			Expect(sp.isUserAllowed("user-3")).To(BeFalse())
		})
	})

	Describe("NowPlaying", func() {
		It("successfully calls the plugin", func() {
			track := &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"}}},
				},
			}

			err := s.NowPlaying(ctxWithUser(), "user-1", track, 30)
			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() {
				track := &model.MediaFile{ID: "track-1", Title: "Test Song"}
				err := retryScrobbler.NowPlaying(ctxWithUser(), "user-1", track, 30)
				Expect(err).To(HaveOccurred())
				Expect(err).To(MatchError(scrobbler.ErrRetryLater))
			})
		})
	})

	Describe("Scrobble", func() {
		It("successfully calls the plugin", func() {
			sc := scrobbler.Scrobble{
				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"}}},
					},
				},
				TimeStamp: time.Now(),
			}

			err := s.Scrobble(ctxWithUser(), "user-1", sc)
			Expect(err).ToNot(HaveOccurred())
		})

		Context("when plugin returns not_authorized error", Ordered, func() {
			var notAuthScrobbler scrobbler.Scrobbler

			BeforeAll(func() {
				mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{
					"test-scrobbler": {"error": "user not linked", "error_type": "scrobbler(not_authorized)"},
				}, "test-scrobbler"+PackageExtension)

				var ok bool
				notAuthScrobbler, ok = mgr.LoadScrobbler("test-scrobbler")
				Expect(ok).To(BeTrue())
			})

			It("returns ErrNotAuthorized", func() {
				scrobble := scrobbler.Scrobble{
					MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"},
					TimeStamp: time.Now(),
				}
				err := notAuthScrobbler.Scrobble(ctxWithUser(), "user-1", scrobble)
				Expect(err).To(HaveOccurred())
				Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
			})
		})

		Context("when plugin returns unrecoverable error", Ordered, func() {
			var unrecoverableScrobbler scrobbler.Scrobbler

			BeforeAll(func() {
				mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{
					"test-scrobbler": {"error": "track rejected", "error_type": "scrobbler(unrecoverable)"},
				}, "test-scrobbler"+PackageExtension)

				var ok bool
				unrecoverableScrobbler, ok = mgr.LoadScrobbler("test-scrobbler")
				Expect(ok).To(BeTrue())
			})

			It("returns ErrUnrecoverable", func() {
				scrobble := scrobbler.Scrobble{
					MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"},
					TimeStamp: time.Now(),
				}
				err := unrecoverableScrobbler.Scrobble(ctxWithUser(), "user-1", scrobble)
				Expect(err).To(HaveOccurred())
				Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
			})
		})
	})

	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")
			Expect(names).To(ContainElement("test-scrobbler"))
		})

		It("does not return metadata agent plugins for Scrobbler capability", func() {
			names := testManager.PluginNames("Scrobbler")
			Expect(names).ToNot(ContainElement("test-metadata-agent"))
		})
	})

	Describe("mediaFileToTrackInfo", func() {
		var track *model.MediaFile

		BeforeEach(func() {
			track = &model.MediaFile{
				ID:        "track-1",
				Title:     "Test Song",
				Path:      "/music/test.flac",
				LibraryID: 1,
			}
		})

		fsManifest := &Manifest{
			Permissions: &Permissions{
				Library: &LibraryPermission{Filesystem: true},
			},
		}

		It("includes LibraryID and Path when the plugin has filesystem access to the track's library", func() {
			p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)}
			ti := mediaFileToTrackInfo(p, track)
			Expect(ti.LibraryID).To(Equal(int32(1)))
			Expect(ti.Path).To(Equal("/music/test.flac"))
		})

		It("omits LibraryID and Path when the plugin lacks filesystem permission", func() {
			p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)}
			ti := mediaFileToTrackInfo(p, track)
			Expect(ti.LibraryID).To(BeZero())
			Expect(ti.Path).To(BeEmpty())
		})

		It("omits LibraryID and Path when the track's library is not in the allowed set", func() {
			p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)}
			ti := mediaFileToTrackInfo(p, track)
			Expect(ti.LibraryID).To(BeZero())
			Expect(ti.Path).To(BeEmpty())
		})
	})
})

var _ = Describe("mapScrobblerError", func() {
	It("returns nil for nil error", func() {
		Expect(mapScrobblerError(nil)).ToNot(HaveOccurred())
	})

	It("returns ErrNotAuthorized for error containing 'not_authorized'", func() {
		err := mapScrobblerError(errors.New("plugin error: scrobbler(not_authorized)"))
		Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
	})

	It("returns ErrRetryLater for error containing 'retry_later'", func() {
		err := mapScrobblerError(errors.New("temporary failure: scrobbler(retry_later)"))
		Expect(err).To(MatchError(scrobbler.ErrRetryLater))
	})

	It("returns ErrUnrecoverable for error containing 'unrecoverable'", func() {
		err := mapScrobblerError(errors.New("fatal error: scrobbler(unrecoverable)"))
		Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
	})

	It("returns ErrUnrecoverable for unknown error", func() {
		err := mapScrobblerError(errors.New("some unknown error"))
		Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
	})
})