Merge branch 'master' into radio-browser-search/5239

This commit is contained in:
Markus Busche 2026-05-09 16:47:41 +02:00 committed by GitHub
commit 777cbbc998
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
146 changed files with 6340 additions and 1561 deletions

View File

@ -53,13 +53,13 @@ runs:
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -67,12 +67,13 @@ runs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
github-token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |

View File

@ -8,7 +8,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@v7
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@ -19,8 +19,7 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
const pulls = await github.pulls.list({owner, repo});
for await (const {data} of github.paginate.iterator(pulls)) {
for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@ -34,7 +33,7 @@ jobs:
return core.error(`No matching pull request found`);
}
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
@ -43,12 +42,12 @@ jobs:
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
await github.issues.createComment({repo, owner, issue_number, body});
await github.rest.issues.createComment({repo, owner, issue_number, body});
}

View File

@ -145,7 +145,7 @@ jobs:
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64

View File

@ -28,7 +28,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
operations-per-run: 999
days-before-issue-stale: 180

View File

@ -164,6 +164,7 @@ RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
ENV PATH="/app:${PATH}"
ENTRYPOINT ["/app/navidrome"]

View File

@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.11.1
GOLANGCI_LINT_VERSION ?= v2.12.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@ -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 {

View File

@ -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 {

View File

@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
@ -110,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router
}
@ -219,7 +221,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@ -43,9 +44,11 @@ var allProviders = wire.NewSet(
metrics.GetPrometheusInstance,
db.Db,
plugins.GetManager,
sonic.New,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),

View File

@ -95,6 +95,7 @@ type configOptions struct {
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
UIPlaybackReportInterval time.Duration
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
@ -133,6 +134,7 @@ type configOptions struct {
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtworkThrottleBuffered bool
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
@ -776,6 +778,7 @@ func setViperDefaults() {
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("enablesharing", false)
@ -859,6 +862,7 @@ func setViperDefaults() {
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartworkthrottlebuffered", true)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)

View File

@ -67,11 +67,12 @@ const (
ScanIgnoreFile = ".ndignore"
ArtworkFolder = "artwork"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
DefaultUIPlaybackReportInterval = time.Minute
DefaultHttpClientTimeOut = 10 * time.Second

View File

@ -37,20 +37,20 @@ 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.
// https://github.com/navidrome/navidrome/issues/5376
// cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← currently wins (bug)
// │ └── cover.jpg ← should not win
// ├── CD2/
// │ ├── 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"),
@ -68,21 +68,20 @@ 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.
// https://github.com/navidrome/navidrome/issues/5376
// folder.jpg basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← currently wins (bug)
// │ └── folder.jpg ← should not win
// ├── CD2/
// │ ├── 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"),
@ -98,17 +97,15 @@ 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.
// https://github.com/navidrome/navidrome/issues/5376
// Single-subfolder albums must still consider the parent folder's images.
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() {
// └── cover.jpg ← should win (parent-folder fallback)
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"),
@ -121,6 +118,32 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// https://github.com/navidrome/navidrome/issues/5456
When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
// Album/ (top-level folder, Path=".")
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── cover.jpg ← should win (album-root)
It("prefers the album-root cover.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Album/cover.jpg": imageFile("album-root"),
"Album/CD1/folder.jpg": imageFile("disc1"),
"Album/CD2/folder.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
// Artist/
// └── Album/

View File

@ -1,6 +1,7 @@
package artworke2e_test
import (
"fmt"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
@ -255,6 +256,100 @@ var _ = Describe("Disc artwork resolution", func() {
})
})
// Reproduces https://github.com/navidrome/navidrome/issues/5456
// Deeply nested layout matching the reporter's actual structure.
When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Genre/Artist/Album/ ← album root with cover.jpg
// ├── cover.jpg ← album-level cover
// ├── Disc 01 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── ... (12 discs)
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
discNames := []string{
"Disc 01 (Birth of the Dead - The Studio Sides)",
"Disc 02 (Birth of the Dead - The Live Sides)",
"Disc 03 (The Grateful Dead)",
"Disc 04 (Anthem of the Sun)",
"Disc 05 (Aoxomoxoa)",
"Disc 06 (Live; Dead)",
"Disc 07 (Workingman's Dead)",
"Disc 08 (American Beauty)",
"Disc 09 (Grateful Dead)",
"Disc 10 (Europe '72)",
"Disc 11 (Europe '72)",
"Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))",
}
layout := fstest.MapFS{
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"),
}
for i, name := range discNames {
discNum := i + 1
prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum))
}
setLayout(layout)
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
for i := range discNames {
discNum := i + 1
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))),
"disc %d should use its own folder.jpg", discNum)
}
})
})
// https://github.com/navidrome/navidrome/issues/5456
// Top-level album variant — album folder at library root (Path=".").
When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Album/ (top-level, Path=".")
// ├── cover.jpg ← album-level cover
// ├── Disc 01/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── Disc 03/
// ├── 01 - Track.mp3
// └── folder.jpg
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
layout := fstest.MapFS{
"Album/cover.jpg": imageFile("album-root-cover"),
}
for i := 1; i <= 3; i++ {
prefix := fmt.Sprintf("Album/Disc %02d/", i)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i))
}
setLayout(layout)
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
for i := 1; i <= 3; i++ {
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))),
"disc %d should use its own folder.jpg", i)
}
})
})
When("discsubtitle is set but no image filename matches the subtitle", func() {
// Artist/
// └── Album/

View File

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/natural"
)
@ -53,10 +54,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
lib: lib,
}
a.cacheKey.artID = artID
if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
a.cacheKey.lastUpdate = *a.updatedAt
} else {
a.cacheKey.lastUpdate = al.UpdatedAt
a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
if imagesUpdateAt != nil {
a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
}
return a, nil
}
@ -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.ParentID != "" {
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),
)
}

View File

@ -141,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() {
ID: "parentFolder",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg", "back.jpg"},
}
@ -213,9 +214,83 @@ 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 library root parent for multi-folder albums", func() {
// Two album parts directly under the library root — parent is the root itself
repo.result = []model.Folder{
{
ID: "folder1",
Path: ".",
Name: "AlbumPart1",
ParentID: "rootFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
ID: "folder2",
Path: ".",
Name: "AlbumPart2",
ParentID: "rootFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
ID: "rootFolder",
Path: "",
Name: ".",
ParentID: "",
ImageFiles: []string{"unrelated.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("includes top-level album folder for multi-disc albums", func() {
// Album folder directly under library root, with disc subfolders
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Album",
Name: "Disc1",
ParentID: "albumFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"folder.jpg"},
},
{
ID: "folder2",
Path: "Album",
Name: "Disc2",
ParentID: "albumFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"folder.jpg"},
},
}
repo.parentResult = &model.Folder{
ID: "albumFolder",
Path: ".",
Name: "Album",
ParentID: "rootFolder",
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(3))
Expect(imgFiles[0]).To(Equal("Album/cover.jpg"))
Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg"))
Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.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 +307,38 @@ 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",
ParentID: "artistFolder",
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{
{

View File

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
type discArtworkReader struct {
@ -105,10 +106,9 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
updatedAt: imagesUpdatedAt,
}
r.cacheKey.artID = artID
if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) {
r.cacheKey.lastUpdate = *r.updatedAt
} else {
r.cacheKey.lastUpdate = al.UpdatedAt
r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
if imagesUpdatedAt != nil {
r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt)
}
return r, nil
}

View File

@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
} else {
log.Error(ctx, "No admin user found!", err)
}
u = &model.User{}
u = &model.User{IsAdmin: true, UserName: "admin"}
}
ctx = request.WithUsername(ctx, u.UserName)

View File

@ -21,6 +21,7 @@ type Claims struct {
ID string // "id" - artwork/mediafile ID
Format string // "f" - audio format
BitRate int // "b" - audio bitrate
ShareID string // "sid" - share ID for share stream tokens
}
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any {
if c.BitRate != 0 {
m["b"] = c.BitRate
}
if c.ShareID != "" {
m["sid"] = c.ShareID
}
return m
}
@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims {
c.BitRate = int(bf)
}
}
var sid string
if err := token.Get("sid", &sid); err == nil {
c.ShareID = sid
}
return c
}

View File

@ -28,6 +28,7 @@ var _ = Describe("Claims", func() {
Expect(m).NotTo(HaveKey("id"))
Expect(m).NotTo(HaveKey("f"))
Expect(m).NotTo(HaveKey("b"))
Expect(m).NotTo(HaveKey("sid"))
})
It("includes expiration and issued-at when set", func() {
@ -52,6 +53,12 @@ var _ = Describe("Claims", func() {
Expect(m).To(HaveKeyWithValue("f", "mp3"))
Expect(m).To(HaveKeyWithValue("b", 192))
})
It("includes share ID claim when set", func() {
c := auth.Claims{ShareID: "abc1234567"}
m := c.ToMap()
Expect(m).To(HaveKeyWithValue("sid", "abc1234567"))
})
})
Describe("ClaimsFromToken", func() {
@ -84,6 +91,7 @@ var _ = Describe("Claims", func() {
ID: "al-456",
Format: "opus",
BitRate: 128,
ShareID: "abc1234567",
}
token, _, err := tokenAuth.Encode(original.ToMap())
Expect(err).NotTo(HaveOccurred())
@ -91,6 +99,7 @@ var _ = Describe("Claims", func() {
c := auth.ClaimsFromToken(token)
Expect(c.Issuer).To(Equal("ND"))
Expect(c.ID).To(Equal("al-456"))
Expect(c.ShareID).To(Equal("abc1234567"))
Expect(c.Format).To(Equal("opus"))
Expect(c.BitRate).To(Equal(128))
})

View File

@ -302,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
}
if err == nil && len(songs) > 0 {
return e.matcher.MatchSongsToLibrary(ctx, songs, count)
return e.matcher.MatchSongs(ctx, songs, count)
}
// Fallback to existing similar artists + top songs algorithm
@ -481,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
}
}
mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count)
mfs, err := e.matcher.MatchSongs(ctx, songs, count)
if err != nil {
return nil, err
}

View File

@ -23,7 +23,7 @@ func New(ds model.DataStore) *Matcher {
return &Matcher{ds: ds}
}
// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase
// MatchSongs matches agent song results to local library tracks using a multi-phase
// matching algorithm that prioritizes accuracy over recall.
//
// # Algorithm Overview
@ -107,25 +107,58 @@ func New(ds model.DataStore) *Matcher {
//
// Returns up to 'count' MediaFiles from the library that best match the input songs,
// preserving the original order from the agent. Songs that cannot be matched are skipped.
func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
idMatches, err := m.loadTracksByID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
}
titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
if len(songs) == 0 {
return nil, nil
}
return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
if err != nil {
return nil, err
}
return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
}
// MatchSongsIndexed matches agent song results to local library tracks and returns a map
// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map.
// This preserves original indices, allowing callers to correlate results back to the input slice.
func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
if len(songs) == 0 {
return nil, nil
}
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
if err != nil {
return nil, err
}
result := make(map[int]model.MediaFile, len(songs))
for i, t := range songs {
if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
result[i] = mf
}
}
return result, nil
}
func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) {
byID, err = m.loadTracksByID(ctx, songs)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
byMBID, err = m.loadTracksByMBID(ctx, songs, byID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
}
byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
return byID, byMBID, byISRC, byTitle, nil
}
// songMatchedIn checks if a song has already been matched in any of the provided match maps.

View File

