mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into radio-browser-search/5239
This commit is contained in:
commit
777cbbc998
9
.github/actions/prepare-docker/action.yml
vendored
9
.github/actions/prepare-docker/action.yml
vendored
@ -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: |
|
||||
|
||||
13
.github/workflows/download-link-on-pr.yml
vendored
13
.github/workflows/download-link-on-pr.yml
vendored
@ -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});
|
||||
}
|
||||
|
||||
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -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
|
||||
|
||||
@ -164,6 +164,7 @@ RUN touch /.nddockerenv
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
WORKDIR /app
|
||||
ENV PATH="/app:${PATH}"
|
||||
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@ -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/*")
|
||||
|
||||
|
||||
@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
return err == nil && sk != ""
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
|
||||
@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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{
|
||||
{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
})
|
||||
|
||||
4
core/external/provider.go
vendored
4
core/external/provider.go
vendored
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
|
||||
s, ok := b.loader()
|
||||
if !ok {
|
||||
return errors.New("scrobbler not available")
|
||||
}
|
||||
return s.PlaybackReport(ctx, info)
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) sendWakeSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
|
||||
@ -23,6 +23,7 @@ type Scrobbler interface {
|
||||
IsAuthorized(ctx context.Context, userId string) bool
|
||||
NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
|
||||
Scrobble(ctx context.Context, userId string, s Scrobble) error
|
||||
PlaybackReport(ctx context.Context, info PlaybackSession) error
|
||||
}
|
||||
|
||||
type Constructor func(ds model.DataStore) Scrobbler
|
||||
|
||||
78
core/scrobbler/nowplaying_worker.go
Normal file
78
core/scrobbler/nowplaying_worker.go
Normal file
@ -0,0 +1,78 @@
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
|
||||
p.npMu.Lock()
|
||||
defer p.npMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
|
||||
p.npQueue[playerId] = nowPlayingEntry{
|
||||
ctx: ctx,
|
||||
userId: userId,
|
||||
track: track,
|
||||
position: position,
|
||||
}
|
||||
p.sendNowPlayingSignal()
|
||||
}
|
||||
|
||||
func (p *playTracker) sendNowPlayingSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
case p.npSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) nowPlayingWorker() {
|
||||
defer close(p.workerDone)
|
||||
for {
|
||||
select {
|
||||
case <-p.shutdown:
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
case <-p.npSignal:
|
||||
}
|
||||
|
||||
p.npMu.Lock()
|
||||
if len(p.npQueue) == 0 {
|
||||
p.npMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep a copy of the entries to process and clear the queue
|
||||
entries := p.npQueue
|
||||
p.npQueue = make(map[string]nowPlayingEntry)
|
||||
p.npMu.Unlock()
|
||||
|
||||
// Process entries without holding lock
|
||||
for _, entry := range entries {
|
||||
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, userId) {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
|
||||
err := s.NowPlaying(ctx, userId, t, position)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
64
core/scrobbler/playbackreport_worker.go
Normal file
64
core/scrobbler/playbackreport_worker.go
Normal file
@ -0,0 +1,64 @@
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) {
|
||||
p.prMu.Lock()
|
||||
defer p.prMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
p.prQueue = append(p.prQueue, playbackReportEntry{
|
||||
ctx: ctx,
|
||||
info: info,
|
||||
})
|
||||
p.sendPlaybackReportSignal()
|
||||
}
|
||||
|
||||
func (p *playTracker) sendPlaybackReportSignal() {
|
||||
select {
|
||||
case p.prSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) playbackReportWorker() {
|
||||
defer close(p.prWorkerDone)
|
||||
for {
|
||||
select {
|
||||
case <-p.shutdown:
|
||||
return
|
||||
case <-p.prSignal:
|
||||
}
|
||||
|
||||
p.prMu.Lock()
|
||||
if len(p.prQueue) == 0 {
|
||||
p.prMu.Unlock()
|
||||
continue
|
||||
}
|
||||
entries := p.prQueue
|
||||
p.prQueue = nil
|
||||
p.prMu.Unlock()
|
||||
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
for _, entry := range entries {
|
||||
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) {
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, info.UserId) {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs)
|
||||
err := s.PlaybackReport(ctx, info)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
130
core/sonic/sonic.go
Normal file
130
core/sonic/sonic.go
Normal 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)
|
||||
}
|
||||
17
core/sonic/sonic_suite_test.go
Normal file
17
core/sonic/sonic_suite_test.go
Normal 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
146
core/sonic/sonic_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
27
go.mod
@ -1,6 +1,6 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.26.0
|
||||
go 1.26
|
||||
|
||||
// Fork to implement raw tags support
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a
|
||||
@ -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
67
go.sum
@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
@ -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=
|
||||
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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})),
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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.0–1.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
|
||||
@ -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: |-
|
||||
|
||||
@ -5,7 +5,7 @@ package capabilities
|
||||
// ListenBrainz, or custom scrobbling backends.
|
||||
//
|
||||
// All methods are required - plugins implementing this capability must provide
|
||||
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
|
||||
// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
|
||||
//
|
||||
//nd:capability name=scrobbler required=true
|
||||
type Scrobbler interface {
|
||||
@ -20,6 +20,10 @@ type Scrobbler interface {
|
||||
// Scrobble submits a completed scrobble to the scrobbling service.
|
||||
//nd:export name=nd_scrobbler_scrobble
|
||||
Scrobble(ScrobbleRequest) error
|
||||
|
||||
// PlaybackReport sends a playback state report to the scrobbling service.
|
||||
//nd:export name=nd_scrobbler_playback_report
|
||||
PlaybackReport(PlaybackReportRequest) error
|
||||
}
|
||||
|
||||
// IsAuthorizedRequest is the request for authorization check.
|
||||
@ -96,6 +100,26 @@ type ScrobbleRequest struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// PlaybackReportRequest is the request for playback report notifications.
|
||||
type PlaybackReportRequest struct {
|
||||
// Username is the username of the user.
|
||||
Username string `json:"username"`
|
||||
// Track is the track being played.
|
||||
Track TrackInfo `json:"track"`
|
||||
// State is the current playback state (starting/playing/paused/stopped/expired).
|
||||
State string `json:"state"`
|
||||
// PositionMs is the current playback position in milliseconds.
|
||||
PositionMs int64 `json:"positionMs"`
|
||||
// PlaybackRate is the playback speed (1.0 = normal).
|
||||
PlaybackRate float64 `json:"playbackRate"`
|
||||
// PlayerId is the unique client identifier.
|
||||
PlayerId string `json:"playerId"`
|
||||
// PlayerName is the human-readable player name.
|
||||
PlayerName string `json:"playerName"`
|
||||
// Timestamp is the Unix timestamp when this report was generated.
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ScrobblerError represents an error type for scrobbling operations.
|
||||
type ScrobblerError string
|
||||
|
||||
|
||||
@ -18,6 +18,11 @@ exports:
|
||||
input:
|
||||
$ref: '#/components/schemas/ScrobbleRequest'
|
||||
contentType: application/json
|
||||
nd_scrobbler_playback_report:
|
||||
description: PlaybackReport sends a playback state report to the scrobbling service.
|
||||
input:
|
||||
$ref: '#/components/schemas/PlaybackReportRequest'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
ArtistRef:
|
||||
@ -59,6 +64,45 @@ components:
|
||||
- username
|
||||
- track
|
||||
- position
|
||||
PlaybackReportRequest:
|
||||
description: PlaybackReportRequest is the request for playback report notifications.
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: Username is the username of the user.
|
||||
track:
|
||||
$ref: '#/components/schemas/TrackInfo'
|
||||
description: Track is the track being played.
|
||||
state:
|
||||
type: string
|
||||
description: State is the current playback state (starting/playing/paused/stopped/expired).
|
||||
positionMs:
|
||||
type: integer
|
||||
format: int64
|
||||
description: PositionMs is the current playback position in milliseconds.
|
||||
playbackRate:
|
||||
type: number
|
||||
format: float
|
||||
description: PlaybackRate is the playback speed (1.0 = normal).
|
||||
playerId:
|
||||
type: string
|
||||
description: PlayerId is the unique client identifier.
|
||||
playerName:
|
||||
type: string
|
||||
description: PlayerName is the human-readable player name.
|
||||
timestamp:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Timestamp is the Unix timestamp when this report was generated.
|
||||
required:
|
||||
- username
|
||||
- track
|
||||
- state
|
||||
- positionMs
|
||||
- playbackRate
|
||||
- playerId
|
||||
- playerName
|
||||
- timestamp
|
||||
ScrobbleRequest:
|
||||
description: ScrobbleRequest is the request for submitting a scrobble.
|
||||
properties:
|
||||
@ -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: |-
|
||||
|
||||
32
plugins/capabilities/sonic_similarity.go
Normal file
32
plugins/capabilities/sonic_similarity.go
Normal 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"`
|
||||
}
|
||||
92
plugins/capabilities/sonic_similarity.yaml
Normal file
92
plugins/capabilities/sonic_similarity.yaml
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
136
plugins/pdk/go/sonicsimilarity/sonicsimilarity.go
Normal file
136
plugins/pdk/go/sonicsimilarity/sonicsimilarity.go
Normal 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
|
||||
}
|
||||
71
plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go
Normal file
71
plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go
Normal 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) {}
|
||||
@ -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;
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
141
plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs
Normal file
141
plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs
Normal 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))
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
92
plugins/sonic_similarity_adapter.go
Normal file
92
plugins/sonic_similarity_adapter.go
Normal 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)
|
||||
110
plugins/sonic_similarity_adapter_test.go
Normal file
110
plugins/sonic_similarity_adapter_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
14
plugins/testdata/test-scrobbler/main.go
vendored
14
plugins/testdata/test-scrobbler/main.go
vendored
@ -53,6 +53,20 @@ func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlaybackReport receives a playback state report.
|
||||
func (t *testScrobbler) PlaybackReport(input scrobbler.PlaybackReportRequest) error {
|
||||
if err := checkConfigError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
artistName := ""
|
||||
if len(input.Track.Artists) > 0 {
|
||||
artistName = input.Track.Artists[0].Name
|
||||
}
|
||||
pdk.Log(pdk.LogInfo, "PlaybackReport: "+input.Track.Title+" by "+artistName+" state="+input.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkConfigError checks if the plugin is configured to return an error.
|
||||
// If "error" config is set, it returns the appropriate ScrobblerError.
|
||||
// Error types: "not_authorized", "retry_later", "unrecoverable"
|
||||
|
||||
16
plugins/testdata/test-sonic-similarity/go.mod
vendored
Normal file
16
plugins/testdata/test-sonic-similarity/go.mod
vendored
Normal 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
|
||||
14
plugins/testdata/test-sonic-similarity/go.sum
vendored
Normal file
14
plugins/testdata/test-sonic-similarity/go.sum
vendored
Normal 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=
|
||||
71
plugins/testdata/test-sonic-similarity/main.go
vendored
Normal file
71
plugins/testdata/test-sonic-similarity/main.go
vendored
Normal 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() {}
|
||||
7
plugins/testdata/test-sonic-similarity/manifest.json
vendored
Normal file
7
plugins/testdata/test-sonic-similarity/manifest.json
vendored
Normal 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"]
|
||||
}
|
||||
@ -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} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -74,23 +74,22 @@ var _ = Describe("Watcher", func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
})
|
||||
|
||||
It("creates separate targets for different folders", func() {
|
||||
It("creates separate targets for different folders", FlakeAttempts(3), func() {
|
||||
// Send notifications for different folders
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"}
|
||||
|
||||
// Wait for watcher to process and trigger scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
// Wait for a scan that collected both targets
|
||||
Eventually(func() []model.ScanTarget {
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return calls[0].Targets
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2))
|
||||
|
||||
// Verify two targets
|
||||
// Verify targets
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].Targets).To(HaveLen(2))
|
||||
|
||||
// Extract folder paths
|
||||
folderPaths := make(map[string]bool)
|
||||
for _, target := range calls[0].Targets {
|
||||
Expect(target.LibraryID).To(Equal(1))
|
||||
@ -107,7 +106,7 @@ var _ = Describe("Watcher", func() {
|
||||
// Wait for watcher to process and trigger scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Verify the target
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
@ -117,20 +116,15 @@ var _ = Describe("Watcher", func() {
|
||||
})
|
||||
|
||||
It("deduplicates folder and file within same folder", func() {
|
||||
// Send notification for a folder
|
||||
// Send multiple notifications for the same folder
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Send notification for same folder (as if file change was detected there)
|
||||
// In practice, watchLibrary() would walk up from file path to folder
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Send another for same folder
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
||||
|
||||
// Wait for watcher to process and trigger scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Verify only one target despite multiple file/folder changes
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
@ -151,32 +145,27 @@ var _ = Describe("Watcher", func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
})
|
||||
|
||||
It("resets timer on each change (debouncing)", func() {
|
||||
It("resets timer on each change (debouncing)", FlakeAttempts(3), func() {
|
||||
// Send first notification
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
||||
|
||||
// Wait a bit less than half the watcher wait time to ensure timer doesn't fire
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// No scan should have been triggered yet
|
||||
Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
|
||||
// Verify no scan fires during a window shorter than the debounce wait
|
||||
Consistently(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0))
|
||||
|
||||
// Send another notification (resets timer)
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
||||
|
||||
// Wait a bit less than half the watcher wait time again
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
// Again, no scan should fire within a short window
|
||||
Consistently(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0))
|
||||
|
||||
// Still no scan
|
||||
Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
|
||||
|
||||
// Wait for full timer to expire after last notification (plus margin)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
// Now scan should have been triggered
|
||||
// Now wait for the debounce timer to expire and trigger scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
})
|
||||
|
||||
It("triggers scan after quiet period", func() {
|
||||
@ -189,7 +178,7 @@ var _ = Describe("Watcher", func() {
|
||||
// Wait for quiet period
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
@ -211,7 +200,7 @@ var _ = Describe("Watcher", func() {
|
||||
// Wait for scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Should scan the library root
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
@ -223,13 +212,12 @@ var _ = Describe("Watcher", func() {
|
||||
It("deduplicates empty and dot paths", func() {
|
||||
// Send notifications with empty and dot paths
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
|
||||
|
||||
// Wait for scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Should have only one target
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
@ -264,20 +252,19 @@ var _ = Describe("Watcher", func() {
|
||||
It("creates separate targets for different libraries", func() {
|
||||
// Send notifications for both libraries
|
||||
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"}
|
||||
|
||||
// Wait for scan
|
||||
Eventually(func() int {
|
||||
return mockScanner.GetScanFoldersCallCount()
|
||||
}, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
|
||||
|
||||
// Verify two targets for different libraries
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].Targets).To(HaveLen(2))
|
||||
// Wait for a scan that collected both targets
|
||||
Eventually(func() []model.ScanTarget {
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return calls[0].Targets
|
||||
}, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2))
|
||||
|
||||
// Verify library IDs are different
|
||||
calls := mockScanner.GetScanFoldersCalls()
|
||||
libraryIDs := make(map[int]bool)
|
||||
for _, target := range calls[0].Targets {
|
||||
libraryIDs[target.LibraryID] = true
|
||||
|
||||
@ -2,6 +2,7 @@ package scheduler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -28,7 +29,7 @@ var _ = Describe("Scheduler", func() {
|
||||
s.c.Stop() // Stop the scheduler after tests
|
||||
})
|
||||
|
||||
It("adds and executes a job", func() {
|
||||
It("adds and executes a job", FlakeAttempts(3), func() {
|
||||
done := make(chan struct{})
|
||||
|
||||
id, err := s.Add("@every 50ms", func() {
|
||||
@ -38,7 +39,7 @@ var _ = Describe("Scheduler", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeZero())
|
||||
|
||||
Eventually(done).Should(BeClosed())
|
||||
Eventually(done, 5*time.Second).Should(BeClosed())
|
||||
})
|
||||
|
||||
It("adds a job with random ~ syntax", func() {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
273
server/e2e/subsonic_sonic_similarity_test.go
Normal file
273
server/e2e/subsonic_sonic_similarity_test.go
Normal 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
|
||||
}
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
196
server/public/handle_streams_test.go
Normal file
196
server/public/handle_streams_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user