2025-06-24 04:50:06 -08:00
|
|
|
package subsonic
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-01-16 01:55:21 -09:00
|
|
|
"time"
|
2025-06-24 04:50:06 -08:00
|
|
|
|
2026-01-16 01:55:21 -09:00
|
|
|
"github.com/navidrome/navidrome/conf"
|
refactor: move playlist business logic from repositories to service layer (#5027)
* refactor: move playlist business logic from repositories to core.Playlists service
Move authorization, permission checks, and orchestration logic from
playlist repositories to the core.Playlists service, following the
existing pattern used by core.Share and core.Library.
Changes:
- Expand core.Playlists interface with read, mutation, track management,
and REST adapter methods
- Add playlistRepositoryWrapper for REST Save/Update/Delete with
permission checks (follows Share/Library pattern)
- Simplify persistence/playlist_repository.go: remove isWritable(),
auth checks from Delete()/Put()/updatePlaylist()
- Simplify persistence/playlist_track_repository.go: remove
isTracksEditable() and permission checks from Add/Delete/Reorder
- Update Subsonic API handlers to route through service
- Update Native API handlers to accept core.Playlists instead of
model.DataStore
* test: add coverage for playlist service methods and REST wrapper
Add 30 new tests covering the service methods added during the playlist
refactoring:
- Delete: owner, admin, denied, not found
- Create: new playlist, replace tracks, admin bypass, denied, not found
- AddTracks: owner, admin, denied, smart playlist, not found
- RemoveTracks: owner, smart playlist denied, non-owner denied
- ReorderTrack: owner, smart playlist denied
- NewRepository wrapper: Save (owner assignment, ID clearing),
Update (owner, admin, denied, ownership change, not found),
Delete (delegation with permission checks)
Expand mockedPlaylistRepo with Get, Delete, Tracks, GetWithTracks, and
rest.Persistable methods. Add mockedPlaylistTrackRepo for track
operation verification.
* fix: add authorization check to playlist Update method
Added ownership verification to the Subsonic Update endpoint in the
playlist service layer. The authorization check was present in the old
repository code but was not carried over during the refactoring to the
service layer, allowing any authenticated user to modify playlists they
don't own via the Subsonic API. Also added corresponding tests for the
Update method's permission logic.
* refactor: improve playlist permission checks and error handling, add e2e tests
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename core.Playlists to playlists package and update references
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename playlists_internal_test.go to parse_m3u_test.go and update tests; add new parse_nsp.go and rest_adapter.go files
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: block track mutations on smart playlists in Create and Update
Create now rejects replacing tracks on smart playlists (pre-existing
gap). Update now uses checkTracksEditable instead of checkWritable
when track changes are requested, restoring the protection that was
removed from the repository layer during the refactoring. Metadata-only
updates on smart playlists remain allowed.
* test: add smart playlist protection tests to ensure readonly behavior and mutation restrictions
* refactor: optimize track removal and renumbering in playlists
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: implement track reordering in playlists with SQL updates
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: wrap track deletion and reordering in transactions for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: remove unused getTracks method from playlistTrackRepository
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: optimize playlist track renumbering with CTE-based UPDATE
Replace the DELETE + re-INSERT renumbering strategy with a two-step
UPDATE approach using a materialized CTE and ROW_NUMBER() window
function. The previous approach (SELECT all IDs, DELETE all tracks,
re-INSERT in chunks of 200) required 13 SQL operations for a 2000-track
playlist. The new approach uses just 2 UPDATEs: first negating all IDs
to clear the positive space, then assigning sequential positions via
UPDATE...FROM with a CTE. This avoids the UNIQUE constraint violations
that affected the original correlated subquery while reducing per-delete
request time from ~110ms to ~12ms on a 2000-track playlist.
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename New function to NewPlaylists for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: update mock playlist repository and tests for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 15:57:13 -09:00
|
|
|
"github.com/navidrome/navidrome/core/playlists"
|
2025-06-24 04:50:06 -08:00
|
|
|
"github.com/navidrome/navidrome/model"
|
2026-02-06 15:35:54 -09:00
|
|
|
"github.com/navidrome/navidrome/model/criteria"
|
2026-01-16 01:55:21 -09:00
|
|
|
"github.com/navidrome/navidrome/model/request"
|
2025-06-24 04:50:06 -08:00
|
|
|
"github.com/navidrome/navidrome/tests"
|
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
refactor: move playlist business logic from repositories to service layer (#5027)
* refactor: move playlist business logic from repositories to core.Playlists service
Move authorization, permission checks, and orchestration logic from
playlist repositories to the core.Playlists service, following the
existing pattern used by core.Share and core.Library.
Changes:
- Expand core.Playlists interface with read, mutation, track management,
and REST adapter methods
- Add playlistRepositoryWrapper for REST Save/Update/Delete with
permission checks (follows Share/Library pattern)
- Simplify persistence/playlist_repository.go: remove isWritable(),
auth checks from Delete()/Put()/updatePlaylist()
- Simplify persistence/playlist_track_repository.go: remove
isTracksEditable() and permission checks from Add/Delete/Reorder
- Update Subsonic API handlers to route through service
- Update Native API handlers to accept core.Playlists instead of
model.DataStore
* test: add coverage for playlist service methods and REST wrapper
Add 30 new tests covering the service methods added during the playlist
refactoring:
- Delete: owner, admin, denied, not found
- Create: new playlist, replace tracks, admin bypass, denied, not found
- AddTracks: owner, admin, denied, smart playlist, not found
- RemoveTracks: owner, smart playlist denied, non-owner denied
- ReorderTrack: owner, smart playlist denied
- NewRepository wrapper: Save (owner assignment, ID clearing),
Update (owner, admin, denied, ownership change, not found),
Delete (delegation with permission checks)
Expand mockedPlaylistRepo with Get, Delete, Tracks, GetWithTracks, and
rest.Persistable methods. Add mockedPlaylistTrackRepo for track
operation verification.
* fix: add authorization check to playlist Update method
Added ownership verification to the Subsonic Update endpoint in the
playlist service layer. The authorization check was present in the old
repository code but was not carried over during the refactoring to the
service layer, allowing any authenticated user to modify playlists they
don't own via the Subsonic API. Also added corresponding tests for the
Update method's permission logic.
* refactor: improve playlist permission checks and error handling, add e2e tests
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename core.Playlists to playlists package and update references
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename playlists_internal_test.go to parse_m3u_test.go and update tests; add new parse_nsp.go and rest_adapter.go files
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: block track mutations on smart playlists in Create and Update
Create now rejects replacing tracks on smart playlists (pre-existing
gap). Update now uses checkTracksEditable instead of checkWritable
when track changes are requested, restoring the protection that was
removed from the repository layer during the refactoring. Metadata-only
updates on smart playlists remain allowed.
* test: add smart playlist protection tests to ensure readonly behavior and mutation restrictions
* refactor: optimize track removal and renumbering in playlists
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: implement track reordering in playlists with SQL updates
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: wrap track deletion and reordering in transactions for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: remove unused getTracks method from playlistTrackRepository
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: optimize playlist track renumbering with CTE-based UPDATE
Replace the DELETE + re-INSERT renumbering strategy with a two-step
UPDATE approach using a materialized CTE and ROW_NUMBER() window
function. The previous approach (SELECT all IDs, DELETE all tracks,
re-INSERT in chunks of 200) required 13 SQL operations for a 2000-track
playlist. The new approach uses just 2 UPDATEs: first negating all IDs
to clear the positive space, then assigning sequential positions via
UPDATE...FROM with a CTE. This avoids the UNIQUE constraint violations
that affected the original correlated subquery while reducing per-delete
request time from ~110ms to ~12ms on a 2000-track playlist.
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename New function to NewPlaylists for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: update mock playlist repository and tests for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 15:57:13 -09:00
|
|
|
var _ playlists.Playlists = (*fakePlaylists)(nil)
|
2025-06-24 04:50:06 -08:00
|
|
|
|
2026-01-16 01:55:21 -09:00
|
|
|
var _ = Describe("buildPlaylist", func() {
|
|
|
|
|
var router *Router
|
|
|
|
|
var ds model.DataStore
|
|
|
|
|
var ctx context.Context
|
|
|
|
|
var playlist model.Playlist
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
ds = &tests.MockDataStore{}
|
feat(subsonic): add sonicSimilarity extension as plugin capability (#5419)
* feat(plugins): add sonicSimilarity capability types
Defines the SonicSimilarity plugin capability interface with
GetSonicSimilarTracks and FindSonicPath methods, along with
their request/response types.
* feat(sonic): add core sonic similarity service
Implements the Sonic service with HasProvider, GetSonicSimilarTracks,
and FindSonicPath, delegating to the PluginLoader and using the
Matcher for index-preserving library resolution.
* test(sonic): add sonic service unit tests
Covers HasProvider, GetSonicSimilarTracks, and FindSonicPath with
mock plugin loader and provider, verifying error propagation and
successful match resolution via the library matcher.
* feat(matcher): add MatchSongsToLibraryMap for index-preserving matching
Adds a new method alongside MatchSongsToLibrary that returns a
map[int]MediaFile keyed by input song index rather than a flat slice,
enabling callers to correlate similarity scores back to the original
position in the results.
* fix(sonic): check provider availability before MediaFile lookup
Avoids unnecessary DB call when no plugin is available, and ensures
the correct error path is tested.
* feat(plugins): add sonic similarity adapter
Adds SonicSimilarityAdapter implementing sonic.Provider, bridging the
plugin system to the core sonic service via Extism plugin functions.
Reuses existing songRefsToAgentSongs helper for SongRef conversion.
* feat(plugins): add LoadSonicSimilarity to plugin manager
Adds Manager.LoadSonicSimilarity method following the pattern of
LoadLyricsProvider, enabling the core sonic service to load a
SonicSimilarityAdapter from a named plugin.
* feat(subsonic): add sonicMatch response type
Add SonicMatch struct with Entry and Similarity fields, and a SonicMatches slice to the Subsonic response struct. These types support the OpenSubsonic sonicSimilarity extension for returning similarity-scored track results.
* feat(subsonic): add getSonicSimilarTracks and findSonicPath handlers
Add two new Subsonic API handlers for the sonicSimilarity OpenSubsonic extension: GetSonicSimilarTracks returns similarity-scored tracks similar to a given song, and FindSonicPath returns a path of tracks connecting two songs. Both handlers delegate to the sonic core service and map results to SonicMatch response types.
* feat(subsonic): advertise sonicSimilarity extension when plugin available
Update GetOpenSubsonicExtensions to conditionally include the sonicSimilarity extension only when a sonic similarity plugin provider is available. The nil guard ensures backward compatibility with tests that pass nil for the sonic field. Also update the existing test to pass the new nil parameter.
* feat(subsonic): wire sonic similarity service into router
Add the sonic.Sonic service to the Router struct and New() constructor, register the getSonicSimilarTracks and findSonicPath routes, and wire sonic.New and its PluginLoader binding into the Wire dependency injection graph. Update all existing test call sites to pass the new nil parameter. Regenerate wire_gen.go.
* fix(e2e): add sonic parameter to subsonic.New call in e2e tests
* test(subsonic): add sonicSimilarity extension advertisement tests
Restructures the GetOpenSubsonicExtensions test into two contexts: one verifying the baseline 5 extensions are returned when no sonic similarity plugin is configured, and one verifying that the sonicSimilarity extension is advertised (making 6 total) when a plugin loader reports an available provider. Adds a mockSonicPluginLoader to satisfy the sonic.PluginLoader interface without requiring a real plugin.
* feat(subsonic): add nil guard and e2e tests for sonic similarity endpoints
Handlers return ErrorDataNotFound when no sonic service is configured,
preventing nil panics. E2e tests verify both endpoints return proper
error responses when no plugin is available.
* fix(subsonic): return HTTP 404 when no sonic similarity plugin available
Endpoints are always registered but return 404 when no provider is
available, rather than a subsonic error code 70.
* refactor: clean up sonic similarity code after review
Extract shared helpers to reduce duplication across the sonic similarity
implementation: loadAllMatches in matcher consolidates the 4-phase
matching pipeline, songRefToAgentSong eliminates per-iteration slice
allocation in the adapter, sonicMatchResponse deduplicates response
building in handlers, and a package-level constant replaces raw
capability name strings in core/sonic.
* fix empty response shapes
Signed-off-by: Deluan <deluan@navidrome.org>
* test(plugins): add testdata plugin and e2e tests for sonic similarity
Add a test-sonic-similarity WASM plugin that implements both
GetSonicSimilarTracks and FindSonicPath via the generated sonicsimilarity
PDK. The plugin returns deterministic test data with decreasing similarity
scores and supports error injection via config. Adapter tests verify the
full round-trip through the WASM plugin including error handling. Also
includes regenerated PDK code from make gen.
* docs: update README to include new capabilities and usage examples for plugins
Signed-off-by: Deluan <deluan@navidrome.org>
* test(e2e): enhance sonic similarity tests with additional scenarios and mock provider
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: address PR review feedback for sonic similarity
Fix incorrect field names in README documentation ({from, to} →
{startSong, endSong}) and remove unnecessary XML serialization test
from e2e suite since OpenSubsonic endpoints only use JSON.
* refactor: rename Matcher methods for conciseness
Rename MatchSongsToLibrary to MatchSongs and MatchSongsToLibraryMap to
MatchSongsIndexed. The Matcher receiver already establishes the "to
library" context, making that suffix redundant, and "Indexed" better
describes the intent (preserving input ordering) than "Map" which
describes the data structure.
* refactor: standardize variable naming for media files in sonic path methods
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: simplify plugin loading by introducing adapter constructors
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-27 13:50:09 -08:00
|
|
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
2026-01-16 01:55:21 -09:00
|
|
|
ctx = context.Background()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
Describe("normal playlist", func() {
|
2026-01-16 01:55:21 -09:00
|
|
|
BeforeEach(func() {
|
2026-02-06 15:35:54 -09:00
|
|
|
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
|
|
|
|
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
|
|
|
|
|
|
|
|
|
playlist = model.Playlist{
|
|
|
|
|
ID: "pls-1",
|
|
|
|
|
Name: "My Playlist",
|
|
|
|
|
Comment: "Test comment",
|
|
|
|
|
OwnerName: "admin",
|
|
|
|
|
OwnerID: "1234",
|
|
|
|
|
Public: true,
|
|
|
|
|
SongCount: 10,
|
|
|
|
|
Duration: 600,
|
|
|
|
|
CreatedAt: createdAt,
|
|
|
|
|
UpdatedAt: updatedAt,
|
|
|
|
|
}
|
2026-01-16 01:55:21 -09:00
|
|
|
})
|
|
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
Context("with minimal client", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
|
|
|
|
player := model.Player{Client: "minimal-client"}
|
|
|
|
|
ctx = request.WithPlayer(ctx, player)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns only basic fields", func() {
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
|
|
|
|
|
|
|
|
|
Expect(result.Id).To(Equal("pls-1"))
|
|
|
|
|
Expect(result.Name).To(Equal("My Playlist"))
|
|
|
|
|
Expect(result.SongCount).To(Equal(int32(10)))
|
|
|
|
|
Expect(result.Duration).To(Equal(int32(600)))
|
|
|
|
|
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
|
|
|
|
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
|
|
|
|
|
|
|
|
|
// These should not be set
|
|
|
|
|
Expect(result.Comment).To(BeEmpty())
|
|
|
|
|
Expect(result.Owner).To(BeEmpty())
|
|
|
|
|
Expect(result.Public).To(BeFalse())
|
|
|
|
|
Expect(result.CoverArt).To(BeEmpty())
|
|
|
|
|
})
|
2026-01-16 01:55:21 -09:00
|
|
|
})
|
|
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
Context("with non-minimal client", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
|
|
|
|
player := model.Player{Client: "regular-client"}
|
|
|
|
|
ctx = request.WithPlayer(ctx, player)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns all fields", func() {
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
|
|
|
|
|
|
|
|
|
Expect(result.Id).To(Equal("pls-1"))
|
|
|
|
|
Expect(result.Name).To(Equal("My Playlist"))
|
|
|
|
|
Expect(result.SongCount).To(Equal(int32(10)))
|
|
|
|
|
Expect(result.Duration).To(Equal(int32(600)))
|
|
|
|
|
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
|
|
|
|
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
|
|
|
|
Expect(result.Comment).To(Equal("Test comment"))
|
|
|
|
|
Expect(result.Owner).To(Equal("admin"))
|
|
|
|
|
Expect(result.Public).To(BeTrue())
|
|
|
|
|
Expect(result.Readonly).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns all fields when as owner", func() {
|
|
|
|
|
ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"})
|
|
|
|
|
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
|
|
|
|
|
|
|
|
|
Expect(result.Id).To(Equal("pls-1"))
|
|
|
|
|
Expect(result.Name).To(Equal("My Playlist"))
|
|
|
|
|
Expect(result.SongCount).To(Equal(int32(10)))
|
|
|
|
|
Expect(result.Duration).To(Equal(int32(600)))
|
|
|
|
|
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
|
|
|
|
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
|
|
|
|
Expect(result.Comment).To(Equal("Test comment"))
|
|
|
|
|
Expect(result.Owner).To(Equal("admin"))
|
|
|
|
|
Expect(result.Public).To(BeTrue())
|
|
|
|
|
Expect(result.Readonly).To(BeFalse())
|
|
|
|
|
})
|
2026-01-16 01:55:21 -09:00
|
|
|
})
|
|
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
Context("when minimal clients list is empty", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Subsonic.MinimalClients = ""
|
|
|
|
|
player := model.Player{Client: "any-client"}
|
|
|
|
|
ctx = request.WithPlayer(ctx, player)
|
|
|
|
|
})
|
2026-01-16 01:55:21 -09:00
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
It("returns all fields", func() {
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
|
|
|
|
|
|
|
|
|
Expect(result.Comment).To(Equal("Test comment"))
|
|
|
|
|
Expect(result.Owner).To(Equal("admin"))
|
|
|
|
|
Expect(result.Public).To(BeTrue())
|
|
|
|
|
})
|
2026-01-16 01:55:21 -09:00
|
|
|
})
|
|
|
|
|
|
2026-04-02 12:37:52 -08:00
|
|
|
Context("with legacy client", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Subsonic.LegacyClients = "legacy-client"
|
|
|
|
|
player := model.Player{Client: "legacy-client"}
|
|
|
|
|
ctx = request.WithPlayer(ctx, player)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns all standard fields but no OpenSubsonic extensions", func() {
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
|
|
|
|
|
|
|
|
|
Expect(result.Comment).To(Equal("Test comment"))
|
|
|
|
|
Expect(result.Owner).To(Equal("admin"))
|
|
|
|
|
Expect(result.Public).To(BeTrue())
|
|
|
|
|
Expect(result.OpenSubsonicPlaylist).To(BeNil())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
Context("when no player in context", func() {
|
|
|
|
|
It("returns all fields", func() {
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
2026-01-16 01:55:21 -09:00
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
Expect(result.Comment).To(Equal("Test comment"))
|
|
|
|
|
Expect(result.Owner).To(Equal("admin"))
|
|
|
|
|
Expect(result.Public).To(BeTrue())
|
|
|
|
|
})
|
2026-01-16 01:55:21 -09:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
Describe("smart playlist", func() {
|
|
|
|
|
evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC)
|
|
|
|
|
validUntil := evaluatedAt.Add(5 * time.Second)
|
2026-01-16 01:55:21 -09:00
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
BeforeEach(func() {
|
|
|
|
|
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
|
|
|
|
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
|
|
|
|
|
|
|
|
|
playlist = model.Playlist{
|
|
|
|
|
ID: "pls-1",
|
|
|
|
|
Name: "My Playlist",
|
|
|
|
|
Comment: "Test comment",
|
|
|
|
|
OwnerName: "admin",
|
|
|
|
|
OwnerID: "1234",
|
|
|
|
|
Public: true,
|
|
|
|
|
SongCount: 10,
|
|
|
|
|
Duration: 600,
|
|
|
|
|
CreatedAt: createdAt,
|
|
|
|
|
UpdatedAt: updatedAt,
|
|
|
|
|
EvaluatedAt: &evaluatedAt,
|
|
|
|
|
Rules: &criteria.Criteria{
|
|
|
|
|
Expression: criteria.All{criteria.Contains{"title": "title"}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with minimal client", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
|
|
|
|
player := model.Player{Client: "minimal-client"}
|
|
|
|
|
ctx = request.WithPlayer(ctx, player)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns only basic fields", func() {
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
|
|
|
|
|
|
|
|
|
Expect(result.Id).To(Equal("pls-1"))
|
|
|
|
|
Expect(result.Name).To(Equal("My Playlist"))
|
|
|
|
|
Expect(result.SongCount).To(Equal(int32(10)))
|
|
|
|
|
Expect(result.Duration).To(Equal(int32(600)))
|
|
|
|
|
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
|
|
|
|
Expect(result.Changed).To(Equal(evaluatedAt))
|
|
|
|
|
|
|
|
|
|
// These should not be set
|
|
|
|
|
Expect(result.Comment).To(BeEmpty())
|
|
|
|
|
Expect(result.Owner).To(BeEmpty())
|
|
|
|
|
Expect(result.Public).To(BeFalse())
|
|
|
|
|
Expect(result.CoverArt).To(BeEmpty())
|
|
|
|
|
Expect(result.OpenSubsonicPlaylist).To(BeNil())
|
|
|
|
|
})
|
2026-01-16 01:55:21 -09:00
|
|
|
})
|
|
|
|
|
|
2026-02-06 15:35:54 -09:00
|
|
|
Context("with non-minimal client", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
|
|
|
|
player := model.Player{Client: "regular-client"}
|
|
|
|
|
ctx = request.WithPlayer(ctx, player)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns all fields", func() {
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
|
|
|
|
Expect(result.Id).To(Equal("pls-1"))
|
|
|
|
|
Expect(result.Name).To(Equal("My Playlist"))
|
|
|
|
|
Expect(result.SongCount).To(Equal(int32(10)))
|
|
|
|
|
Expect(result.Duration).To(Equal(int32(600)))
|
|
|
|
|
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
|
|
|
|
Expect(result.Changed).To(Equal(*playlist.EvaluatedAt))
|
|
|
|
|
Expect(result.Comment).To(Equal("Test comment"))
|
|
|
|
|
Expect(result.Owner).To(Equal("admin"))
|
|
|
|
|
Expect(result.Public).To(BeTrue())
|
|
|
|
|
Expect(result.Readonly).To(BeTrue())
|
|
|
|
|
Expect(result.ValidUntil).To(Equal(&validUntil))
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-04-02 12:37:52 -08:00
|
|
|
|
|
|
|
|
Context("with legacy client", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Subsonic.LegacyClients = "legacy-client"
|
|
|
|
|
player := model.Player{Client: "legacy-client"}
|
|
|
|
|
ctx = request.WithPlayer(ctx, player)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns standard fields but no OpenSubsonic extensions", func() {
|
|
|
|
|
result := router.buildPlaylist(ctx, playlist)
|
|
|
|
|
|
|
|
|
|
Expect(result.Comment).To(Equal("Test comment"))
|
|
|
|
|
Expect(result.Owner).To(Equal("admin"))
|
|
|
|
|
Expect(result.Public).To(BeTrue())
|
|
|
|
|
Expect(result.OpenSubsonicPlaylist).To(BeNil())
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-02-06 15:35:54 -09:00
|
|
|
})
|
2026-01-16 01:55:21 -09:00
|
|
|
})
|
|
|
|
|
|
2025-06-24 04:50:06 -08:00
|
|
|
var _ = Describe("UpdatePlaylist", func() {
|
|
|
|
|
var router *Router
|
|
|
|
|
var ds model.DataStore
|
|
|
|
|
var playlists *fakePlaylists
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
ds = &tests.MockDataStore{}
|
|
|
|
|
playlists = &fakePlaylists{}
|
feat(subsonic): add sonicSimilarity extension as plugin capability (#5419)
* feat(plugins): add sonicSimilarity capability types
Defines the SonicSimilarity plugin capability interface with
GetSonicSimilarTracks and FindSonicPath methods, along with
their request/response types.
* feat(sonic): add core sonic similarity service
Implements the Sonic service with HasProvider, GetSonicSimilarTracks,
and FindSonicPath, delegating to the PluginLoader and using the
Matcher for index-preserving library resolution.
* test(sonic): add sonic service unit tests
Covers HasProvider, GetSonicSimilarTracks, and FindSonicPath with
mock plugin loader and provider, verifying error propagation and
successful match resolution via the library matcher.
* feat(matcher): add MatchSongsToLibraryMap for index-preserving matching
Adds a new method alongside MatchSongsToLibrary that returns a
map[int]MediaFile keyed by input song index rather than a flat slice,
enabling callers to correlate similarity scores back to the original
position in the results.
* fix(sonic): check provider availability before MediaFile lookup
Avoids unnecessary DB call when no plugin is available, and ensures
the correct error path is tested.
* feat(plugins): add sonic similarity adapter
Adds SonicSimilarityAdapter implementing sonic.Provider, bridging the
plugin system to the core sonic service via Extism plugin functions.
Reuses existing songRefsToAgentSongs helper for SongRef conversion.
* feat(plugins): add LoadSonicSimilarity to plugin manager
Adds Manager.LoadSonicSimilarity method following the pattern of
LoadLyricsProvider, enabling the core sonic service to load a
SonicSimilarityAdapter from a named plugin.
* feat(subsonic): add sonicMatch response type
Add SonicMatch struct with Entry and Similarity fields, and a SonicMatches slice to the Subsonic response struct. These types support the OpenSubsonic sonicSimilarity extension for returning similarity-scored track results.
* feat(subsonic): add getSonicSimilarTracks and findSonicPath handlers
Add two new Subsonic API handlers for the sonicSimilarity OpenSubsonic extension: GetSonicSimilarTracks returns similarity-scored tracks similar to a given song, and FindSonicPath returns a path of tracks connecting two songs. Both handlers delegate to the sonic core service and map results to SonicMatch response types.
* feat(subsonic): advertise sonicSimilarity extension when plugin available
Update GetOpenSubsonicExtensions to conditionally include the sonicSimilarity extension only when a sonic similarity plugin provider is available. The nil guard ensures backward compatibility with tests that pass nil for the sonic field. Also update the existing test to pass the new nil parameter.
* feat(subsonic): wire sonic similarity service into router
Add the sonic.Sonic service to the Router struct and New() constructor, register the getSonicSimilarTracks and findSonicPath routes, and wire sonic.New and its PluginLoader binding into the Wire dependency injection graph. Update all existing test call sites to pass the new nil parameter. Regenerate wire_gen.go.
* fix(e2e): add sonic parameter to subsonic.New call in e2e tests
* test(subsonic): add sonicSimilarity extension advertisement tests
Restructures the GetOpenSubsonicExtensions test into two contexts: one verifying the baseline 5 extensions are returned when no sonic similarity plugin is configured, and one verifying that the sonicSimilarity extension is advertised (making 6 total) when a plugin loader reports an available provider. Adds a mockSonicPluginLoader to satisfy the sonic.PluginLoader interface without requiring a real plugin.
* feat(subsonic): add nil guard and e2e tests for sonic similarity endpoints
Handlers return ErrorDataNotFound when no sonic service is configured,
preventing nil panics. E2e tests verify both endpoints return proper
error responses when no plugin is available.
* fix(subsonic): return HTTP 404 when no sonic similarity plugin available
Endpoints are always registered but return 404 when no provider is
available, rather than a subsonic error code 70.
* refactor: clean up sonic similarity code after review
Extract shared helpers to reduce duplication across the sonic similarity
implementation: loadAllMatches in matcher consolidates the 4-phase
matching pipeline, songRefToAgentSong eliminates per-iteration slice
allocation in the adapter, sonicMatchResponse deduplicates response
building in handlers, and a package-level constant replaces raw
capability name strings in core/sonic.
* fix empty response shapes
Signed-off-by: Deluan <deluan@navidrome.org>
* test(plugins): add testdata plugin and e2e tests for sonic similarity
Add a test-sonic-similarity WASM plugin that implements both
GetSonicSimilarTracks and FindSonicPath via the generated sonicsimilarity
PDK. The plugin returns deterministic test data with decreasing similarity
scores and supports error injection via config. Adapter tests verify the
full round-trip through the WASM plugin including error handling. Also
includes regenerated PDK code from make gen.
* docs: update README to include new capabilities and usage examples for plugins
Signed-off-by: Deluan <deluan@navidrome.org>
* test(e2e): enhance sonic similarity tests with additional scenarios and mock provider
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: address PR review feedback for sonic similarity
Fix incorrect field names in README documentation ({from, to} →
{startSong, endSong}) and remove unnecessary XML serialization test
from e2e suite since OpenSubsonic endpoints only use JSON.
* refactor: rename Matcher methods for conciseness
Rename MatchSongsToLibrary to MatchSongs and MatchSongsToLibraryMap to
MatchSongsIndexed. The Matcher receiver already establishes the "to
library" context, making that suffix redundant, and "Indexed" better
describes the intent (preserving input ordering) than "Map" which
describes the data structure.
* refactor: standardize variable naming for media files in sonic path methods
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: simplify plugin loading by introducing adapter constructors
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-27 13:50:09 -08:00
|
|
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil)
|
2025-06-24 04:50:06 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("clears the comment when parameter is empty", func() {
|
|
|
|
|
r := newGetRequest("playlistId=123", "comment=")
|
|
|
|
|
_, err := router.UpdatePlaylist(r)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
|
|
|
|
Expect(playlists.lastComment).ToNot(BeNil())
|
|
|
|
|
Expect(*playlists.lastComment).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("leaves comment unchanged when parameter is missing", func() {
|
|
|
|
|
r := newGetRequest("playlistId=123")
|
|
|
|
|
_, err := router.UpdatePlaylist(r)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
|
|
|
|
Expect(playlists.lastComment).To(BeNil())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("sets public to true when parameter is 'true'", func() {
|
|
|
|
|
r := newGetRequest("playlistId=123", "public=true")
|
|
|
|
|
_, err := router.UpdatePlaylist(r)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
|
|
|
|
Expect(playlists.lastPublic).ToNot(BeNil())
|
|
|
|
|
Expect(*playlists.lastPublic).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("sets public to false when parameter is 'false'", func() {
|
|
|
|
|
r := newGetRequest("playlistId=123", "public=false")
|
|
|
|
|
_, err := router.UpdatePlaylist(r)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
|
|
|
|
Expect(playlists.lastPublic).ToNot(BeNil())
|
|
|
|
|
Expect(*playlists.lastPublic).To(BeFalse())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("leaves public unchanged when parameter is missing", func() {
|
|
|
|
|
r := newGetRequest("playlistId=123")
|
|
|
|
|
_, err := router.UpdatePlaylist(r)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
|
|
|
|
Expect(playlists.lastPublic).To(BeNil())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
type fakePlaylists struct {
|
refactor: move playlist business logic from repositories to service layer (#5027)
* refactor: move playlist business logic from repositories to core.Playlists service
Move authorization, permission checks, and orchestration logic from
playlist repositories to the core.Playlists service, following the
existing pattern used by core.Share and core.Library.
Changes:
- Expand core.Playlists interface with read, mutation, track management,
and REST adapter methods
- Add playlistRepositoryWrapper for REST Save/Update/Delete with
permission checks (follows Share/Library pattern)
- Simplify persistence/playlist_repository.go: remove isWritable(),
auth checks from Delete()/Put()/updatePlaylist()
- Simplify persistence/playlist_track_repository.go: remove
isTracksEditable() and permission checks from Add/Delete/Reorder
- Update Subsonic API handlers to route through service
- Update Native API handlers to accept core.Playlists instead of
model.DataStore
* test: add coverage for playlist service methods and REST wrapper
Add 30 new tests covering the service methods added during the playlist
refactoring:
- Delete: owner, admin, denied, not found
- Create: new playlist, replace tracks, admin bypass, denied, not found
- AddTracks: owner, admin, denied, smart playlist, not found
- RemoveTracks: owner, smart playlist denied, non-owner denied
- ReorderTrack: owner, smart playlist denied
- NewRepository wrapper: Save (owner assignment, ID clearing),
Update (owner, admin, denied, ownership change, not found),
Delete (delegation with permission checks)
Expand mockedPlaylistRepo with Get, Delete, Tracks, GetWithTracks, and
rest.Persistable methods. Add mockedPlaylistTrackRepo for track
operation verification.
* fix: add authorization check to playlist Update method
Added ownership verification to the Subsonic Update endpoint in the
playlist service layer. The authorization check was present in the old
repository code but was not carried over during the refactoring to the
service layer, allowing any authenticated user to modify playlists they
don't own via the Subsonic API. Also added corresponding tests for the
Update method's permission logic.
* refactor: improve playlist permission checks and error handling, add e2e tests
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename core.Playlists to playlists package and update references
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename playlists_internal_test.go to parse_m3u_test.go and update tests; add new parse_nsp.go and rest_adapter.go files
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: block track mutations on smart playlists in Create and Update
Create now rejects replacing tracks on smart playlists (pre-existing
gap). Update now uses checkTracksEditable instead of checkWritable
when track changes are requested, restoring the protection that was
removed from the repository layer during the refactoring. Metadata-only
updates on smart playlists remain allowed.
* test: add smart playlist protection tests to ensure readonly behavior and mutation restrictions
* refactor: optimize track removal and renumbering in playlists
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: implement track reordering in playlists with SQL updates
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: wrap track deletion and reordering in transactions for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: remove unused getTracks method from playlistTrackRepository
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: optimize playlist track renumbering with CTE-based UPDATE
Replace the DELETE + re-INSERT renumbering strategy with a two-step
UPDATE approach using a materialized CTE and ROW_NUMBER() window
function. The previous approach (SELECT all IDs, DELETE all tracks,
re-INSERT in chunks of 200) required 13 SQL operations for a 2000-track
playlist. The new approach uses just 2 UPDATEs: first negating all IDs
to clear the positive space, then assigning sequential positions via
UPDATE...FROM with a CTE. This avoids the UNIQUE constraint violations
that affected the original correlated subquery while reducing per-delete
request time from ~110ms to ~12ms on a 2000-track playlist.
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: rename New function to NewPlaylists for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: update mock playlist repository and tests for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 15:57:13 -09:00
|
|
|
playlists.Playlists
|
2025-06-24 04:50:06 -08:00
|
|
|
lastPlaylistID string
|
|
|
|
|
lastName *string
|
|
|
|
|
lastComment *string
|
|
|
|
|
lastPublic *bool
|
|
|
|
|
lastAdd []string
|
|
|
|
|
lastRemove []int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fakePlaylists) Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error {
|
|
|
|
|
f.lastPlaylistID = playlistID
|
|
|
|
|
f.lastName = name
|
|
|
|
|
f.lastComment = comment
|
|
|
|
|
f.lastPublic = public
|
|
|
|
|
f.lastAdd = idsToAdd
|
|
|
|
|
f.lastRemove = idxToRemove
|
|
|
|
|
return nil
|
|
|
|
|
}
|