@ -75,7 +75,7 @@ var _ = Describe("Matcher", func() {
Return(artistTracks, nil).Maybe()
}
Describe("MatchSongsToLibrary", func() {
Describe("MatchSongs", func() {
Context("matching by direct ID", func() {
It("matches songs with an ID field to MediaFiles by ID", func() {
conf.Server.Matcher.FuzzyThreshold = 100
@ -87,7 +87,7 @@ var _ = Describe("Matcher", func() {
}
expectIDPhase(model.MediaFiles{idMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-1"))
@ -106,7 +106,7 @@ var _ = Describe("Matcher", func() {
}
expectMBIDPhase(model.MediaFiles{mbidMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-mbid"))
@ -125,7 +125,7 @@ var _ = Describe("Matcher", func() {
}
expectISRCPhase(model.MediaFiles{isrcMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-isrc"))
@ -142,7 +142,7 @@ var _ = Describe("Matcher", func() {
ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode",
}
setupTitleOnlyExpectations(model.MediaFiles{titleMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-title"))
@ -157,7 +157,7 @@ var _ = Describe("Matcher", func() {
ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-fuzzy"))
@ -172,7 +172,7 @@ var _ = Describe("Matcher", func() {
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
}
setupTitleOnlyExpectations(differentTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
@ -189,7 +189,7 @@ var _ = Describe("Matcher", func() {
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("br-live"))
@ -205,7 +205,7 @@ var _ = Describe("Matcher", func() {
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("br"))
@ -227,7 +227,7 @@ var _ = Describe("Matcher", func() {
}
expectIDPhase(model.MediaFiles{idMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-id"))
@ -248,7 +248,7 @@ var _ = Describe("Matcher", func() {
{ID: "c", Title: "Song C", Artist: "Artist"},
}
setupTitleOnlyExpectations(tracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
result, err := m.MatchSongs(ctx, songs, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
})
@ -256,13 +256,60 @@ var _ = Describe("Matcher", func() {
Context("empty input", func() {
It("returns empty results for no songs", func() {
result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5)
result, err := m.MatchSongs(ctx, []agents.Song{}, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
})
Describe("MatchSongsIndexed", func() {
It("returns index-keyed map of matched songs", func() {
songs := []agents.Song{
{ID: "track-1", Name: "Song One", Artist: "Artist A"},
{ID: "track-2", Name: "Song Two", Artist: "Artist B"},
{ID: "track-3", Name: "Song Three", Artist: "Artist C"},
}
mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"}
mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"}
expectIDPhase(model.MediaFiles{mf1, mf2})
allowOtherPhases()
result, err := m.MatchSongsIndexed(ctx, songs)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("track-1"))
Expect(result[1].ID).To(Equal("track-2"))
_, exists := result[2]
Expect(exists).To(BeFalse())
})
It("preserves original indices when some songs don't match", func() {
songs := []agents.Song{
{Name: "Unknown Song", Artist: "Unknown Artist"},
{ID: "track-1", Name: "Known Song", Artist: "Known Artist"},
}
mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"}
expectIDPhase(model.MediaFiles{mf1})
allowOtherPhases()
result, err := m.MatchSongsIndexed(ctx, songs)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
_, exists := result[0]
Expect(exists).To(BeFalse())
Expect(result[1].ID).To(Equal("track-1"))
})
It("returns empty map for empty input", func() {
result, err := m.MatchSongsIndexed(ctx, nil)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("specificity level matching", func() {
BeforeEach(func() {
conf.Server.Matcher.FuzzyThreshold = 100
@ -283,7 +330,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -303,7 +350,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -323,7 +370,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -337,7 +384,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
@ -356,7 +403,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
@ -384,7 +431,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
@ -407,7 +454,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -426,7 +473,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -447,7 +494,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
@ -467,7 +514,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -495,7 +542,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -515,7 +562,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -535,7 +582,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -556,7 +603,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -577,7 +624,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -603,7 +650,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -620,7 +667,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{closeDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -640,7 +687,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -657,7 +704,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{differentDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -677,7 +724,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -694,7 +741,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{anyTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -711,7 +758,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{shortTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -737,7 +784,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
@ -757,7 +804,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
@ -778,7 +825,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB})
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
result, err := m.MatchSongs(ctx, songs, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))

View File

@ -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 {

View File

@ -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

View 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
}
}
}

View File

@ -3,7 +3,7 @@ package scrobbler
import (
"context"
"maps"
"sort"
"slices"
"sync"
"time"
@ -17,13 +17,32 @@ import (
"github.com/navidrome/navidrome/utils/singleton"
)
type NowPlayingInfo struct {
MediaFile model.MediaFile
Start time.Time
Position int
Username string
PlayerId string
PlayerName string
const (
StateStarting = "starting"
StatePlaying = "playing"
StatePaused = "paused"
StateStopped = "stopped"
StateExpired = "expired"
)
var ValidStates = map[string]bool{
StateStarting: true,
StatePlaying: true,
StatePaused: true,
StateStopped: true,
}
type PlaybackSession struct {
MediaFile model.MediaFile
Start time.Time
UserId string
Username string
PlayerId string
PlayerName string
State string
PositionMs int64
PlaybackRate float64
LastReport time.Time
}
type Submission struct {
@ -31,6 +50,16 @@ type Submission struct {
Timestamp time.Time
}
type ReportPlaybackParams struct {
MediaId string
PositionMs int64
State string
PlaybackRate float64
IgnoreScrobble bool
ClientId string
ClientName string
}
type nowPlayingEntry struct {
ctx context.Context
userId string
@ -38,10 +67,15 @@ type nowPlayingEntry struct {
position int
}
type playbackReportEntry struct {
ctx context.Context
info PlaybackSession
}
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
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
}
// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
@ -54,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
@ -64,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 {
@ -72,10 +110,14 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
})
}
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
// the GetPlayTracker function above
// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton,
// returned by the GetPlayTracker function above. This constructor is exported for testing.
func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
return newPlayTracker(ds, broker, pluginManager)
}
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,
@ -87,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 {
@ -107,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.
@ -193,112 +249,151 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
return combined
}
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
if err != nil {
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
return err
func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration {
if rate <= 0 {
rate = 1.0
}
remainingMs := float64(int64(durationSec*1000)-positionMs) / rate
remainingSec := max(int(remainingMs/1000), 0)
return time.Duration(remainingSec+5) * time.Second
}
func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error {
player, _ := request.PlayerFrom(ctx)
user, _ := request.UserFrom(ctx)
info := NowPlayingInfo{
MediaFile: *mf,
Start: time.Now(),
Position: position,
Username: user.UserName,
PlayerId: playerId,
PlayerName: playerName,
clientId := params.ClientId
client := params.ClientName
now := time.Now()
switch params.State {
case StateStarting:
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
info := PlaybackSession{
MediaFile: *mf,
Start: now,
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
State: params.State,
PositionMs: params.PositionMs,
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
if err != nil {
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)
if getErr != nil || info.MediaFile.ID != params.MediaId {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
info = PlaybackSession{
MediaFile: *mf,
Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
}
}
info.State = params.State
info.PositionMs = params.PositionMs
info.PlaybackRate = params.PlaybackRate
info.LastReport = now
ttl := 30 * time.Minute
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 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 {
err = p.incPlay(ctx, mf, now)
if err != nil {
log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
}
p.dispatchScrobble(ctx, mf, now)
}
}
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)
}
// Calculate TTL based on remaining track duration. If position exceeds track duration,
// remaining is set to 0 to avoid negative TTL.
remaining := max(int(mf.Duration)-position, 0)
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl)
if conf.Server.EnableNowPlaying {
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
if !params.IgnoreScrobble && player.ScrobbleEnabled &&
(params.State == StateStarting || params.State == StatePlaying) {
if info, err := p.playMap.Get(clientId); err == nil {
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
}
}
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()
sort.Slice(res, func(i, j int) bool {
return res[i].Start.After(res[j].Start)
slices.SortFunc(res, func(a, b PlaybackSession) int {
return b.Start.Compare(a.Start)
})
for i := range res {
if res[i].State == StatePlaying {
elapsed := time.Since(res[i].LastReport).Milliseconds()
estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate)
trackDurationMs := int64(res[i].MediaFile.Duration * 1000)
res[i].PositionMs = min(estimated, trackDurationMs)
}
}
return res, nil
}

View File

@ -20,9 +20,6 @@ import (
. "github.com/onsi/gomega"
)
// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests
// Moved to top-level scope to avoid linter issues
type mockPluginLoader struct {
mu sync.RWMutex
names []string
@ -51,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
@ -74,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",
@ -99,88 +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"))
})
Describe("NowPlaying", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Expect(fake.GetUserID()).To(Equal("u-1"))
Expect(fake.GetTrack().ID).To(Equal("123"))
Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
})
It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("stores position when greater than zero", func() {
pos := 42
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred())
Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos))
})
It("sends event with count", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
eventList := eventBroker.getEvents()
Expect(eventList).ToNot(BeEmpty())
evt, ok := eventList[0].(*events.NowPlayingCount)
Expect(ok).To(BeTrue())
Expect(evt.Count).To(Equal(1))
})
It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
It("passes user to scrobbler via context (fix for issue #4787)", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
// Verify the username was passed through async dispatch via context
Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
})
Expect(tracker.builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled"))
})
Describe("GetNowPlaying", func() {
@ -188,10 +109,16 @@ var _ = Describe("PlayTracker", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true})
_ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one",
})
ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true})
_ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
playing, err := tracker.GetNowPlaying(ctx)
@ -211,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)
@ -223,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() {
@ -336,6 +301,532 @@ var _ = Describe("PlayTracker", func() {
})
})
Describe("ReportPlayback", func() {
const defaultClientId = "client-1"
BeforeEach(func() {
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true})
})
It("creates entry on starting and removes on stopped", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("starting"))
Expect(playing[0].MediaFile.ID).To(Equal("123"))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty())
})
It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("playing"))
Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000)))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing[0].State).To(Equal("paused"))
Expect(playing[0].PositionMs).To(Equal(int64(30000)))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty())
})
It("starting replaces existing entry for same player", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("starting"))
Expect(playing[0].PositionMs).To(Equal(int64(0)))
})
It("multiple players have independent sessions", func() {
ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true})
ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true})
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1",
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2",
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(2))
})
Describe("SSE broadcast on state change", func() {
BeforeEach(func() {
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.builtinScrobblers["fake"] = fake
})
It("broadcasts NowPlayingCount on every state change", func() {
// starting -> count should be 1
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts := eventBroker.getEvents()
Expect(evts).To(HaveLen(1))
Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1))
// playing -> count should be 1
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(2))
Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1))
// paused -> count should be 1
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(3))
Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1))
// stopped -> count should be 0
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(4))
Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0))
})
It("does NOT broadcast when EnableNowPlaying is false", func() {
conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.builtinScrobblers["fake"] = fake
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
})
Describe("auto-scrobble", func() {
It("scrobbles on stopped when positionMs >= 50% of track", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1)))
Expect(artist1.PlayCount).To(Equal(int64(1)))
})
It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() {
longTrack := model.MediaFile{
ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1",
Duration: 600,
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")},
},
}
_ = ds.MediaFile(ctx).Put(&longTrack)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(longTrack.PlayCount).To(Equal(int64(1)))
})
It("does NOT scrobble when positionMs below threshold", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("scrobbles twice for two separate sessions of same song", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(2)))
})
It("dispatches to external scrobblers on auto-scrobble", func() {
fake.ScrobbleCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
})
})
Describe("position estimation", func() {
It("estimates position for playing state based on elapsed time", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000)))
})
It("does NOT estimate for paused", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(10000)))
})
It("does NOT estimate for starting", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(0)))
})
It("respects playbackRate", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(100 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
// At 2x speed, 100ms real time = ~200ms playback time
Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100)))
})
It("caps estimated position at track duration", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000
})
})
Describe("resilience (no prior starting)", func() {
It("playing without prior starting creates entry with Start approx now - positionMs", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("playing"))
expectedStart := time.Now().Add(-30 * time.Second)
Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second))
})
It("paused without prior starting creates entry", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("paused"))
})
It("stopped without prior starting auto-scrobbles if threshold met", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
})
It("stopped without prior starting does NOT scrobble if below threshold", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
})
Describe("external scrobbler dispatch", func() {
It("dispatches NowPlaying on starting", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("dispatches NowPlaying on playing", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("does NOT dispatch on paused", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("does NOT dispatch when ignoreScrobble=true", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("does NOT dispatch when ScrobbleEnabled=false", func() {
fake.nowPlayingCalled.Store(false)
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
})
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() {
var pluginLoader *mockPluginLoader
var pluginFake *fakeScrobbler
@ -349,32 +840,37 @@ 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() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
// Should not be called since plugin was removed
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.nowPlayingCalled.Store(false)
pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
@ -462,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() {
@ -550,16 +1046,36 @@ var _ = Describe("PlayTracker", func() {
})
})
var _ = DescribeTable("remainingTTL",
func(durationSec float32, positionMs int64, rate float64, expected time.Duration) {
Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected))
},
Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second),
Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second),
Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second),
Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second),
Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second),
Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second),
Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second),
Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second),
Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second),
Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second),
Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second),
Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second),
)
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 {
@ -577,17 +1093,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load()
}
func (f *fakeScrobbler) GetPosition() int {
return int(f.position.Load())
}
func (f *fakeScrobbler) GetUsername() string {
if p := f.username.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
@ -623,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 {
@ -678,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)
}

View 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
}
}
}

130
core/sonic/sonic.go Normal file
View File

@ -0,0 +1,130 @@
package sonic
import (
"context"
"fmt"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const capabilitySonicSimilarity = "SonicSimilarity"
type SimilarResult struct {
Song agents.Song
Similarity float64
}
type SimilarMatch struct {
MediaFile model.MediaFile
Similarity float64
}
type Provider interface {
GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error)
FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error)
}
type PluginLoader interface {
PluginNames(capability string) []string
LoadSonicSimilarity(name string) (Provider, bool)
}
type Sonic struct {
ds model.DataStore
pluginLoader PluginLoader
matcher *matcher.Matcher
}
func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic {
return &Sonic{
ds: ds,
pluginLoader: pluginLoader,
matcher: matcher,
}
}
func (s *Sonic) HasProvider() bool {
return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0
}
func (s *Sonic) loadProvider() (Provider, error) {
names := s.pluginLoader.PluginNames(capabilitySonicSimilarity)
if len(names) == 0 {
return nil, model.ErrNotFound
}
provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0])
if !ok {
return nil, model.ErrNotFound
}
return provider, nil
}
func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) {
songs := make([]agents.Song, len(results))
for i, r := range results {
songs[i] = r.Song
}
matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs)
if err != nil {
return nil, fmt.Errorf("matching songs to library: %w", err)
}
var matches []SimilarMatch
for i, r := range results {
if mf, ok := matchMap[i]; ok {
matches = append(matches, SimilarMatch{
MediaFile: mf,
Similarity: r.Similarity,
})
}
}
return matches, nil
}
func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) {
provider, err := s.loadProvider()
if err != nil {
return nil, err
}
mf, err := s.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, fmt.Errorf("getting media file %s: %w", id, err)
}
results, err := provider.GetSonicSimilarTracks(ctx, mf, count)
if err != nil {
log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err)
return nil, err
}
return s.resolveMatches(ctx, results)
}
func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) {
provider, err := s.loadProvider()
if err != nil {
return nil, err
}
startMF, err := s.ds.MediaFile(ctx).Get(startID)
if err != nil {
return nil, fmt.Errorf("getting start media file %s: %w", startID, err)
}
endMF, err := s.ds.MediaFile(ctx).Get(endID)
if err != nil {
return nil, fmt.Errorf("getting end media file %s: %w", endID, err)
}
results, err := provider.FindSonicPath(ctx, startMF, endMF, count)
if err != nil {
log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err)
return nil, err
}
return s.resolveMatches(ctx, results)
}

View File

@ -0,0 +1,17 @@
package sonic_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSonic(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Sonic Suite")
}

146
core/sonic/sonic_test.go Normal file
View File

@ -0,0 +1,146 @@
package sonic_test
import (
"context"
"errors"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockPluginLoader struct {
names []string
provider sonic.Provider
loadOk bool
}
func (m *mockPluginLoader) PluginNames(capability string) []string {
if capability == "SonicSimilarity" {
return m.names
}
return nil
}
func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
return m.provider, m.loadOk
}
type mockProvider struct {
similarResults []sonic.SimilarResult
similarErr error
pathResults []sonic.SimilarResult
pathErr error
}
func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
return m.similarResults, m.similarErr
}
func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
return m.pathResults, m.pathErr
}
var _ = Describe("Sonic", func() {
var (
ctx context.Context
ds *tests.MockDataStore
loader *mockPluginLoader
service *sonic.Sonic
)
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{}
loader = &mockPluginLoader{}
})
Describe("HasProvider", func() {
It("returns false when no plugins available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
Expect(service.HasProvider()).To(BeFalse())
})
It("returns true when a plugin is available", func() {
loader.names = []string{"test-plugin"}
service = sonic.New(ds, loader, nil)
Expect(service.HasProvider()).To(BeTrue())
})
})
Describe("GetSonicSimilarTracks", func() {
It("returns error when no plugin available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
_, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns error when media file not found", func() {
loader.names = []string{"test-plugin"}
loader.provider = &mockProvider{}
loader.loadOk = true
ds.MockedMediaFile = &tests.MockMediaFileRepo{}
service = sonic.New(ds, loader, matcher.New(ds))
_, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10)
Expect(err).To(HaveOccurred())
})
It("returns matched results from plugin", func() {
mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"}
mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"}
mockRepo := tests.CreateMockMediaFileRepo()
mockRepo.SetData(model.MediaFiles{mf1, mf2})
ds.MockedMediaFile = mockRepo
provider := &mockProvider{
similarResults: []sonic.SimilarResult{
{Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85},
},
}
loader.names = []string{"test-plugin"}
loader.provider = provider
loader.loadOk = true
service = sonic.New(ds, loader, matcher.New(ds))
matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
Expect(err).ToNot(HaveOccurred())
Expect(matches).To(HaveLen(1))
Expect(matches[0].MediaFile.ID).To(Equal("song-2"))
Expect(matches[0].Similarity).To(Equal(0.85))
})
})
Describe("FindSonicPath", func() {
It("returns error when no plugin available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
_, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns error when plugin call fails", func() {
mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"}
mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"}
mockRepo := tests.CreateMockMediaFileRepo()
mockRepo.SetData(model.MediaFiles{mf1, mf2})
ds.MockedMediaFile = mockRepo
provider := &mockProvider{pathErr: errors.New("plugin error")}
loader.names = []string{"test-plugin"}
loader.provider = provider
loader.loadOk = true
service = sonic.New(ds, loader, matcher.New(ds))
_, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -59,18 +59,6 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile,
decision.SourceStream = buildSourceStream(mf, probe)
src := &decision.SourceStream
// Check for server-side player transcoding override
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
modified := *clientInfo
modified.MaxAudioBitrate = player.MaxBitRate
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}
}
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
"codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
"sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)

View File

@ -1042,8 +1042,8 @@ var _ = Describe("Decider", func() {
})
})
Context("Server-side player transcoding override", func() {
It("forces transcoding when override targets a different format", func() {
Context("Server-side context is ignored by MakeDecision", func() {
It("ignores transcoding override in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
@ -1051,148 +1051,21 @@ var _ = Describe("Decider", func() {
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
// Set server override in context
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TargetBitrate).To(Equal(192))
})
It("allows direct play when source matches forced format and bitrate is within cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
Expect(decision.CanTranscode).To(BeFalse())
})
It("transcodes when source bitrate exceeds the forced cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
}
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TargetBitrate).To(Equal(192))
})
It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
}
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TargetBitrate).To(Equal(320))
})
It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
}
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
// With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
Expect(decision.TargetBitrate).To(Equal(160))
})
It("does not apply override when no transcoding is in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
// No override in context — client profiles used as-is
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
})
Context("Player MaxBitRate cap", func() {
It("applies player MaxBitRate cap when client has no limit", func() {
It("ignores player MaxBitRate in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}},
},
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
// Source bitrate 1000 > player cap 320, so direct play is not possible
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
// Lossless→lossy should use MaxAudioBitrate (320) as target, not format default
Expect(decision.TargetBitrate).To(Equal(320))
})
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
Name: "TestClient",
MaxAudioBitrate: 256,
MaxTranscodingAudioBitrate: 256,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
// Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins
Expect(decision.TargetBitrate).To(Equal(256))
})
It("does not cap when player MaxBitRate is 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
},
}
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())

