package plugins
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strconv"
"time"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type scrobblePage struct {
Scrobbles []host.ScrobbleRef `json:"scrobbles"`
Next *host.ScrobbleOptions `json:"next"`
}
var _ = Describe("Scrobble Retriever Host Function", Ordered, func() {
var (
manager *Manager
tmpDir string
dataStore *tests.MockDataStore
)
p := func(val int64) *int64 {
return &val
}
opts := func(from, to *int64, descending bool, offset, maxItems int) *host.ScrobbleOptions {
return &host.ScrobbleOptions{
FromTimestamp: from, ToTimestamp: to,
Descending: descending, Offset: offset, MaxItems: maxItems,
}
}
BeforeAll(func() {
ctx := GinkgoT().Context()
var err error
tmpDir, err = os.MkdirTemp("", "scrobble-retriever-test-*")
Expect(err).ToNot(HaveOccurred())
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL")
db.Init(ctx)
DeferCleanup(func() {
Expect(tests.ClearDB()).To(Succeed())
})
dataStore = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
srcPath := filepath.Join(testdataDir, "test-scrobble-retriever"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
userRepo := dataStore.User()
_ = userRepo.Put(ctx, &model.User{
ID: "user1",
UserName: "testuser",
IsAdmin: false,
})
_ = userRepo.Put(ctx, &model.User{
ID: "admin1",
UserName: "adminuser",
IsAdmin: true,
})
err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "1", LibraryID: 1})
Expect(err).To(BeNil())
err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "2", LibraryID: 1})
Expect(err).To(BeNil())
err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "3", LibraryID: 1})
Expect(err).To(BeNil())
scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"})
scrobbleRepo := dataStore.Scrobble()
err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(0, 0))
Expect(err).To(BeNil())
err = scrobbleRepo.RecordScrobble(scrobbleCtx, "2", time.Unix(1, 0))
Expect(err).To(BeNil())
err = scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(2, 0))
Expect(err).To(BeNil())
err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(2, 0))
Expect(err).To(BeNil())
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
}
router := &fakeSubsonicRouter{}
manager.SetSubsonicRouter(router)
pluginPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension)
wasmData, err := os.ReadFile(pluginPath)
Expect(err).ToNot(HaveOccurred())
hash := sha256.Sum256(wasmData)
hashHex := hex.EncodeToString(hash[:])
dataStore.MockedPlugin = tests.CreateMockPluginRepo()
mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo)
mockPluginRepo.Permitted = true
enabledPlugin := model.Plugin{
ID: "test-scrobble-retriever",
Path: pluginPath,
SHA256: hashHex,
Enabled: true,
Users: `["user1","admin1"]`,
}
mockPluginRepo.SetData(model.Plugins{enabledPlugin})
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
var instance *extism.Plugin
BeforeEach(func() {
manager.mu.RLock()
plugin := manager.plugins["test-scrobble-retriever"]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
var err error
ctx := GinkgoT().Context()
instance, err = plugin.instance(ctx)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
instance.Close(ctx)
})
})
Describe("not authorized", func() {
It("rejects first timestamp", func() {
exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser"))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
It("rejects last timestamp", func() {
exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser"))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
It("rejects scrobbles", func() {
exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
It("rejects scrobbles", func() {
exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
})
Describe("no items", func() {
It("calls get first timestamp", func() {
exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
Expect(output).To(Equal([]byte("{\"timestamp\":null}")))
})
It("calls get last timestamp", func() {
exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
Expect(output).To(Equal([]byte("{\"timestamp\":null}")))
})
It("calls scrobbles", func() {
exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
Expect(output).To(Equal([]byte(`{"scrobbles":[],"next":null}`)))
})
It("calls get scrobble count", func() {
exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
Expect(output).To(Equal([]byte("0")))
})
})
Describe("with items", func() {
scrobbles := []host.ScrobbleRef{
{ID: 1, MediaFileID: "1", SubmissionTime: 0},
{ID: 2, MediaFileID: "2", SubmissionTime: 1},
{ID: 3, MediaFileID: "3", SubmissionTime: 2},
{ID: 4, MediaFileID: "1", SubmissionTime: 2},
}
scrobblesReversed := make([]host.ScrobbleRef, 4)
BeforeAll(func() {
for idx := range scrobbles {
scrobblesReversed[3-idx] = scrobbles[idx]
}
})
It("calls get first timestamp", func() {
exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
Expect(output).To(Equal([]byte("{\"timestamp\":0}")))
})
It("calls get last timestamp", func() {
exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
Expect(output).To(Equal([]byte("{\"timestamp\":2}")))
})
DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, next *host.ScrobbleOptions) {
exit, output, err := instance.Call("call_get_scrobbles", []byte(params))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
var page scrobblePage
Expect(json.Unmarshal(output, &page)).To(Succeed())
Expect(page).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next}))
},
Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil),
Entry("calls scrobbles in descending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil),
Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil),
Entry("defaults to ascending even when only toTimestamp is given", `{"username":"adminuser","toTimestamp":2}`, scrobbles, nil),
Entry("calls subset of scrobbles in ascending order, next page", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], opts(p(2), nil, false, 0, 2)),
Entry("calls subset of scrobbles in ascending order, next page with offset", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], opts(p(2), nil, false, 1, 2)),
Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil),
Entry("calls subset of scrobbles in descending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil),
Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2,"descending":true}`, scrobblesReversed, nil),
Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 3}`, scrobblesReversed[:3], opts(nil, p(0), true, 0, 3)),
Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 1}`, scrobblesReversed[:1], opts(nil, p(2), true, 1, 1)),
)
DescribeTable("GetScrobblesCount", func(params string, count int) {
exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
value, err := strconv.ParseInt(string(output), 10, 64)
Expect(err).ToNot(HaveOccurred())
Expect(value).To(Equal(int64(count)))
},
Entry("gets all scrobbles", `{"username":"adminuser"}`, 4),
Entry("gets two scrobbles ascending", `{"username":"adminuser", "fromTimestamp": 2}`, 2),
Entry("gets one scrobble descending", `{"username":"adminuser", "toTimestamp": 0}`, 1),
Entry("filters upper and bottom", `{"username":"adminuser", "fromTimestamp": 1, "toTimestamp": 1}`, 1),
Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4),
)
})
Context("Complex edge cases - multiple scrobbles at the same timestamp", func() {
duplicates := make([]host.ScrobbleRef, 5)
duplicatesReversed := make([]host.ScrobbleRef, 5)
BeforeAll(func() {
scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"})
scrobbleRepo := dataStore.Scrobble()
for i := range 5 {
err := scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(100, 0))
Expect(err).To(BeNil())
scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100}
duplicates[i] = scrobble
duplicatesReversed[4-i] = scrobble
}
})
getPage := func(o host.ScrobbleOptions) scrobblePage {
GinkgoHelper()
payload, err := json.Marshal(struct {
Username string `json:"username"`
FromTimestamp *int64 `json:"fromTimestamp,omitempty"`
ToTimestamp *int64 `json:"toTimestamp,omitempty"`
Descending bool `json:"descending"`
Offset int `json:"offset,omitempty"`
MaxItems int `json:"maxItems"`
}{
Username: "adminuser",
FromTimestamp: o.FromTimestamp,
ToTimestamp: o.ToTimestamp,
Descending: o.Descending,
Offset: o.Offset,
MaxItems: o.MaxItems,
})
Expect(err).ToNot(HaveOccurred())
exit, output, err := instance.Call("call_get_scrobbles", payload)
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
var page scrobblePage
Expect(json.Unmarshal(output, &page)).To(Succeed())
return page
}
tied := func(offset, maxItems int, descending bool) host.ScrobbleOptions {
return *opts(p(100), p(100), descending, offset, maxItems)
}
DescribeTable("Edge cases; duplicate scrobbles", func(offset, count int, descending bool, scrobbles []host.ScrobbleRef, nextOffset *int) {
var next *host.ScrobbleOptions
if nextOffset != nil {
next = opts(p(100), p(100), descending, *nextOffset, count)
}
Expect(getPage(tied(offset, count, descending))).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next}))
},
Entry("All tracks, in order", 0, 100, false, duplicates, nil),
Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil),
Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], new(1)),
Entry("Ascending order, middle page", 1, 2, false, duplicates[1:3], new(3)),
Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil),
Entry("Ascending order, from the start, continuing offset", 3, 1, false, duplicates[3:4], new(4)),
Entry("start descending", 0, 2, true, duplicatesReversed[:2], new(2)),
Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], new(4)),
Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil),
)
It("walks every page exactly once when all timestamps collide", func() {
for _, descending := range []bool{false, true} {
var seen []host.ScrobbleRef
o := tied(0, 2, descending)
var page scrobblePage
for range 10 {
page = getPage(o)
seen = append(seen, page.Scrobbles...)
if page.Next == nil {
break
}
o = *page.Next
}
Expect(page.Next).To(BeNil(), "pagination did not terminate")
if descending {
Expect(seen).To(Equal(duplicatesReversed))
} else {
Expect(seen).To(Equal(duplicates))
}
}
})
})
})