View File

@ -7,12 +7,11 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
// into a ClientInfo for use with MakeDecision.
// It does NOT read request.TranscodingFrom(ctx) — that is handled by
// MakeDecision's applyServerOverride.
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
ci := &ClientInfo{Name: "legacy"}
@ -65,6 +64,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
// Apply server-side player transcoding override before making the decision
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
modified := *clientInfo
modified.MaxAudioBitrate = player.MaxBitRate
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}
}
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
if err != nil {
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)

View File

@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -187,6 +188,109 @@ var _ = Describe("ResolveRequest", func() {
Expect(req.Offset).To(Equal(30))
})
Context("Server-side player transcoding override", func() {
It("forces transcoding when override targets a different format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(192))
})
It("allows direct play when source matches forced format and bitrate is within cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
It("transcodes when source bitrate exceeds the forced cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(192))
})
It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(320))
})
It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
// With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
Expect(req.BitRate).To(Equal(160))
})
It("does not apply override when no transcoding is in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
})
Context("Player MaxBitRate cap", func() {
It("applies player MaxBitRate cap when client has no limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(320))
})
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(256))
})
It("does not cap when player MaxBitRate is 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
})
Context("fallback for unknown format", func() {
It("falls back to DefaultDownsamplingFormat", func() {
DeferCleanup(configtest.SetupConfig())

27
go.mod
View File

@ -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
@ -9,7 +9,6 @@ require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.3
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933
@ -35,15 +34,16 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/kardianos/service v1.2.4
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/mattn/go-sqlite3 v1.14.42
github.com/lestrrat-go/jwx/v3 v3.1.0
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.1
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.0
github.com/pressly/goose/v3 v3.27.1
github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.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
@ -112,10 +112,9 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ogier/pflag v0.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
@ -139,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
)

67
go.sum
View File

@ -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=
@ -16,8 +16,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@ -65,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=
@ -127,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=
@ -141,8 +139,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -169,16 +167,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw=
github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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-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.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=
@ -195,31 +193,30 @@ 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.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/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=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@ -321,8 +318,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -414,19 +409,19 @@ 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=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

View File

@ -2,83 +2,95 @@ package criteria
import "strings"
// FieldInfo contains semantic metadata about a criteria field
// FieldInfo contains semantic metadata about a criteria field.
type FieldInfo struct {
Name string
Alias string // If set, this field is a backward-compat alias for another canonical name
IsTag bool
IsRole bool
Numeric bool
alias string
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
}
// Name returns the canonical field name (the map key used to register this field).
func (f FieldInfo) Name() string {
return f.name
}
var fieldMap = map[string]FieldInfo{
"title": {Name: "title"},
"album": {Name: "album"},
"hascoverart": {Name: "hascoverart"},
"tracknumber": {Name: "tracknumber"},
"discnumber": {Name: "discnumber"},
"year": {Name: "year"},
"date": {Name: "date", alias: "recordingdate"},
"originalyear": {Name: "originalyear"},
"originaldate": {Name: "originaldate"},
"releaseyear": {Name: "releaseyear"},
"releasedate": {Name: "releasedate"},
"size": {Name: "size"},
"compilation": {Name: "compilation"},
"missing": {Name: "missing"},
"explicitstatus": {Name: "explicitstatus"},
"dateadded": {Name: "dateadded"},
"datemodified": {Name: "datemodified"},
"discsubtitle": {Name: "discsubtitle"},
"comment": {Name: "comment"},
"lyrics": {Name: "lyrics"},
"sorttitle": {Name: "sorttitle"},
"sortalbum": {Name: "sortalbum"},
"sortartist": {Name: "sortartist"},
"sortalbumartist": {Name: "sortalbumartist"},
"albumcomment": {Name: "albumcomment"},
"catalognumber": {Name: "catalognumber"},
"filepath": {Name: "filepath"},
"filetype": {Name: "filetype"},
"codec": {Name: "codec"},
"duration": {Name: "duration"},
"bitrate": {Name: "bitrate"},
"bitdepth": {Name: "bitdepth"},
"samplerate": {Name: "samplerate"},
"bpm": {Name: "bpm"},
"channels": {Name: "channels"},
"loved": {Name: "loved"},
"dateloved": {Name: "dateloved"},
"lastplayed": {Name: "lastplayed"},
"daterated": {Name: "daterated"},
"playcount": {Name: "playcount"},
"rating": {Name: "rating"},
"averagerating": {Name: "averagerating", Numeric: true},
"albumrating": {Name: "albumrating"},
"albumloved": {Name: "albumloved"},
"albumplaycount": {Name: "albumplaycount"},
"albumlastplayed": {Name: "albumlastplayed"},
"albumdateloved": {Name: "albumdateloved"},
"albumdaterated": {Name: "albumdaterated"},
"artistrating": {Name: "artistrating"},
"artistloved": {Name: "artistloved"},
"artistplaycount": {Name: "artistplaycount"},
"artistlastplayed": {Name: "artistlastplayed"},
"artistdateloved": {Name: "artistdateloved"},
"artistdaterated": {Name: "artistdaterated"},
"mbz_album_id": {Name: "mbz_album_id"},
"mbz_album_artist_id": {Name: "mbz_album_artist_id"},
"mbz_artist_id": {Name: "mbz_artist_id"},
"mbz_recording_id": {Name: "mbz_recording_id"},
"mbz_release_track_id": {Name: "mbz_release_track_id"},
"mbz_release_group_id": {Name: "mbz_release_group_id"},
"library_id": {Name: "library_id", Numeric: true},
"title": {},
"album": {},
"hascoverart": {Boolean: true},
"tracknumber": {},
"discnumber": {},
"year": {},
"date": {tagAlias: "recordingdate"},
"originalyear": {},
"originaldate": {},
"releaseyear": {},
"releasedate": {},
"size": {},
"compilation": {Boolean: true},
"missing": {Boolean: true},
"explicitstatus": {},
"dateadded": {},
"datemodified": {},
"discsubtitle": {},
"comment": {},
"lyrics": {},
"sorttitle": {},
"sortalbum": {},
"sortartist": {},
"sortalbumartist": {},
"albumcomment": {},
"catalognumber": {},
"filepath": {},
"filetype": {},
"codec": {},
"duration": {},
"bitrate": {},
"bitdepth": {},
"samplerate": {},
"bpm": {},
"channels": {},
"loved": {Boolean: true},
"dateloved": {},
"lastplayed": {},
"daterated": {},
"playcount": {},
"rating": {},
"averagerating": {Numeric: true},
"albumrating": {},
"albumloved": {Boolean: true},
"albumplaycount": {},
"albumlastplayed": {},
"albumdateloved": {},
"albumdaterated": {},
"artistrating": {},
"artistloved": {Boolean: true},
"artistplaycount": {},
"artistlastplayed": {},
"artistdateloved": {},
"artistdaterated": {},
"mbz_album_id": {},
"mbz_album_artist_id": {},
"mbz_artist_id": {},
"mbz_recording_id": {},
"mbz_release_track_id": {},
"mbz_release_group_id": {},
"rgalbumgain": {Numeric: true},
"rgalbumpeak": {Numeric: true},
"rgtrackgain": {Numeric: true},
"rgtrackpeak": {Numeric: true},
"library_id": {Numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {Name: "releasetype", IsTag: true},
"albumtype": {Alias: "releasetype", IsTag: true},
"random": {Name: "random"},
"value": {Name: "value"},
// Pseudo-field for random sorting
"random": {},
}
// AllFieldNames returns the names of all registered criteria fields.
@ -92,7 +104,15 @@ func AllFieldNames() []string {
// LookupField returns semantic metadata for a criteria field name.
func LookupField(name string) (FieldInfo, bool) {
f, ok := fieldMap[strings.ToLower(name)]
key := strings.ToLower(name)
f, ok := fieldMap[key]
if ok {
if f.Alias != "" {
f.name = f.Alias
} else {
f.name = key
}
}
return f, ok
}
@ -104,7 +124,7 @@ func AddRoles(roles []string) {
if _, ok := fieldMap[name]; ok {
continue
}
fieldMap[name] = FieldInfo{Name: name, IsRole: true}
fieldMap[name] = FieldInfo{IsRole: true}
}
}
@ -116,14 +136,16 @@ func AddTagNames(tagNames []string) {
if _, ok := fieldMap[name]; ok {
continue
}
for _, fm := range fieldMap {
if fm.alias == name {
for key, fm := range fieldMap {
if fm.tagAlias == name {
fm.Alias = key
fm.tagAlias = ""
fieldMap[name] = fm
break
}
}
if _, ok := fieldMap[name]; !ok {
fieldMap[name] = FieldInfo{Name: name, IsTag: true}
fieldMap[name] = FieldInfo{IsTag: true}
}
}
}
@ -136,7 +158,7 @@ func AddNumericTags(tagNames []string) {
fm.Numeric = true
fieldMap[name] = fm
} else {
fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true}
fieldMap[name] = FieldInfo{IsTag: true, Numeric: true}
}
}
}

View File

@ -11,31 +11,24 @@ var _ = Describe("fields", func() {
field, ok := LookupField("Title")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"}))
gomega.Expect(field.Name()).To(gomega.Equal("title"))
})
It("resolves aliases to their semantic field name", func() {
It("resolves aliases to their canonical field name", func() {
field, ok := LookupField("albumtype")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("releasetype"))
gomega.Expect(field.Name()).To(gomega.Equal("releasetype"))
gomega.Expect(field.IsTag).To(gomega.BeTrue())
})
It("finds special fields", func() {
field, ok := LookupField("value")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("value"))
})
It("finds registered tag names", func() {
AddTagNames([]string{"task3_mood"})
field, ok := LookupField("task3_mood")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("task3_mood"))
gomega.Expect(field.Name()).To(gomega.Equal("task3_mood"))
gomega.Expect(field.IsTag).To(gomega.BeTrue())
})
@ -56,8 +49,9 @@ var _ = Describe("fields", func() {
field, ok := LookupField("task3_producer")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("task3_producer"))
gomega.Expect(field.Name()).To(gomega.Equal("task3_producer"))
gomega.Expect(field.IsRole).To(gomega.BeTrue())
})
})
})

View File

@ -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)
@ -69,10 +71,48 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
return InPlaylist(m)
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)

View File

@ -162,6 +162,22 @@ func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) {
func (nipl NotInPlaylist) fields() map[string]any { return nipl }
type IsMissing map[string]any
func (im IsMissing) MarshalJSON() ([]byte, error) {
return marshalExpression("isMissing", im)
}
func (im IsMissing) fields() map[string]any { return im }
type IsPresent map[string]any
func (ip IsPresent) MarshalJSON() ([]byte, error) {
return marshalExpression("isPresent", ip)
}
func (ip IsPresent) fields() map[string]any { return ip }
func extractPlaylistIds(inputRule any) (ids []string) {
var id string
var ok bool

View File

@ -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}}`),
@ -46,5 +47,83 @@ var _ = Describe("Operators", func() {
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`),
Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`),
Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`),
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}))
})
})
})

View File

@ -43,7 +43,7 @@ func (c Criteria) OrderByFields() []SortField {
if order == "desc" {
desc = !desc
}
fields = append(fields, SortField{Field: info.Name, Desc: desc})
fields = append(fields, SortField{Field: info.Name(), Desc: desc})
}
if len(fields) == 0 {
log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue)

View File

@ -24,7 +24,7 @@ func Walk(expr Expression, visit Visitor) error {
return err
}
}
case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist:
case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent:
return nil
default:
return fmt.Errorf("unknown criteria expression type %T", expr)

View File

@ -111,9 +111,12 @@ var smartPlaylistFields = map[string]smartPlaylistField{
"mbz_recording_id": {expr: "media_file.mbz_recording_id"},
"mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
"mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
"rgalbumgain": {expr: "media_file.rg_album_gain"},
"rgalbumpeak": {expr: "media_file.rg_album_peak"},
"rgtrackgain": {expr: "media_file.rg_track_gain"},
"rgtrackpeak": {expr: "media_file.rg_track_peak"},
"library_id": {expr: "media_file.library_id"},
"random": {order: "random()"},
"value": {expr: "value"},
}
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
@ -185,6 +188,10 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz
return c.inList(e, false)
case criteria.NotInPlaylist:
return c.inList(e, true)
case criteria.IsMissing:
return missingExpr(e, true)
case criteria.IsPresent:
return missingExpr(e, false)
default:
return nil, fmt.Errorf("unknown criteria expression type %T", expr)
}
@ -201,6 +208,26 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) {
return squirrel.NotEq(fields), nil
}
func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) {
field, value, info, ok := singleField(values)
if !ok {
if len(values) != 1 {
return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field")
}
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
if !info.IsTag && !info.IsRole {
return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field)
}
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
}
func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil
@ -314,9 +341,9 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
if info.IsRole {
return roleCond{role: info.Name, cond: cond, not: negate}
return roleCond{role: info.Name(), cond: cond, not: negate}
}
return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate}
return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate}
}
type tagCond struct {
@ -327,11 +354,18 @@ type tagCond struct {
}
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
if e.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
var cond string
var args []any
var err error
if e.cond != nil {
cond, args, err = e.cond.ToSql()
if e.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
}
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
} else {
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag)
}
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
if e.not {
cond = "not " + cond
}
@ -345,8 +379,15 @@ type roleCond struct {
}
func (e roleCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond)
var cond string
var args []any
var err error
if e.cond != nil {
cond, args, err = e.cond.ToSql()
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond)
} else {
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role)
}
if e.not {
cond = "not " + cond
}
@ -374,7 +415,7 @@ func sqlFields(values map[string]any) (map[string]any, error) {
if info.IsTag || info.IsRole {
return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
}
sqlField, ok := fieldExpr(info.Name)
sqlField, ok := fieldExpr(info.Name())
if !ok || sqlField == "" {
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
@ -393,7 +434,7 @@ func fieldJoinType(name string) smartPlaylistJoinType {
if !ok {
return smartPlaylistJoinNone
}
field, ok := smartPlaylistFields[info.Name]
field, ok := smartPlaylistFields[info.Name()]
if !ok {
return smartPlaylistJoinNone
}
@ -441,17 +482,17 @@ func sortExpr(sortField string) (string, bool) {
if !ok {
return "", false
}
if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" {
if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" {
return field.order, true
}
var mapped string
switch {
case info.IsTag:
mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')"
mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')"
case info.IsRole:
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')"
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
default:
field, ok := smartPlaylistFields[info.Name]
field, ok := smartPlaylistFields[info.Name()]
if !ok || field.expr == "" {
return "", false
}

View File

@ -59,6 +59,30 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"),
Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
// ReplayGain fields
Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0),
Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0),
Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0),
// isMissing — tags
Entry("isMissing tag [true]", criteria.IsMissing{"genre": true},
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
Entry("isMissing tag [false]", criteria.IsMissing{"genre": false},
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
// isMissing — roles
Entry("isMissing role [true]", criteria.IsMissing{"artist": true},
"not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"),
Entry("isMissing role [false]", criteria.IsMissing{"artist": false},
"exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"),
// isPresent — tags
Entry("isPresent tag [true]", criteria.IsPresent{"genre": true},
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
Entry("isPresent tag [false]", criteria.IsPresent{"genre": false},
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
// isPresent — roles
Entry("isPresent role [true]", criteria.IsPresent{"composer": true},
"exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"),
Entry("isPresent role [false]", criteria.IsPresent{"composer": false},
"not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"),
)
Describe("playlist permissions", func() {
@ -115,6 +139,21 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Expect(err).To(MatchError("invalid field in criteria: unknown"))
})
It("returns an error when isMissing is used with a regular field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where()
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields")))
})
It("returns an error when isPresent is used with a regular field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where()
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"))
@ -160,8 +199,8 @@ var _ = Describe("Smart playlist criteria SQL", func() {
if info.IsTag || info.IsRole {
continue
}
_, hasSQLField := smartPlaylistFields[info.Name]
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name)
_, hasSQLField := smartPlaylistFields[info.Name()]
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name())
}
})

View File

@ -116,9 +116,9 @@ func buildTestFS() {
fs := storagetest.FakeFS{}
fs.SetFiles(fstest.MapFS{
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
_t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120})),
_t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
_t{"genre": "Rock", "composer": "Harrison", "bpm": 100})),
_t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})),
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven",
_t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac",
"bitrate": 900, "samplerate": 44100, "bitdepth": 16})),

View File

@ -330,4 +330,45 @@ var _ = Describe("Smart Playlists", func() {
})
})
Describe("isMissing/isPresent operators", func() {
It("isMissing finds tracks without grouping tag", func() {
results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("isMissing false finds tracks with grouping tag", func() {
results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something"))
})
It("isPresent finds tracks with grouping tag", func() {
results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something"))
})
It("isPresent false finds tracks without grouping tag", func() {
results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("isMissing returns all tracks for a tag nobody has", func() {
results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("isPresent returns all tracks for a role everyone has", func() {
results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("combines isMissing with other operators", func() {
results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`)
Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower"))
})
})
})

View File

@ -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() {

View File

@ -1,8 +1,8 @@
# Navidrome Plugin System
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access.
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access.
The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means you can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
**Essential Extism Resources:**
- [Extism Documentation](https://extism.org/docs/overview) Core concepts and architecture
@ -19,12 +19,18 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag
- [Capabilities](#capabilities)
- [MetadataAgent](#metadataagent)
- [Scrobbler](#scrobbler)
- [Lyrics](#lyrics)
- [SonicSimilarity](#sonicsimilarity)
- [TaskWorker](#taskworker)
- [Lifecycle](#lifecycle)
- [SchedulerCallback](#schedulercallback)
- [WebSocketCallback](#websocketcallback)
- [Host Services](#host-services)
- [HTTP Requests](#http-requests)
- [HTTP](#http)
- [Scheduler](#scheduler)
- [Cache](#cache)
- [KVStore](#kvstore)
- [Task](#task)
- [WebSocket](#websocket)
- [Library](#library)
- [Artwork](#artwork)
@ -95,14 +101,6 @@ A Navidrome plugin is an `.ndp` package file (zip archive) containing:
1. **`manifest.json`** Plugin metadata (name, author, version, permissions)
2. **`plugin.wasm`** Compiled WebAssembly module with capability functions
### Plugin Package Structure
```
my-plugin.ndp (zip archive)
├── manifest.json # Required: Plugin metadata
└── plugin.wasm # Required: Compiled WebAssembly module
```
### Plugin Naming
Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field:
@ -123,6 +121,10 @@ Every plugin must include a `manifest.json` file. Example:
"version": "1.0.0",
"description": "What this plugin does",
"website": "https://example.com",
"config": {
"schema": { ... },
"uiSchema": { ... }
},
"permissions": {
"http": {
"reason": "Fetch metadata from external API",
@ -134,6 +136,30 @@ Every plugin must include a `manifest.json` file. Example:
**Required fields:** `name`, `author`, `version`
**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental`
#### Config Definition
The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI:
```json
{
"config": {
"schema": {
"type": "object",
"properties": {
"api_key": { "type": "string", "title": "API Key" },
"max_retries": { "type": "integer", "default": 3 }
},
"required": ["api_key"]
},
"uiSchema": {
"api_key": { "ui:widget": "password" }
}
}
}
```
#### Experimental Features
Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
@ -142,9 +168,6 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re
```json
{
"name": "Threaded Plugin",
"author": "Author Name",
"version": "1.0.0",
"experimental": {
"threads": {
"reason": "Required for concurrent audio processing"
@ -159,50 +182,25 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re
## Capabilities
Capabilities define what your plugin can do. They're automatically detected based on which functions you export.
Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities.
### MetadataAgent
Provides artist and album metadata. Export one or more of these functions:
Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports.
| Function | Input | Output | Description |
|---------------------------|----------------------------|----------------------------------|----------------------|
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
**Example:**
```go
type ArtistInput struct {
ID string `json:"id"`
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
}
type BiographyOutput struct {
Biography string `json:"biography"`
}
//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
// Fetch biography from your data source...
output := BiographyOutput{Biography: "Artist biography..."}
pdk.OutputJSON(output)
return 0
}
```
| Function | Input | Output | Description |
|-----------------------------------|----------------------------|----------------------------------|--------------------------|
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track |
| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album |
| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist |
To use the plugin as a metadata agent, add it to your config:
@ -210,17 +208,49 @@ To use the plugin as a metadata agent, add it to your config:
Agents = "lastfm,spotify,my-plugin"
```
**Example (using Go PDK package):**
```go
package main
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
type myPlugin struct{}
func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
}
func init() { metadata.Register(&myPlugin{}) }
func main() {}
```
**Example (raw wasmexport):**
```go
//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."})
return 0
}
```
### Scrobbler
Integrates with external scrobbling services. Export one or more of these functions:
Integrates with external scrobbling services. All three methods are **required**.
| Function | Input | Output | Description |
|------------------------------|-----------------------|----------------|-----------------------------|
| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized |
| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
| Function | Input | Output | Description |
|------------------------------|-----------------------|--------|-----------------------------|
| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized |
| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes.
> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration.
**Manifest permission:**
@ -267,31 +297,95 @@ On success, return `0`. On failure, use `pdk.SetError()` with one of these error
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
// Return error using predefined constants
return scrobbler.ScrobblerErrorNotAuthorized
return scrobbler.ScrobblerErrorRetryLater
return scrobbler.ScrobblerErrorUnrecoverable
```
### Lyrics
Provides lyrics for tracks. The single method is **required**.
| Function | Input | Output | Description |
|-------------------------|-------------------------------|------------------------------------|-----------------|
| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics |
Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages.
### SonicSimilarity
Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**.
| Function | Input | Output | Description |
|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------|
| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks |
| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs |
Each match contains a `song` reference and a `similarity` score (float64, 0.01.0).
### TaskWorker
Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work.
| Function | Input | Output | Description |
|---------------------|---------------------------------------------|---------|----------------------|
| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task|
The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success.
### Lifecycle
Optional initialization callback. Export this function to run code when your plugin loads:
Optional initialization callback. Called once after the plugin fully loads.
| Function | Input | Output | Description |
|--------------|-------|------------|--------------------------------|
| `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads |
Useful for initializing connections, scheduling recurring tasks, etc.
Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading.
### SchedulerCallback
Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service.
| Function | Input | Output | Description |
|---------------------------|----------------------------------------------|--------|-----------------------------|
| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event |
### WebSocketCallback
Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service.
| Function | Input | Description |
|----------------------------------|---------------------------------|----------------------------------|
| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
---
## Host Services
Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest.
Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest.
### HTTP Requests
### Go PDK Setup
Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests.
All host service examples below use the generated Go SDK. Add this to your `go.mod`:
```
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
```
Then import:
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
```
### HTTP
Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control.
**Manifest permission:**
@ -306,22 +400,28 @@ Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extis
}
```
**Host functions:**
| Function | Parameters | Returns |
|-------------|----------------------------------------------------------|----------------------------------|
| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` |
**Usage:**
```go
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data")
req.SetHeader("Authorization", "Bearer " + apiKey)
resp := req.Send()
if resp.Status() == 200 {
data := resp.Body()
// Process response...
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "GET",
URL: "https://api.example.com/data",
Headers: map[string]string{"Authorization": "Bearer " + apiKey},
})
if resp.StatusCode == 200 {
// Process resp.Body
}
```
### Scheduler
Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` to receive events.
Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events.
**Manifest permission:**
@ -343,40 +443,9 @@ Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_call
| `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback |
| `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task |
**Callback function:**
**Usage:**
```go
type SchedulerCallbackInput struct {
ScheduleID string `json:"scheduleId"`
Payload string `json:"payload"`
IsRecurring bool `json:"isRecurring"`
}
//go:wasmexport nd_scheduler_callback
func ndSchedulerCallback() int32 {
var input SchedulerCallbackInput
pdk.InputJSON(&input)
// Handle the scheduled task based on payload
pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID)
return 0
}
```
**Scheduling tasks (using generated SDK):**
Add the generated SDK to your `go.mod`:
```
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
```
Then import and use:
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Schedule one-time task in 60 seconds
scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "")
@ -389,7 +458,7 @@ err := host.SchedulerCancelSchedule(scheduleID)
### Cache
Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace.
In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart.
**Manifest permission:**
@ -420,28 +489,22 @@ Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own
**TTL:** Pass `0` for the default (24 hours), or specify seconds.
**Usage (with generated SDK):**
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Cache a value for 1 hour
host.CacheSetString("api-response", responseData, 3600)
// Retrieve (check Exists before using Value)
result, err := host.CacheGetString("api-response")
if result.Exists {
data := result.Value
// Retrieve (returns value, exists, error)
value, exists, err := host.CacheGetString("api-response")
if exists {
// Use value
}
```
> **Note:** Cache is in-memory only and cleared on server restart.
### KVStore
Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database.
Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
**Manifest permission:**
@ -456,61 +519,101 @@ Persistent key-value storage that survives server restarts. Each plugin has its
}
```
**Permission options:**
- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB
**Key constraints:** Maximum 256 bytes, must be valid UTF-8.
**Host functions:**
| Function | Parameters | Description |
|--------------------------|--------------|-----------------------------------|
| `kvstore_set` | `key, value` | Store a byte value |
| `kvstore_get` | `key` | Retrieve a byte value |
| `kvstore_delete` | `key` | Delete a value |
| `kvstore_has` | `key` | Check if key exists |
| `kvstore_list` | `prefix` | List keys matching prefix |
| `kvstore_getstorageused` | - | Get current storage usage (bytes) |
| Function | Parameters | Description |
|-----------------------------|--------------------------|-----------------------------------|
| `kvstore_set` | `key, value` | Store a byte value |
| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration |
| `kvstore_get` | `key` | Retrieve a byte value |
| `kvstore_getmany` | `keys` | Retrieve multiple values at once |
| `kvstore_has` | `key` | Check if key exists |
| `kvstore_list` | `prefix` | List keys matching prefix |
| `kvstore_delete` | `key` | Delete a value |
| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix |
| `kvstore_getstorageused` | | Get current storage usage (bytes) |
**Key constraints:**
- Maximum key length: 256 bytes
- Keys must be valid UTF-8 strings
**Usage (with generated SDK):**
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Store a value (as raw bytes)
token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`)
_, err := host.KVStoreSet("oauth:spotify", token)
host.KVStoreSet("oauth:spotify", token)
// Store with TTL (auto-expires after 1 hour)
host.KVStoreSetWithTTL("session:abc", sessionData, 3600)
// Retrieve a value
result, err := host.KVStoreGet("oauth:spotify")
if result.Exists {
value, exists, err := host.KVStoreGet("oauth:spotify")
if exists {
var tokenData map[string]string
json.Unmarshal(result.Value, &tokenData)
json.Unmarshal(value, &tokenData)
}
// List all keys with prefix
keysResult, err := host.KVStoreList("user:")
for _, key := range keysResult.Keys {
// Process each key
}
// Batch retrieve
results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"})
// List and delete by prefix
keys, err := host.KVStoreList("user:")
host.KVStoreDeleteByPrefix("user:")
// Check storage usage
usageResult, err := host.KVStoreGetStorageUsed()
fmt.Printf("Using %d bytes\n", usageResult.Bytes)
// Delete a value
host.KVStoreDelete("oauth:spotify")
usage, err := host.KVStoreGetStorageUsed()
fmt.Printf("Using %d bytes\n", usage)
```
> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
### Task
Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function.
**Manifest permission:**
```json
{
"permissions": {
"taskqueue": {
"reason": "Process audio analysis in the background",
"maxConcurrency": 2
}
}
}
```
**Host functions:**
| Function | Parameters | Description |
|---------------------|---------------------------------------------------|----------------------------|
| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue |
| `task_enqueue` | `queueName, payload` | Add a task to the queue |
| `task_get` | `taskID` | Get task status and result |
| `task_cancel` | `taskID` | Cancel a pending task |
| `task_clearqueue` | `queueName` | Remove all tasks from queue|
**Usage:**
```go
// Create a queue with retry configuration
host.TaskCreateQueue("analysis", host.QueueConfig{
Concurrency: 2,
MaxRetries: 3,
BackoffMs: 1000,
})
// Enqueue a task
taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`))
// Check task status
info, err := host.TaskGet(taskID)
fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt)
```
### WebSocket
Establish persistent WebSocket connections to external services.
Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events.
**Manifest permission:**
@ -527,21 +630,20 @@ Establish persistent WebSocket connections to external services.
**Host functions:**
| Function | Parameters | Description |
|------------------------|---------------------------------|-------------------|
| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
| `websocket_sendtext` | `connectionId, message` | Send text message |
| `websocket_sendbinary` | `connectionId, data` | Send binary data |
| `websocket_close` | `connectionId, code?, reason?` | Close connection |
| Function | Parameters | Description |
|----------------------------|---------------------------------|-------------------|
| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
| `websocket_sendtext` | `connectionId, message` | Send text message |
| `websocket_sendbinary` | `connectionId, data` | Send binary data |
| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection |
**Callback functions (export these to receive events):**
**Usage:**
| Function | Input | Description |
|----------------------------------|---------------------------------|----------------------------------|
| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
```go
connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "")
host.WebSocketSendText(connID, `{"op": 1, "d": null}`)
host.WebSocketCloseConnection(connID, 1000, "done")
```
### Library
@ -595,33 +697,22 @@ When `filesystem: true`, your plugin can read files from library directories via
```go
import "os"
// Read a file from library 1
content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
// List directory contents
entries, err := os.ReadDir("/libraries/1/Artist")
```
> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem.
> **Security:** Filesystem access is read-only and restricted to configured library paths only.
**Usage (with generated SDK):**
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK:
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Get a specific library
resp, err := host.LibraryGetLibrary(1)
if err != nil {
// Handle error
}
library := resp.Result
library, err := host.LibraryGetLibrary(1)
fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs)
// Get all libraries
resp, err := host.LibraryGetAllLibraries()
for _, lib := range resp.Result {
// lib is of type host.Library
libraries, err := host.LibraryGetAllLibraries()
for _, lib := range libraries {
fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs)
}
```
@ -651,6 +742,12 @@ Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists).
| `artwork_gettrackurl` | `id, size` | Artwork URL |
| `artwork_getplaylisturl` | `id, size` | Artwork URL |
**Usage:**
```go
url, err := host.ArtworkGetAlbumUrl("album-id", 300)
```
### SubsonicAPI
Call Navidrome's Subsonic API internally (no network round-trip).
@ -670,24 +767,28 @@ Call Navidrome's Subsonic API internally (no network round-trip).
}
```
> **Important:** The `subsonicapi` permission requires the `users` permission. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API.
> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI.
**Host function:**
**Host functions:**
| Function | Parameters | Returns |
|--------------------|------------|---------------|
| `subsonicapi_call` | `uri` | JSON response |
| Function | Parameters | Returns |
|-----------------------|------------|--------------------------------|
| `subsonicapi_call` | `uri` | JSON response string |
| `subsonicapi_callraw` | `uri` | Content type + binary response |
**Usage:**
```go
// The URI must include the 'u' parameter with the username
response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
// JSON response
response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
// Binary response (e.g., cover art, streams)
contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username")
```
### Config
Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings).
Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys useful for discovering dynamic configuration.
> **Note:** This service is always available and does not require a manifest permission.
@ -699,25 +800,17 @@ Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` wh
| `config_getint` | `key` | `value, exists` |
| `config_keys` | `prefix` | Array of matching key names |
**Usage (with generated SDK):**
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Get a string configuration value
// Get a configuration value
value, exists := host.ConfigGet("api_key")
if exists {
// Use the value
}
// Get an integer configuration value
count, exists := host.ConfigGetInt("max_retries")
// List all keys with a prefix (useful for user-specific config)
keys := host.ConfigKeys("user:")
for _, key := range keys {
// key might be "user:john", "user:jane", etc.
}
// List all configuration keys
allKeys := host.ConfigKeys("")
@ -725,7 +818,7 @@ allKeys := host.ConfigKeys("")
### Users
Access user information for the users that the plugin has been granted access to. This is useful for plugins that need to associate data with specific users or display user information.
Access user information for the users that the plugin has been granted access to.
**Manifest permission:**
@ -739,7 +832,7 @@ Access user information for the users that the plugin has been granted access to
}
```
**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access. This can be done in two ways:
**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access:
1. **Allow all users** Enable the "Allow all users" toggle in the plugin settings
2. **Select specific users** Choose individual users from the user list
@ -751,6 +844,7 @@ If neither option is configured, the plugin cannot be enabled.
| Function | Parameters | Returns |
|------------------|------------|-----------------------|
| `users_getusers` | | Array of User objects |
| `users_getadmins`| | Array of admin Users |
**User object fields:**
@ -762,45 +856,15 @@ If neither option is configured, the plugin cannot be enabled.
> **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins.
**Usage (with generated SDK):**
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Get all users the plugin has access to
users, err := host.UsersGetUsers()
if err != nil {
pdk.Log(pdk.LogError, "Failed to get users: " + err.Error())
return
}
for _, user := range users {
pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")")
if user.IsAdmin {
pdk.Log(pdk.LogInfo, " - Administrator")
}
}
```
**Rust example:**
```rust
use nd_pdk_host::users::get_users;
let users = get_users()?;
for user in users {
println!("User: {} ({})", user.user_name, user.name);
}
```
**Python example:**
```python
from host.nd_host_users import users_get_users
users = users_get_users()
for user in users:
print(f"User: {user['userName']} ({user['name']})")
admins, err := host.UsersGetAdmins()
```
---
@ -834,20 +898,20 @@ if !ok {
}
```
For more advanced access (listing keys, integer values), use the [Config](#config) host service.
---
## Building Plugins
### Supported Languages
Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details.
Plugins can be written in any language that Extism supports. We recommend:
We recommend:
- **Go** Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk)
- **Rust** Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk)
- **Python** Experimental support via [extism-py](https://github.com/extism/python-pdk)
- **TypeScript** Experimental support via [extism-js](https://github.com/extism/js-pdk)
- **Go** Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support.
- **Rust** Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk).
- **Python** Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages.
- **TypeScript** Experimental support via [extism-js](https://github.com/extism/js-pdk).
### Go with TinyGo (Recommended)
@ -863,14 +927,12 @@ zip -j my-plugin.ndp manifest.json plugin.wasm
#### Using Go PDK Packages
Navidrome provides type-safe Go packages for each capability in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
```go
package main
import (
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
)
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
type myPlugin struct{}
@ -878,10 +940,7 @@ func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.A
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
}
func init() {
metadata.Register(&myPlugin{})
}
func init() { metadata.Register(&myPlugin{}) }
func main() {}
```
@ -892,16 +951,19 @@ require github.com/navidrome/navidrome v0.0.0
replace github.com/navidrome/navidrome => ../../..
```
Available capability packages:
**Available capability packages:**
| Package | Import Path | Description |
|-------------|----------------------------|--------------------------------------|
| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks |
| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers |
| `host` | `plugins/pdk/go/host` | Host service SDK (HTTP, cache, etc.) |
| Package | Import Path | Description |
|-------------------|--------------------------------------|--------------------------------------|
| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers |
| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery |
| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing |
| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks |
| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers |
| `host` | `plugins/pdk/go/host` | Host service SDK (all services) |
See the example plugins in [examples/](examples/) for complete usage patterns.
@ -917,8 +979,6 @@ zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm
#### Using Rust PDK
The Rust PDK provides generated type-safe wrappers for both capabilities and host services:
```toml
# Cargo.toml
[dependencies]
@ -953,17 +1013,12 @@ register_scrobbler!(MyPlugin); // Generates all WASM exports
```rust
use nd_pdk::host::{cache, scheduler, library};
// Cache a value for 1 hour
cache::set_string("my_key", "my_value", 3600)?;
// Schedule a recurring task
scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
// Access library metadata
let libs = library::get_all_libraries()?;
```
See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples.
See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation.
### Python (with extism-py)
@ -975,6 +1030,8 @@ extism-py plugin.wasm -o plugin.wasm *.py
zip -j my-plugin.ndp manifest.json plugin.wasm
```
**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations).
### Using XTP CLI (Scaffolding)
Bootstrap a new plugin from a schema:
@ -996,66 +1053,38 @@ zip -j my-agent.ndp manifest.json dist/plugin.wasm
See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples.
### Using Host Service SDKs
Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`.
**For Go plugins:** Import the SDK as a Go module:
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
```
Add to your `go.mod`:
```
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
```
See [pdk/go/README.md](pdk/go/README.md) for detailed documentation.
**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations).
**Recommendations:**
- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem.
- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety.
- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages.
---
## Examples
See [examples/](examples/) for complete working plugins:
| Plugin | Language | Capabilities | Host Services | Description |
|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------|
| [minimal](examples/minimal/) | Go | MetadataAgent | | Basic structure example |
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
| Plugin | Language | Capabilities | Host Services | Description |
|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------|
| [minimal](examples/minimal/) | Go | MetadataAgent | | Basic structure example |
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive |
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
---
## Security
Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime:
1. **Host Allowlisting** Only explicitly allowed hosts are accessible via HTTP/WebSocket
2. **Limited File System** Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only
2. **Limited File System** Read-only access to library directories, only when explicitly granted the `library.filesystem` permission
3. **No Network Listeners** Plugins cannot bind ports
4. **Config Isolation** Plugins only receive their own config section
5. **Memory Limits** Controlled by the WebAssembly runtime
6. **User-Scoped Authorization** Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration. The `users` permission is required for these features.
6. **User-Scoped Authorization** Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration
7. **Users Permission** Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed
---
## Runtime Management
@ -1064,7 +1093,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.
With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive.
If the `AutoReload` setting is disabled, Navidrome needs to be restarted to pick up plugin changes.
If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes.
### Enabling/Disabling Plugins
@ -1074,4 +1103,4 @@ Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persis
- **In-flight requests** When reloading, existing requests complete before the new version takes over
- **Config changes** Changes to the plugin configuration in the UI are applied immediately
- **Cache persistence** The in-memory cache is cleared when a plugin is unloaded
- **Cache persistence** The in-memory cache is cleared when a plugin is unloaded

View File

@ -102,6 +102,12 @@ components:
mbzReleaseTrackId:
type: string
description: MBZReleaseTrackID is the MusicBrainz release track ID.
libraryId:
type: integer
format: int32
description: |-
LibraryID is the ID of the library the track belongs to.
Only included if the plugin has library permission with filesystem access for the track's library.
path:
type: string
description: |-

View File

@ -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

View File

@ -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:
@ -128,6 +172,12 @@ components:
mbzReleaseTrackId:
type: string
description: MBZReleaseTrackID is the MusicBrainz release track ID.
libraryId:
type: integer
format: int32
description: |-
LibraryID is the ID of the library the track belongs to.
Only included if the plugin has library permission with filesystem access for the track's library.
path:
type: string
description: |-

View File

@ -0,0 +1,32 @@
package capabilities
// SonicSimilarity provides audio-similarity based track discovery.
//
//nd:capability name=sonicsimilarity required=true
type SonicSimilarity interface {
//nd:export name=nd_get_sonic_similar_tracks
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
//nd:export name=nd_find_sonic_path
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
}
type GetSonicSimilarTracksRequest struct {
Song SongRef `json:"song"`
Count int32 `json:"count"`
}
type FindSonicPathRequest struct {
StartSong SongRef `json:"startSong"`
EndSong SongRef `json:"endSong"`
Count int32 `json:"count"`
}
type SonicSimilarityResponse struct {
Matches []SonicMatch `json:"matches"`
}
type SonicMatch struct {
Song SongRef `json:"song"`
Similarity float64 `json:"similarity"`
}

View File

@ -0,0 +1,92 @@
version: v1-draft
exports:
nd_get_sonic_similar_tracks:
input:
$ref: '#/components/schemas/GetSonicSimilarTracksRequest'
contentType: application/json
output:
$ref: '#/components/schemas/SonicSimilarityResponse'
contentType: application/json
nd_find_sonic_path:
input:
$ref: '#/components/schemas/FindSonicPathRequest'
contentType: application/json
output:
$ref: '#/components/schemas/SonicSimilarityResponse'
contentType: application/json
components:
schemas:
FindSonicPathRequest:
properties:
startSong:
$ref: '#/components/schemas/SongRef'
endSong:
$ref: '#/components/schemas/SongRef'
count:
type: integer
format: int32
required:
- startSong
- endSong
- count
GetSonicSimilarTracksRequest:
properties:
song:
$ref: '#/components/schemas/SongRef'
count:
type: integer
format: int32
required:
- song
- count
SongRef:
description: SongRef is a reference to a song with metadata for matching.
properties:
id:
type: string
description: ID is the internal Navidrome mediafile ID (if known).
name:
type: string
description: Name is the song name.
mbid:
type: string
description: MBID is the MusicBrainz ID for the song.
isrc:
type: string
description: ISRC is the International Standard Recording Code for the song.
artist:
type: string
description: Artist is the artist name.
artistMbid:
type: string
description: ArtistMBID is the MusicBrainz artist ID.
album:
type: string
description: Album is the album name.
albumMbid:
type: string
description: AlbumMBID is the MusicBrainz release ID.
duration:
type: number
format: float
description: Duration is the song duration in seconds.
required:
- name
SonicMatch:
properties:
song:
$ref: '#/components/schemas/SongRef'
similarity:
type: number
format: float
required:
- song
- similarity
SonicSimilarityResponse:
properties:
matches:
type: array
items:
$ref: '#/components/schemas/SonicMatch'
required:
- matches

View File

@ -21,6 +21,10 @@ func init() {
)
}
func newLyricsPlugin(p *plugin) *LyricsPlugin {
return &LyricsPlugin{name: p.name, plugin: p}
}
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
type LyricsPlugin struct {
name string

View File

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
@ -238,65 +239,32 @@ func (m *Manager) PluginNames(capability string) []string {
return names
}
// LoadMediaAgent loads and returns a media agent plugin by name.
// Returns false if the plugin is not found or doesn't have the MetadataAgent capability.
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
m.mu.RLock()
plugin, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !hasCapability(plugin.capabilities, CapabilityMetadataAgent) {
return nil, false
}
// Create a new metadata agent adapter for this plugin
return &MetadataAgent{
name: plugin.name,
plugin: plugin,
}, true
return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent)
}
// LoadScrobbler loads and returns a scrobbler plugin by name.
// Returns false if the plugin is not found or doesn't have the Scrobbler capability.
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
m.mu.RLock()
plugin, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !hasCapability(plugin.capabilities, CapabilityScrobbler) {
return nil, false
}
// Build user ID map for fast lookups
userIDMap := make(map[string]struct{})
for _, id := range plugin.allowedUserIDs {
userIDMap[id] = struct{}{}
}
// Create a new scrobbler adapter for this plugin with user authorization config
return &ScrobblerPlugin{
name: plugin.name,
plugin: plugin,
allowedUserIDs: plugin.allowedUserIDs,
allUsers: plugin.allUsers,
userIDMap: userIDMap,
}, true
return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin)
}
// LoadLyricsProvider loads and returns a lyrics provider plugin by name.
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin)
}
func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin)
}
func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) {
m.mu.RLock()
plugin, ok := m.plugins[name]
p, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) {
return nil, false
var zero T
if !ok || !hasCapability(p.capabilities, cap) {
return zero, false
}
return &LyricsPlugin{
name: plugin.name,
plugin: plugin,
}, true
return newAdapter(p), true
}
// PluginInfo contains basic information about a plugin for metrics/insights.

View File

@ -138,11 +138,13 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
filesOnDisk := make(map[string]string) // name -> path
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), PackageExtension) {
log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir())
continue
}
name := strings.TrimSuffix(entry.Name(), PackageExtension)
filesOnDisk[name] = filepath.Join(folder, entry.Name())
}
log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk))
// Get all plugins from DB
repo := m.ds.Plugin(adminCtx)
@ -154,6 +156,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
for i := range dbPlugins {
pluginsInDB[dbPlugins[i].ID] = &dbPlugins[i]
}
log.Debug(ctx, "Plugin sync: current DB state", "pluginsInDB", len(pluginsInDB))
now := time.Now()

View File

@ -6,6 +6,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/plugins/capabilities"
"github.com/navidrome/navidrome/utils/slice"
)
// CapabilityMetadataAgent indicates the plugin can provide artist/album metadata.
@ -44,6 +45,10 @@ func init() {
)
}
func newMetadataAgent(p *plugin) *MetadataAgent {
return &MetadataAgent{name: p.name, plugin: p}
}
// MetadataAgent is an adapter that wraps an Extism plugin and implements
// the agents interfaces for metadata retrieval.
type MetadataAgent struct {
@ -222,23 +227,24 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)})
}
// songRefToAgentSong converts a single SongRef to agents.Song
func songRefToAgentSong(s capabilities.SongRef) agents.Song {
return agents.Song{
ID: s.ID,
Name: s.Name,
MBID: s.MBID,
ISRC: s.ISRC,
Artist: s.Artist,
ArtistMBID: s.ArtistMBID,
Album: s.Album,
AlbumMBID: s.AlbumMBID,
Duration: uint32(s.Duration * 1000),
}
}
// songRefsToAgentSongs converts a slice of SongRef to agents.Song
func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song {
songs := make([]agents.Song, len(refs))
for i, s := range refs {
songs[i] = agents.Song{
ID: s.ID,
Name: s.Name,
MBID: s.MBID,
ISRC: s.ISRC,
Artist: s.Artist,
ArtistMBID: s.ArtistMBID,
Album: s.Album,
AlbumMBID: s.AlbumMBID,
Duration: uint32(s.Duration * 1000),
}
}
return songs
return slice.Map(refs, songRefToAgentSong)
}
// Verify interface implementations at compile time

View File

@ -68,6 +68,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`

View File

@ -65,6 +65,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`

View File

@ -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.
@ -92,6 +112,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`
@ -103,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)
@ -111,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.
@ -124,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.
@ -198,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
}

View File

@ -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.
@ -89,6 +109,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`
@ -100,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)
@ -108,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.

View File

@ -0,0 +1,136 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains export wrappers for the SonicSimilarity capability.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package sonicsimilarity
import (
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// FindSonicPathRequest represents the FindSonicPathRequest data structure.
type FindSonicPathRequest struct {
StartSong SongRef `json:"startSong"`
EndSong SongRef `json:"endSong"`
Count int32 `json:"count"`
}
// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
type GetSonicSimilarTracksRequest struct {
Song SongRef `json:"song"`
Count int32 `json:"count"`
}
// SongRef is a reference to a song with metadata for matching.
type SongRef struct {
// ID is the internal Navidrome mediafile ID (if known).
ID string `json:"id,omitempty"`
// Name is the song name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
// ISRC is the International Standard Recording Code for the song.
ISRC string `json:"isrc,omitempty"`
// Artist is the artist name.
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.
AlbumMBID string `json:"albumMbid,omitempty"`
// Duration is the song duration in seconds.
Duration float32 `json:"duration,omitempty"`
}
// SonicMatch represents the SonicMatch data structure.
type SonicMatch struct {
Song SongRef `json:"song"`
Similarity float64 `json:"similarity"`
}
// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
type SonicSimilarityResponse struct {
Matches []SonicMatch `json:"matches"`
}
// SonicSimilarity requires all methods to be implemented.
// SonicSimilarity provides audio-similarity based track discovery.
type SonicSimilarity interface {
// GetSonicSimilarTracks
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
// FindSonicPath
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
} // Internal implementation holders
var (
sonicSimilarTracksImpl func(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
findSonicPathImpl func(FindSonicPathRequest) (SonicSimilarityResponse, error)
)
// Register registers a sonicsimilarity implementation.
// All methods are required.
func Register(impl SonicSimilarity) {
sonicSimilarTracksImpl = impl.GetSonicSimilarTracks
findSonicPathImpl = impl.FindSonicPath
}
// NotImplementedCode is the standard return code for unimplemented functions.
// The host recognizes this and skips the plugin gracefully.
const NotImplementedCode int32 = -2
//go:wasmexport nd_get_sonic_similar_tracks
func _NdGetSonicSimilarTracks() int32 {
if sonicSimilarTracksImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input GetSonicSimilarTracksRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := sonicSimilarTracksImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
return 0
}
//go:wasmexport nd_find_sonic_path
func _NdFindSonicPath() int32 {
if findSonicPathImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input FindSonicPathRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := findSonicPathImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
return 0
}

View File

@ -0,0 +1,71 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file provides stub implementations for non-WASM platforms.
// It allows Go plugins to compile and run tests outside of WASM,
// but the actual functionality is only available in WASM builds.
//
//go:build !wasip1
package sonicsimilarity
// FindSonicPathRequest represents the FindSonicPathRequest data structure.
type FindSonicPathRequest struct {
StartSong SongRef `json:"startSong"`
EndSong SongRef `json:"endSong"`
Count int32 `json:"count"`
}
// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
type GetSonicSimilarTracksRequest struct {
Song SongRef `json:"song"`
Count int32 `json:"count"`
}
// SongRef is a reference to a song with metadata for matching.
type SongRef struct {
// ID is the internal Navidrome mediafile ID (if known).
ID string `json:"id,omitempty"`
// Name is the song name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
// ISRC is the International Standard Recording Code for the song.
ISRC string `json:"isrc,omitempty"`
// Artist is the artist name.
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.
AlbumMBID string `json:"albumMbid,omitempty"`
// Duration is the song duration in seconds.
Duration float32 `json:"duration,omitempty"`
}
// SonicMatch represents the SonicMatch data structure.
type SonicMatch struct {
Song SongRef `json:"song"`
Similarity float64 `json:"similarity"`
}
// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
type SonicSimilarityResponse struct {
Matches []SonicMatch `json:"matches"`
}
// SonicSimilarity requires all methods to be implemented.
// SonicSimilarity provides audio-similarity based track discovery.
type SonicSimilarity interface {
// GetSonicSimilarTracks
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
// FindSonicPath
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
}
// NotImplementedCode is the standard return code for unimplemented functions.
const NotImplementedCode int32 = -2
// Register is a no-op on non-WASM platforms.
// This stub allows code to compile outside of WASM.
func Register(_ SonicSimilarity) {}

View File

@ -10,5 +10,6 @@ pub mod lyrics;
pub mod metadata;
pub mod scheduler;
pub mod scrobbler;
pub mod sonicsimilarity;
pub mod taskworker;
pub mod websocket;

View File

@ -102,6 +102,10 @@ pub struct TrackInfo {
/// MBZReleaseTrackID is the MusicBrainz release track ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_release_track_id: String,
/// LibraryID is the ID of the library the track belongs to.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "is_zero_i32")]
pub library_id: i32,
/// Path is the full path to the track file, relative to the library root.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "String::is_empty")]

View File

@ -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")]
@ -122,6 +151,10 @@ pub struct TrackInfo {
/// MBZReleaseTrackID is the MusicBrainz release track ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_release_track_id: String,
/// LibraryID is the ID of the library the track belongs to.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "is_zero_i32")]
pub library_id: i32,
/// Path is the full path to the track file, relative to the library root.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "String::is_empty")]
@ -154,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>;
@ -162,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.
@ -193,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(())
}
};
}

View File

@ -0,0 +1,141 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains export wrappers for the SonicSimilarity capability.
// It is intended for use in Navidrome plugins built with extism-pdk.
use serde::{Deserialize, Serialize};
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
#[allow(dead_code)]
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
/// FindSonicPathRequest represents the FindSonicPathRequest data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FindSonicPathRequest {
#[serde(default)]
pub start_song: SongRef,
#[serde(default)]
pub end_song: SongRef,
#[serde(default)]
pub count: i32,
}
/// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSonicSimilarTracksRequest {
#[serde(default)]
pub song: SongRef,
#[serde(default)]
pub count: i32,
}
/// SongRef is a reference to a song with metadata for matching.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SongRef {
/// ID is the internal Navidrome mediafile ID (if known).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub id: String,
/// Name is the song name.
#[serde(default)]
pub name: String,
/// MBID is the MusicBrainz ID for the song.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbid: String,
/// ISRC is the International Standard Recording Code for the song.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub isrc: String,
/// Artist is the artist name.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub artist: String,
/// ArtistMBID is the MusicBrainz artist ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub artist_mbid: String,
/// Album is the album name.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub album: String,
/// AlbumMBID is the MusicBrainz release ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub album_mbid: String,
/// Duration is the song duration in seconds.
#[serde(default, skip_serializing_if = "is_zero_f32")]
pub duration: f32,
}
/// SonicMatch represents the SonicMatch data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SonicMatch {
#[serde(default)]
pub song: SongRef,
#[serde(default)]
pub similarity: f64,
}
/// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SonicSimilarityResponse {
#[serde(default)]
pub matches: Vec<SonicMatch>,
}
/// Error represents an error from a capability method.
#[derive(Debug)]
pub struct Error {
pub message: String,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for Error {}
impl Error {
pub fn new(message: impl Into<String>) -> Self {
Self { message: message.into() }
}
}
/// SonicSimilarity requires all methods to be implemented.
/// SonicSimilarity provides audio-similarity based track discovery.
pub trait SonicSimilarity {
/// GetSonicSimilarTracks
fn get_sonic_similar_tracks(&self, req: GetSonicSimilarTracksRequest) -> Result<SonicSimilarityResponse, Error>;
/// FindSonicPath
fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result<SonicSimilarityResponse, Error>;
}
/// Register all exports for the SonicSimilarity capability.
/// This macro generates the WASM export functions for all trait methods.
#[macro_export]
macro_rules! register_sonicsimilarity {
($plugin_type:ty) => {
#[extism_pdk::plugin_fn]
pub fn nd_get_sonic_similar_tracks(
req: extism_pdk::Json<$crate::sonicsimilarity::GetSonicSimilarTracksRequest>
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::sonicsimilarity::SonicSimilarity::get_sonic_similar_tracks(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
#[extism_pdk::plugin_fn]
pub fn nd_find_sonic_path(
req: extism_pdk::Json<$crate::sonicsimilarity::FindSonicPathRequest>
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::sonicsimilarity::SonicSimilarity::find_sonic_path(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
};
}

View File

@ -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,9 +29,24 @@ func init() {
FuncScrobblerIsAuthorized,
FuncScrobblerNowPlaying,
FuncScrobblerScrobble,
FuncScrobblerPlaybackReport,
)
}
func newScrobblerPlugin(p *plugin) *ScrobblerPlugin {
userIDMap := make(map[string]struct{})
for _, id := range p.allowedUserIDs {
userIDMap[id] = struct{}{}
}
return &ScrobblerPlugin{
name: p.name,
plugin: p,
allowedUserIDs: p.allowedUserIDs,
allUsers: p.allUsers,
userIDMap: userIDMap,
}
}
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
// the scrobbler.Scrobbler interface for scrobbling to external services.
type ScrobblerPlugin struct {
@ -168,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)

View File

@ -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")

View File

@ -0,0 +1,92 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/capabilities"
)
const CapabilitySonicSimilarity Capability = "SonicSimilarity"
const (
FuncGetSonicSimilarTracks = "nd_get_sonic_similar_tracks"
FuncFindSonicPath = "nd_find_sonic_path"
)
func init() {
registerCapability(
CapabilitySonicSimilarity,
FuncGetSonicSimilarTracks,
FuncFindSonicPath,
)
}
func newSonicSimilarityPlugin(p *plugin) *SonicSimilarityPlugin {
return &SonicSimilarityPlugin{name: p.name, plugin: p}
}
type SonicSimilarityPlugin struct {
name string
plugin *plugin
}
func (a *SonicSimilarityPlugin) GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
req := capabilities.GetSonicSimilarTracksRequest{
Song: mediaFileToSongRef(mf),
Count: int32(count),
}
resp, err := callPluginFunction[capabilities.GetSonicSimilarTracksRequest, capabilities.SonicSimilarityResponse](
ctx, a.plugin, FuncGetSonicSimilarTracks, req,
)
if err != nil {
return nil, err
}
return sonicMatchesToSimilarResults(resp.Matches), nil
}
func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
req := capabilities.FindSonicPathRequest{
StartSong: mediaFileToSongRef(startMf),
EndSong: mediaFileToSongRef(endMf),
Count: int32(count),
}
resp, err := callPluginFunction[capabilities.FindSonicPathRequest, capabilities.SonicSimilarityResponse](
ctx, a.plugin, FuncFindSonicPath, req,
)
if err != nil {
return nil, err
}
return sonicMatchesToSimilarResults(resp.Matches), nil
}
func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef {
ref := capabilities.SongRef{
ID: mf.ID,
Name: mf.Title,
MBID: mf.MbzRecordingID,
Artist: mf.Artist,
ArtistMBID: mf.MbzArtistID,
Album: mf.Album,
AlbumMBID: mf.MbzAlbumID,
Duration: mf.Duration,
}
if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 {
ref.ISRC = isrcs[0]
}
return ref
}
func sonicMatchesToSimilarResults(matches []capabilities.SonicMatch) []sonic.SimilarResult {
results := make([]sonic.SimilarResult, len(matches))
for i, m := range matches {
results[i] = sonic.SimilarResult{
Song: songRefToAgentSong(m.Song),
Similarity: m.Similarity,
}
}
return results
}
var _ sonic.Provider = (*SonicSimilarityPlugin)(nil)

View File

@ -0,0 +1,110 @@
//go:build !windows
package plugins
import (
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SonicSimilarityPlugin", Ordered, func() {
var (
manager *Manager
provider sonic.Provider
)
BeforeAll(func() {
manager, _ = createTestManagerWithPlugins(nil, "test-sonic-similarity"+PackageExtension)
var ok bool
provider, ok = manager.LoadSonicSimilarity("test-sonic-similarity")
Expect(ok).To(BeTrue())
})
Describe("PluginNames", func() {
It("reports the sonic similarity capability", func() {
names := manager.PluginNames(string(CapabilitySonicSimilarity))
Expect(names).To(ContainElement("test-sonic-similarity"))
})
})
Describe("GetSonicSimilarTracks", func() {
It("returns similar tracks from the plugin", func() {
mf := &model.MediaFile{
ID: "track-1",
Title: "Yesterday",
Artist: "The Beatles",
}
results, err := provider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1"))
Expect(results[0].Song.Artist).To(Equal("The Beatles"))
Expect(results[0].Similarity).To(Equal(1.0))
Expect(results[1].Similarity).To(Equal(0.9))
Expect(results[2].Similarity).To(Equal(0.8))
})
})
Describe("FindSonicPath", func() {
It("returns a path between two tracks from the plugin", func() {
startMf := &model.MediaFile{
ID: "track-1",
Title: "Yesterday",
Artist: "The Beatles",
}
endMf := &model.MediaFile{
ID: "track-2",
Title: "Tomorrow Never Knows",
Artist: "The Beatles",
}
results, err := provider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1"))
Expect(results[0].Song.Artist).To(Equal("The Beatles"))
Expect(results[0].Similarity).To(Equal(1.0))
Expect(results[1].Similarity).To(Equal(0.95))
Expect(results[2].Similarity).To(Equal(0.9))
})
})
})
var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() {
var (
errorManager *Manager
errorProvider sonic.Provider
)
BeforeAll(func() {
errorManager, _ = createTestManagerWithPlugins(map[string]map[string]string{
"test-sonic-similarity": {
"error": "simulated plugin error",
},
}, "test-sonic-similarity"+PackageExtension)
var ok bool
errorProvider, ok = errorManager.LoadSonicSimilarity("test-sonic-similarity")
Expect(ok).To(BeTrue())
})
It("returns error from GetSonicSimilarTracks", func() {
mf := &model.MediaFile{ID: "track-1", Title: "Test"}
_, err := errorProvider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from FindSonicPath", func() {
startMf := &model.MediaFile{ID: "track-1", Title: "Start"}
endMf := &model.MediaFile{ID: "track-2", Title: "End"}
_, err := errorProvider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
})

View File

@ -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"

View File

@ -0,0 +1,16 @@
module test-sonic-similarity
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,71 @@
// Test plugin for Navidrome sonic similarity integration tests.
// Build with: tinygo build -o ../test-sonic-similarity.wasm -target wasip1 -buildmode=c-shared .
package main
import (
"errors"
"strconv"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity"
)
func init() {
sonicsimilarity.Register(&testSonicSimilarity{})
}
type testSonicSimilarity struct{}
func checkConfigError() error {
errMsg, hasErr := pdk.GetConfig("error")
if !hasErr || errMsg == "" {
return nil
}
return errors.New(errMsg)
}
func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSonicSimilarTracksRequest) (sonicsimilarity.SonicSimilarityResponse, error) {
if err := checkConfigError(); err != nil {
return sonicsimilarity.SonicSimilarityResponse{}, err
}
count := int(input.Count)
if count == 0 {
count = 5
}
matches := make([]sonicsimilarity.SonicMatch, 0, count)
for i := range count {
matches = append(matches, sonicsimilarity.SonicMatch{
Song: sonicsimilarity.SongRef{
ID: "similar-track-" + strconv.Itoa(i+1),
Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1),
Artist: input.Song.Artist,
},
Similarity: 1.0 - float64(i)*0.1,
})
}
return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil
}
func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathRequest) (sonicsimilarity.SonicSimilarityResponse, error) {
if err := checkConfigError(); err != nil {
return sonicsimilarity.SonicSimilarityResponse{}, err
}
count := int(input.Count)
if count == 0 {
count = 5
}
matches := make([]sonicsimilarity.SonicMatch, 0, count)
for i := range count {
matches = append(matches, sonicsimilarity.SonicMatch{
Song: sonicsimilarity.SongRef{
ID: "path-track-" + strconv.Itoa(i+1),
Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1),
Artist: input.StartSong.Artist,
},
Similarity: 1.0 - float64(i)*0.05,
})
}
return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil
}
func main() {}

View File

@ -0,0 +1,7 @@
{
"name": "Test Sonic Similarity",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test plugin for sonic similarity integration testing",
"capabilities": ["SonicSimilarity"]
}

View File

@ -180,7 +180,7 @@
"name": "名稱",
"transcodingId": "轉碼",
"maxBitRate": "最大位元率",
"client": "戶端",
"client": "戶端",
"userName": "使用者名稱",
"lastSeen": "上次上線",
"reportRealPath": "回報實際路徑",
@ -333,7 +333,7 @@
}
},
"plugin": {
"name": "插件 |||| 插件",
"name": "外掛 |||| 外掛",
"fields": {
"id": "ID",
"name": "名稱",
@ -359,7 +359,7 @@
},
"sections": {
"status": "狀態",
"info": "插件資訊",
"info": "外掛資訊",
"configuration": "設定",
"manifest": "資訊清單",
"usersPermission": "使用者權限",
@ -379,29 +379,29 @@
"rescan": "重新掃描"
},
"notifications": {
"enabled": "插件已啟用",
"disabled": "插件已停用",
"updated": "插件已更新",
"error": "更新插件時發生錯誤"
"enabled": "外掛已啟用",
"disabled": "外掛已停用",
"updated": "外掛已更新",
"error": "更新外掛時發生錯誤"
},
"validation": {
"invalidJson": "設定必須是有效的 JSON"
},
"messages": {
"configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
"configHelp": "使用鍵值對設定外掛。若外掛無需設定則留空。",
"clickPermissions": "點擊權限以查看詳細資訊",
"noConfig": "無設定",
"allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
"allUsersHelp": "啟用後,外掛將可存取所有使用者,包含未來建立的使用者。",
"noUsers": "未選擇使用者",
"permissionReason": "原因",
"usersRequired": "此插件需要存取使用者資訊。請選擇插件可存取的使用者,或啟用「允許所有使用者」。",
"allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
"usersRequired": "此外掛需要存取使用者資訊。請選擇外掛可存取的使用者,或啟用「允許所有使用者」。",
"allLibrariesHelp": "啟用後,外掛將可存取所有媒體庫,包含未來建立的媒體庫。",
"noLibraries": "未選擇媒體庫",
"librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
"librariesRequired": "此外掛需要存取媒體庫資訊。請選擇外掛可存取的媒體庫,或啟用「允許所有媒體庫」。",
"requiredHosts": "必要的 Hosts",
"configValidationError": "設定驗證失敗:",
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
"allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。"
"schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。",
"allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。"
},
"placeholders": {
"configKey": "鍵",
@ -452,7 +452,7 @@
"delete": "刪除",
"edit": "編輯",
"export": "匯出",
"list": "列表",
"list": "清單",
"refresh": "重新整理",
"remove_filter": "清除此條件",
"remove": "移除",
@ -497,9 +497,9 @@
"upload_single": "拖曳單個圖片上傳或點擊選擇一個"
},
"references": {
"all_missing": "未找到參考數據",
"many_missing": "至少有一條參考數據不再可用",
"single_missing": "關聯的參考數據不再可用"
"all_missing": "未找到參考資料",
"many_missing": "至少有一條參考資料不再可用",
"single_missing": "關聯的參考資料不再可用"
},
"password": {
"toggle_visible": "隱藏密碼",
@ -514,7 +514,7 @@
"delete_content": "您確定要刪除該項目?",
"delete_title": "刪除 %{name} #%{id}",
"details": "詳細資訊",
"error": "發生戶端錯誤,您的請求無法完成",
"error": "發生戶端錯誤,您的請求無法完成",
"invalid_form": "提交內容無效,請檢查錯誤",
"loading": "正在載入頁面,請稍候",
"no": "否",
@ -564,19 +564,19 @@
"delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
"notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
"notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
"lastfmLinkSuccess": "已成功連 Last.fm 並開啟音樂記錄",
"lastfmLinkFailure": "無法連 Last.fm",
"lastfmUnlinkSuccess": "已取消與 Last.fm 的連並停用音樂記錄",
"lastfmUnlinkFailure": "無法取消與 Last.fm 的連",
"lastfmLinkSuccess": "已成功連 Last.fm 並開啟音樂記錄",
"lastfmLinkFailure": "無法連 Last.fm",
"lastfmUnlinkSuccess": "已取消與 Last.fm 的連並停用音樂記錄",
"lastfmUnlinkFailure": "無法取消與 Last.fm 的連",
"openIn": {
"lastfm": "在 Last.fm 中開啟",
"musicbrainz": "在 MusicBrainz 中開啟"
},
"lastfmLink": "查看更多…",
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連 ListenBrainz 並開啟音樂記錄",
"listenBrainzLinkFailure": "無法連 ListenBrainz%{error}",
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連並停用音樂記錄",
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連",
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連 ListenBrainz 並開啟音樂記錄",
"listenBrainzLinkFailure": "無法連 ListenBrainz%{error}",
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連並停用音樂記錄",
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連",
"downloadOriginalFormat": "下載原始格式",
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享 %{resource} '%{name}'",
@ -718,4 +718,4 @@
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
}
}
}

View File

@ -50,7 +50,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error {
return nil
}
u, _ := request.UserFrom(p.ctx)
if !u.IsAdmin {
if !u.IsAdmin || u.ID == "" {
log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+
"Please create an admin user first, and then update the playlists for them to be imported")
return nil

View File

@ -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

View File

@ -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() {

View File

@ -390,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
// noopPlayTracker implements scrobbler.PlayTracker
type noopPlayTracker struct{}
func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error {
return nil
}
func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) {
return nil, nil
}
func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error {
return nil
}
// Compile-time interface checks
var (
_ artwork.Artwork = noopArtwork{}
_ stream.MediaStreamer = &spyStreamer{}
_ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{}
_ scrobbler.PlayTracker = noopPlayTracker{}
_ ffmpeg.FFmpeg = noopFFmpeg{}
_ artwork.Artwork = noopArtwork{}
_ stream.MediaStreamer = &spyStreamer{}
_ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{}
_ ffmpeg.FFmpeg = noopFFmpeg{}
)
var _ = BeforeSuite(func() {
@ -513,12 +497,13 @@ func setupTestDB() {
s,
events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()),
noopPlayTracker{},
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
lyrics.NewLyrics(nil),
decider,
nil,
)
}

View File

@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() {
Expect(resp.Error).ToNot(BeNil())
})
})
Describe("ReportPlayback", Ordered, func() {
var songID string
BeforeAll(func() {
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID = songs[0].ID
})
It("returns error when required params are missing", func() {
resp := doReq("reportPlayback")
Expect(resp.Status).To(Equal(responses.StatusFailed))
})
It("returns error for invalid state", func() {
resp := doReq("reportPlayback",
"mediaId", songID,
"mediaType", "song",
"positionMs", "0",
"state", "invalid",
)
Expect(resp.Status).To(Equal(responses.StatusFailed))
})
It("starting report creates a getNowPlaying entry", func() {
resp := doReq("reportPlayback",
"mediaId", songID,
"mediaType", "song",
"positionMs", "0",
"state", "starting",
)
Expect(resp.Status).To(Equal(responses.StatusOK))
np := doReq("getNowPlaying")
Expect(np.Status).To(Equal(responses.StatusOK))
Expect(np.NowPlaying.Entry).To(HaveLen(1))
Expect(np.NowPlaying.Entry[0].Id).To(Equal(songID))
Expect(np.NowPlaying.Entry[0].State).To(Equal("starting"))
})
It("playing report updates getNowPlaying state and position", func() {
resp := doReq("reportPlayback",
"mediaId", songID,
"mediaType", "song",
"positionMs", "30000",
"state", "playing",
)
Expect(resp.Status).To(Equal(responses.StatusOK))
np := doReq("getNowPlaying")
Expect(np.NowPlaying.Entry).To(HaveLen(1))
Expect(np.NowPlaying.Entry[0].State).To(Equal("playing"))
Expect(np.NowPlaying.Entry[0].PositionMs).To(BeNumerically(">=", int64(30000)))
})
It("paused report freezes position in getNowPlaying", func() {
resp := doReq("reportPlayback",
"mediaId", songID,
"mediaType", "song",
"positionMs", "30000",
"state", "paused",
)
Expect(resp.Status).To(Equal(responses.StatusOK))
np := doReq("getNowPlaying")
Expect(np.NowPlaying.Entry).To(HaveLen(1))
Expect(np.NowPlaying.Entry[0].State).To(Equal("paused"))
Expect(np.NowPlaying.Entry[0].PositionMs).To(Equal(int64(30000)))
})
It("stopped report removes entry from getNowPlaying", func() {
resp := doReq("reportPlayback",
"mediaId", songID,
"mediaType", "song",
"positionMs", "90000",
"state", "stopped",
)
Expect(resp.Status).To(Equal(responses.StatusOK))
np := doReq("getNowPlaying")
Expect(np.NowPlaying.Entry).To(BeEmpty())
})
It("accepts mediaType=podcast without error", func() {
resp := doReq("reportPlayback",
"mediaId", songID,
"mediaType", "podcast",
"positionMs", "0",
"state", "starting",
)
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("accepts optional playbackRate and ignoreScrobble", func() {
resp := doReq("reportPlayback",
"mediaId", songID,
"mediaType", "song",
"positionMs", "5000",
"state", "playing",
"playbackRate", "1.5",
"ignoreScrobble", "true",
)
Expect(resp.Status).To(Equal(responses.StatusOK))
np := doReq("getNowPlaying")
Expect(np.NowPlaying.Entry).To(HaveLen(1))
Expect(np.NowPlaying.Entry[0].PlaybackRate).To(Equal(1.5))
})
})
})

View File

@ -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))
})
})
})

View File

@ -0,0 +1,273 @@
package e2e
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// buildSonicRouter creates a subsonic.Router with a real sonic.Sonic service
// backed by the given provider and the shared e2e DataStore.
func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
loader := &mockSonicPluginLoader{provider: provider}
m := matcher.New(ds)
sonicSvc := sonic.New(ds, loader, m)
decider := stream.NewTranscodeDecider(ds, noopFFmpeg{})
return subsonic.New(
ds,
noopArtwork{},
&spyStreamer{},
noopArchiver{},
core.NewPlayers(ds),
noopProvider{},
nil, // scanner
events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()),
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
lyrics.NewLyrics(nil),
decider,
sonicSvc,
)
}
// doSonicReq makes a request through a sonic-enabled router and returns the parsed response.
func doSonicReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *responses.Subsonic {
w := httptest.NewRecorder()
r := buildReq(adminUser, endpoint, params...)
sonicRouter.ServeHTTP(w, r)
return parseJSONResponse(w)
}
// doSonicRawReq makes a request through a sonic-enabled router and returns the raw recorder.
func doSonicRawReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := buildReq(adminUser, endpoint, params...)
sonicRouter.ServeHTTP(w, r)
return w
}
var _ = Describe("Sonic Similarity Endpoints", func() {
BeforeEach(func() {
setupTestDB()
})
Context("without sonic similarity plugin", func() {
Describe("getSonicSimilarTracks", func() {
It("returns 404 when no sonic similarity plugin is available", func() {
w := doRawReq("getSonicSimilarTracks", "id", "any-song-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("findSonicPath", func() {
It("returns 404 when no sonic similarity plugin is available", func() {
w := doRawReq("findSonicPath", "startSongId", "any-song-id", "endSongId", "another-song-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})
Context("with sonic similarity plugin", func() {
var (
sonicRouter *subsonic.Router
comeTogether model.MediaFile
something model.MediaFile
)
BeforeEach(func() {
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Come Together"},
})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
comeTogether = songs[0]
songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Something"},
})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
something = songs[0]
provider := &mockSonicProvider{
similarIDs: []string{something.ID, comeTogether.ID},
pathIDs: []string{comeTogether.ID, something.ID},
}
sonicRouter = buildSonicRouter(provider)
})
Describe("getSonicSimilarTracks", func() {
It("returns similar tracks with similarity scores", func() {
resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID)
Expect(resp.Status).To(Equal(responses.StatusOK))
matches := *resp.SonicMatches
Expect(matches).To(HaveLen(2))
Expect(matches[0].Entry.Title).To(Equal("Something"))
Expect(matches[0].Similarity).To(Equal(1.0))
Expect(matches[1].Entry.Title).To(Equal("Come Together"))
Expect(matches[1].Similarity).To(Equal(0.9))
})
It("respects the count parameter", func() {
resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID, "count", "1")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(*resp.SonicMatches).To(HaveLen(1))
})
It("returns an error for a missing id parameter", func() {
resp := doSonicReq(sonicRouter, "getSonicSimilarTracks")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("returns an error for a non-existent song ID", func() {
resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", "non-existent-id")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("returns correct JSON structure", func() {
w := doSonicRawReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID)
Expect(w.Code).To(Equal(http.StatusOK))
var wrapper responses.JsonWrapper
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
matches := *wrapper.Subsonic.SonicMatches
Expect(matches).To(HaveLen(2))
Expect(matches[0].Similarity).To(BeNumerically(">", 0))
Expect(matches[0].Entry.Id).ToNot(BeEmpty())
})
})
Describe("findSonicPath", func() {
It("returns a path between two tracks with similarity scores", func() {
resp := doSonicReq(sonicRouter, "findSonicPath",
"startSongId", comeTogether.ID,
"endSongId", something.ID,
)
Expect(resp.Status).To(Equal(responses.StatusOK))
matches := *resp.SonicMatches
Expect(matches).To(HaveLen(2))
Expect(matches[0].Entry.Title).To(Equal("Come Together"))
Expect(matches[0].Similarity).To(Equal(1.0))
Expect(matches[1].Entry.Title).To(Equal("Something"))
Expect(matches[1].Similarity).To(Equal(0.95))
})
It("returns an error for a missing startSongId parameter", func() {
resp := doSonicReq(sonicRouter, "findSonicPath", "endSongId", something.ID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("returns an error for a missing endSongId parameter", func() {
resp := doSonicReq(sonicRouter, "findSonicPath", "startSongId", comeTogether.ID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("returns an error for a non-existent start song ID", func() {
resp := doSonicReq(sonicRouter, "findSonicPath",
"startSongId", "non-existent-id",
"endSongId", something.ID,
)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("returns an error for a non-existent end song ID", func() {
resp := doSonicReq(sonicRouter, "findSonicPath",
"startSongId", comeTogether.ID,
"endSongId", "non-existent-id",
)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
})
})
})
// mockSonicProvider returns results using IDs from the real test library,
// so that the matcher can resolve them back to actual MediaFiles.
type mockSonicProvider struct {
similarIDs []string
pathIDs []string
}
func (m *mockSonicProvider) GetSonicSimilarTracks(_ context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
var results []sonic.SimilarResult
for i, id := range m.similarIDs {
if i >= count {
break
}
results = append(results, sonic.SimilarResult{
Song: agents.Song{ID: id},
Similarity: 1.0 - float64(i)*0.1,
})
}
return results, nil
}
func (m *mockSonicProvider) FindSonicPath(_ context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
var results []sonic.SimilarResult
for i, id := range m.pathIDs {
if i >= count {
break
}
results = append(results, sonic.SimilarResult{
Song: agents.Song{ID: id},
Similarity: 1.0 - float64(i)*0.05,
})
}
return results, nil
}
type mockSonicPluginLoader struct {
provider sonic.Provider
}
func (m *mockSonicPluginLoader) PluginNames(capability string) []string {
if capability == "SonicSimilarity" && m.provider != nil {
return []string{"mock-sonic"}
}
return nil
}
func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonic.Provider, bool) {
if m.provider != nil {
return m.provider, true
}
return nil, false
}

View File

@ -396,68 +396,30 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
})
})
Describe("player MaxBitRate cap", func() {
It("forces transcode when source bitrate exceeds player MaxBitRate", func() {
Describe("player MaxBitRate cap is ignored", func() {
It("allows direct play even when source bitrate exceeds player MaxBitRate", func() {
setPlayerMaxBitRate(320) // 320 kbps cap
// FLAC is 900kbps, client has no bitrate limit but player cap is 320
// FLAC is 900kbps, player cap is 320, but getTranscodeDecision
// ignores server-side overrides — client profiles are used as-is
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
// Target bitrate should be capped at player's 320kbps = 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
It("does not affect direct play when source bitrate is under player MaxBitRate", func() {
setPlayerMaxBitRate(500) // 500 kbps cap
// MP3 is 320kbps, under the 500kbps player cap → direct play
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
})
It("uses client limit when more restrictive than player MaxBitRate", func() {
setPlayerMaxBitRate(500) // 500 kbps player cap
// Client caps at 320kbps (bitrateCapClient), which is more restrictive than 500
// FLAC is 900kbps → exceeds both limits → transcode
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// Client limit (320kbps) is more restrictive → 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
It("uses player MaxBitRate when more restrictive than client limit", func() {
It("uses only client limit, not player MaxBitRate", func() {
setPlayerMaxBitRate(192) // 192 kbps player cap
// Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192
// FLAC is 900kbps → transcode at 192kbps
// but getTranscodeDecision ignores player cap → client limit (320kbps) applies
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// Player limit (192kbps) is more restrictive → 192000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
It("has no effect when player MaxBitRate is 0", func() {
setPlayerMaxBitRate(0) // No player cap
// FLAC with flac+mp3 client → direct play (no bitrate constraint)
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
// Only client limit (320kbps) applies → 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
})
@ -513,56 +475,37 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
})
})
Describe("player MaxBitRate + client limits combined", func() {
It("player MaxBitRate injects maxAudioBitrate, format default used for transcode target", func() {
Describe("player MaxBitRate is ignored by getTranscodeDecision", func() {
It("does not inject maxAudioBitrate from player cap", func() {
setPlayerMaxBitRate(320)
// opusTranscodeClient has no client bitrate limits
// Player cap injects maxAudioBitrate=320
// FLAC (900kbps) → exceeds 320 → transcode to opus
// Lossless→lossy: maxTranscodingAudioBitrate=0, so falls back to maxAudioBitrate=320
// Player cap is 320, but getTranscodeDecision ignores it
// FLAC (900kbps) → can't direct play → transcode to opus using format default
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
// maxAudioBitrate=320 used as fallback → 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
// Bitrate should be opus format default (128kbps), not player cap (320kbps)
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000)))
})
It("player MaxBitRate + client maxTranscodingAudioBitrate work together", func() {
It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() {
setPlayerMaxBitRate(320)
// maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps), no maxAudioBitrate
// Player cap injects maxAudioBitrate=320
// FLAC (900kbps) → exceeds 320 → transcode to mp3
// Lossless→lossy: maxTranscodingAudioBitrate=192 takes priority
// maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps)
// Player cap is 320, but getTranscodeDecision ignores it
// Only client maxTranscodingAudioBitrate=192 applies
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// maxTranscodingAudioBitrate=192 is preferred → 192000 bps
// maxTranscodingAudioBitrate=192 → 192000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
It("streams with correct bitrate after player MaxBitRate-triggered transcode", func() {
setPlayerMaxBitRate(128)
// Get decision: FLAC (900kbps) with player cap 128 → transcode
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Stream using the token
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
})
})
})

View File

@ -102,6 +102,7 @@ func encodeMediafileShare(s model.Share, id string) string {
ID: id,
Format: s.Format,
BitRate: s.MaxBitRate,
ShareID: s.ID,
}
token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims)
return token

View File

@ -4,11 +4,13 @@ import (
"errors"
"net/http"
"strconv"
"time"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/req"
)
@ -23,6 +25,18 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
return
}
if info.shareID != "" {
share, err := pub.ds.Share(ctx).Get(info.shareID)
if err != nil {
checkShareError(ctx, w, err, info.shareID)
return
}
if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
checkShareError(ctx, w, model.ErrExpired, info.shareID)
return
}
}
mf, err := pub.ds.MediaFile(ctx).Get(info.id)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
@ -63,17 +77,14 @@ type shareTrackInfo struct {
id string
format string
bitrate int
shareID string
}
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
token, err := auth.TokenAuth.Decode(tokenString)
c, err := auth.Validate(tokenString)
if err != nil {
return shareTrackInfo{}, err
}
if token == nil {
return shareTrackInfo{}, errors.New("unauthorized")
}
c := auth.ClaimsFromToken(token)
if c.ID == "" {
return shareTrackInfo{}, errors.New("required claim \"id\" not found")
}
@ -81,5 +92,6 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
id: c.ID,
format: c.Format,
bitrate: c.BitRate,
shareID: c.ShareID,
}, nil
}

View File

@ -0,0 +1,196 @@
package public
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockStreamer struct {
req stream.Request
called bool
}
func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) {
m.called = true
m.req = r
return nil, errors.New("mock: not implemented")
}
var _ = Describe("decodeStreamInfo", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
})
It("decodes a valid token with all fields", func() {
claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
info, err := decodeStreamInfo(token)
Expect(err).NotTo(HaveOccurred())
Expect(info.id).To(Equal("mf-123"))
Expect(info.format).To(Equal("mp3"))
Expect(info.bitrate).To(Equal(192))
Expect(info.shareID).To(Equal("share123"))
})
It("rejects an expired token", func() {
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
_, err := decodeStreamInfo(token)
Expect(err).To(HaveOccurred())
})
It("accepts a token without exp (non-expiring share)", func() {
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
token, _ := auth.CreatePublicToken(claims)
info, err := decodeStreamInfo(token)
Expect(err).NotTo(HaveOccurred())
Expect(info.id).To(Equal("mf-123"))
Expect(info.shareID).To(Equal("share123"))
})
It("rejects a token without an id claim", func() {
claims := auth.Claims{ShareID: "share123"}
token, _ := auth.CreatePublicToken(claims)
_, err := decodeStreamInfo(token)
Expect(err).To(HaveOccurred())
})
It("rejects an invalid token string", func() {
_, err := decodeStreamInfo("not-a-valid-token")
Expect(err).To(HaveOccurred())
})
It("handles tokens without shareID (backward compat)", func() {
claims := auth.Claims{ID: "mf-123", Format: "opus"}
token, _ := auth.CreatePublicToken(claims)
info, err := decodeStreamInfo(token)
Expect(err).NotTo(HaveOccurred())
Expect(info.id).To(Equal("mf-123"))
Expect(info.format).To(Equal("opus"))
Expect(info.shareID).To(BeEmpty())
})
})
var _ = Describe("encodeMediafileShare", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
})
It("includes the share ID in the token", func() {
exp := P(time.Now().Add(time.Hour))
s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp}
token := encodeMediafileShare(s, "mf-999")
info, err := decodeStreamInfo(token)
Expect(err).NotTo(HaveOccurred())
Expect(info.shareID).To(Equal("shareABC"))
Expect(info.id).To(Equal("mf-999"))
Expect(info.format).To(Equal("mp3"))
Expect(info.bitrate).To(Equal(320))
})
It("creates a non-expiring token when share has no expiry", func() {
s := model.Share{ID: "shareXYZ", ExpiresAt: nil}
token := encodeMediafileShare(s, "mf-111")
info, err := decodeStreamInfo(token)
Expect(err).NotTo(HaveOccurred())
Expect(info.shareID).To(Equal("shareXYZ"))
Expect(info.id).To(Equal("mf-111"))
})
})
var _ = Describe("handleStream", func() {
var ds *tests.MockDataStore
var shareRepo *tests.MockShareRepo
var streamer *mockStreamer
var pub *Router
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
ds = &tests.MockDataStore{}
shareRepo = &tests.MockShareRepo{}
ds.MockedShare = shareRepo
streamer = &mockStreamer{}
pub = &Router{ds: ds, streamer: streamer}
})
makeRequest := func(token string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil)
w := httptest.NewRecorder()
pub.handleStream(w, r)
return w
}
It("passes all validation and reaches the streamer for a valid token", func() {
shareRepo.ID = "share123"
mfRepo := tests.CreateMockMediaFileRepo()
mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}})
ds.MockedMediaFile = mfRepo
claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
makeRequest(token)
Expect(streamer.called).To(BeTrue())
Expect(streamer.req.Format).To(Equal("mp3"))
Expect(streamer.req.BitRate).To(Equal(192))
})
It("returns 400 for an expired token", func() {
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
w := makeRequest(token)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 404 when share has been deleted", func() {
shareRepo.ID = "other-share"
claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"}
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
w := makeRequest(token)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 410 when share has been set to expired", func() {
shareRepo.ID = "share123"
expired := time.Now().Add(-time.Hour)
shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired}
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
token, _ := auth.CreatePublicToken(claims)
w := makeRequest(token)
Expect(w.Code).To(Equal(http.StatusGone))
})
It("returns 500 when share lookup fails", func() {
shareRepo.Error = errors.New("db error")
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
w := makeRequest(token)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
It("skips share check for tokens without shareID (backward compat)", func() {
claims := auth.Claims{ID: "mf-123"}
token, _ := auth.CreatePublicToken(claims)
w := makeRequest(token)
// Should get past share check, then fail on media file lookup (no mock data)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 400 for an invalid token", func() {
w := makeRequest("not-a-valid-token")
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})

View File

@ -5,14 +5,12 @@ import (
"path"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/publicurl"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
@ -43,13 +41,8 @@ func (pub *Router) routes() http.Handler {
r.Group(func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Group(func(r chi.Router) {
if conf.Server.DevArtworkMaxRequests > 0 {
log.Debug("Throttling public images endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
conf.Server.DevArtworkThrottleBacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
}
r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
r.HandleFunc("/img/{id}", pub.handleImages)
})
if conf.Server.EnableSharing {

View File

@ -58,6 +58,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"uiCoverArtSize": conf.Server.UICoverArtSize,
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
"enableNowPlaying": conf.Server.EnableNowPlaying,
"playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(),
"gaTrackingId": conf.Server.GATrackingID,
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
"devActivityPanel": conf.Server.DevActivityPanel,

View File

@ -106,6 +106,7 @@ var _ = Describe("serveIndex", func() {
Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true),
Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true),
Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"),
Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)),
)
It("sanitizes entity-encoded welcomeMessage as html", func() {

View File

@ -212,13 +212,17 @@ 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),
UserName: np.Username,
MinutesAgo: int32(time.Since(np.Start).Minutes()),
PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything
PlayerName: np.PlayerName,
Child: childFromMediaFile(ctx, np.MediaFile),
UserName: np.Username,
MinutesAgo: int32(time.Since(np.Start).Minutes()),
PlayerId: i,
PlayerName: np.PlayerName,
State: np.State,
PositionMs: np.PositionMs,
PlaybackRate: np.PlaybackRate,
}
})
return response, nil

View File

@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
})

View File

@ -9,7 +9,6 @@ import (
"regexp"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
@ -19,6 +18,7 @@ import (
"github.com/navidrome/navidrome/core/playback"
playlistsvc "github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
sonicsvc "github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -52,12 +52,14 @@ type Router struct {
metrics metrics.Metrics
lyrics lyricssvc.Lyrics
transcodeDecision stream.TranscodeDecider
sonic *sonicsvc.Sonic
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver,
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider,
sonic *sonicsvc.Sonic,
) *Router {
r := &Router{
ds: ds,
@ -75,6 +77,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea
metrics: metrics,
lyrics: lyrics,
transcodeDecision: transcodeDecision,
sonic: sonic,
}
r.Handler = r.routes()
return r
@ -121,6 +124,8 @@ func (api *Router) routes() http.Handler {
h(r, "getTopSongs", api.GetTopSongs)
h(r, "getSimilarSongs", api.GetSimilarSongs)
h(r, "getSimilarSongs2", api.GetSimilarSongs2)
hr(r, "getSonicSimilarTracks", api.GetSonicSimilarTracks)
hr(r, "findSonicPath", api.FindSonicPath)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
@ -138,6 +143,7 @@ func (api *Router) routes() http.Handler {
h(r, "star", api.Star)
h(r, "unstar", api.Unstar)
h(r, "scrobble", api.Scrobble)
h(r, "reportPlayback", api.ReportPlayback)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
@ -183,14 +189,8 @@ func (api *Router) routes() http.Handler {
hr(r, "getTranscodeStream", api.GetTranscodeStream)
})
r.Group(func(r chi.Router) {
// configure request throttling
if conf.Server.DevArtworkMaxRequests > 0 {
log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
conf.Server.DevArtworkThrottleBacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
}
r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
hr(r, "getCoverArt", api.GetCoverArt)
})
r.Group(func(r chi.Router) {

View File

@ -156,7 +156,7 @@ var _ = Describe("sendResponse", func() {
It("updates status pointer when an error occurs", func() {
pointer := int32(0)
ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer)
ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet
r = r.WithContext(ctx)
payload.Status = responses.StatusFailed

View File

@ -3,6 +3,7 @@ package subsonic
import (
"context"
"fmt"
"math"
"net/http"
"time"
@ -217,6 +218,73 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi
}
log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position)
err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position)
return err
return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
MediaId: trackId,
PositionMs: int64(position) * 1000,
State: scrobbler.StatePlaying,
PlaybackRate: 1.0,
ClientId: clientId,
ClientName: client,
})
}
func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
mediaId, err := p.String("mediaId")
if err != nil {
return nil, err
}
mediaType, err := p.String("mediaType")
if err != nil {
return nil, err
}
positionMs, err := p.Int64("positionMs")
if err != nil {
return nil, err
}
if positionMs < 0 {
return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative")
}
state, err := p.String("state")
if err != nil {
return nil, err
}
if !scrobbler.ValidStates[state] {
return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state)
}
playbackRate := p.Float64Or("playbackRate", 1.0)
if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 {
return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number")
}
ignoreScrobble := p.BoolOr("ignoreScrobble", false)
ctx := r.Context()
if mediaType != "song" {
log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId)
}
player, _ := request.PlayerFrom(ctx)
client, _ := request.ClientFrom(ctx)
clientId, ok := request.ClientUniqueIdFrom(ctx)
if !ok {
clientId = player.ID
}
err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
MediaId: mediaId,
PositionMs: positionMs,
State: state,
PlaybackRate: playbackRate,
IgnoreScrobble: ignoreScrobble,
ClientId: clientId,
ClientName: client,
})
if err != nil {
log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err)
return nil, err
}
return newResponse(), nil
}

View File

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -27,7 +28,7 @@ var _ = Describe("MediaAnnotationController", func() {
ds = &tests.MockDataStore{}
playTracker = &fakePlayTracker{}
eventBroker = &fakeEventBroker{}
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil)
})
Describe("Scrobble", func() {
@ -89,35 +90,110 @@ var _ = Describe("MediaAnnotationController", func() {
Expect(playTracker.Submissions).To(BeEmpty())
})
It("registers a NowPlaying", func() {
It("registers a NowPlaying via ReportPlayback", func() {
_, err := router.Scrobble(req)
Expect(err).ToNot(HaveOccurred())
Expect(playTracker.Playing).To(HaveLen(1))
Expect(playTracker.Playing).To(HaveKey("player-1"))
Expect(playTracker.ReportedPlayback).To(HaveLen(1))
Expect(playTracker.ReportedPlayback[0].MediaId).To(Equal("12"))
Expect(playTracker.ReportedPlayback[0].State).To(Equal(scrobbler.StatePlaying))
Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("player-1"))
})
})
})
Describe("ReportPlayback", func() {
It("returns error when mediaId is missing", func() {
r := newGetRequest("mediaType=song", "positionMs=0", "state=playing")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error when mediaType is missing", func() {
r := newGetRequest("mediaId=123", "positionMs=0", "state=playing")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error when positionMs is missing", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "state=playing")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error when state is missing", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error for invalid state value", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=invalid")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error for negative positionMs", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=-1", "state=playing")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error for NaN playbackRate", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=NaN")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error for Inf playbackRate", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=Inf")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error for negative playbackRate", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=-1.0")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("returns error for zero playbackRate", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=0")
_, err := router.ReportPlayback(r)
Expect(err).To(HaveOccurred())
})
It("accepts mediaType=podcast without error", func() {
r := newGetRequest("mediaId=123", "mediaType=podcast", "positionMs=0", "state=playing")
ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"})
r = r.WithContext(ctx)
resp, err := router.ReportPlayback(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("defaults playbackRate to 1.0 and ignoreScrobble to false", func() {
r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=5000", "state=playing")
ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"})
r = r.WithContext(ctx)
_, err := router.ReportPlayback(r)
Expect(err).ToNot(HaveOccurred())
Expect(playTracker.ReportedPlayback).To(HaveLen(1))
Expect(playTracker.ReportedPlayback[0].PlaybackRate).To(Equal(1.0))
Expect(playTracker.ReportedPlayback[0].IgnoreScrobble).To(BeFalse())
Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("p1"))
Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty())
})
})
})
type fakePlayTracker struct {
Submissions []scrobbler.Submission
Playing map[string]string
Error error
Submissions []scrobbler.Submission
ReportedPlayback []scrobbler.ReportPlaybackParams
Error error
}
func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error {
if f.Error != nil {
return f.Error
}
if f.Playing == nil {
f.Playing = make(map[string]string)
}
f.Playing[playerId] = trackId
return nil
}
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) {
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) {
return nil, f.Error
}
@ -129,6 +205,14 @@ func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Subm
return nil
}
func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.ReportPlaybackParams) error {
if f.Error != nil {
return f.Error
}
f.ReportedPlayback = append(f.ReportedPlayback, params)
return nil
}
var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil)
type fakeEventBroker struct {

Some files were not shown because too many files have changed in this diff Show More