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): implement OpenSubsonic Transcoding extension (#4990)
* feat(subsonic): implement transcode decision logic and codec handling for media files
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(subsonic): update codec limitation structure and decision logic for improved clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): simplify container alias handling in matchesContainer function
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): enhance logging for transcode decision process and client info conversion
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): enhance transcoding config lookup logic for audio codecs
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): enhance transcoding options with sample rate support and improve command handling
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): add bit depth support for audio transcoding and enhance related logic
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): enhance AAC command handling and support for audio channels in streaming
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): update default command handling and add codec support for transcoding
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: implement noopDecider for transcoding decision handling in tests
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: address review findings for OpenSubsonic transcoding PR
Fix multiple issues identified during code review of the transcoding
extension: add missing return after error in shared stream handler
preventing nil pointer panic, replace dead r.Body nil check with
MaxBytesReader size limit, distinguish not-found from other DB errors,
fix bpsToKbps integer truncation with rounding, add "pcm" to
isLosslessFormat for consistency with model.IsLossless(), add
sampleRate/bitDepth/channels to streaming log, fix outdated test
comment, and add tests for conversion functions and GetTranscodeStream
parameter passing.
* feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: small issues
Updated mock AAC transcoding command to use the new default (ipod with
fragmented MP4) matching the migration, ensuring tests exercise the same
buildDynamicArgs code path as production. Improved archiver test mock to
match on the whole StreamRequest struct instead of decomposing fields,
making it resilient to future field additions. Added named constants for
JWT claim keys in the transcode token and wrapped ParseTranscodeParams
errors with ErrTokenInvalid for consistency. Documented the IsLossless
BitDepth fallback heuristic as temporary until Codec column is populated.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcoding): adapt transcode claims to struct-based auth.Claims
Updated transcode token handling to use the struct-based auth.Claims
introduced on master, replacing the previous map[string]any approach.
Extended auth.Claims with transcoding-specific fields (MediaID, DirectPlay,
UpdatedAt, Channels, SampleRate, BitDepth) and added float64 fallback in
ClaimsFromToken for numeric claims that lose their Go type during JWT
string serialization. Also added the missing lyrics parameter to all
subsonic.New() calls in test files.
* feat(model): add ProbeData field and UpdateProbeData repository method
Add probe_data TEXT column to media_file for caching ffprobe results.
Add UpdateProbeData to MediaFileRepository interface and implementations.
Use hash:"ignore" tag so probe data doesn't affect MediaFile fingerprints.
* feat(ffmpeg): add ProbeAudioStream for authoritative audio metadata
Add ProbeAudioStream to FFmpeg interface, using ffprobe to extract
codec, profile, bitrate, sample rate, bit depth, and channels.
Parse bits_per_raw_sample as fallback for FLAC/ALAC bit depth.
Normalize "unknown" profile to empty string.
All parseProbeOutput tests use real ffprobe JSON from actual files.
* feat(transcoding): integrate ffprobe into transcode decisions
Add ensureProbed to probe media files on first transcode decision,
caching results in probe_data. Build SourceStream from probe data
with fallback to tag-based metadata.
Refactor decision logic to pass StreamDetails instead of MediaFile,
enabling codec profile limitations (e.g., audioProfile) to use
probe data. Add normalizeProbeCodec to map ffprobe codec names
(dsd_lsbf_planar, pcm_s16le) to internal names (dsd, pcm).
NewDecider now accepts ffmpeg.FFmpeg; wire_gen.go regenerated.
* feat(transcoding): add DevEnableMediaFileProbe config flag
Add DevEnableMediaFileProbe (default true) to allow disabling ffprobe-
based media file probing as a safety fallback. When disabled, the
decider uses tag-based metadata from the scanner instead.
* test(transcode): add ensureProbed unit tests
Test probing when ProbeData is empty, skipping when already set,
error propagation from ffprobe, and DevEnableMediaFileProbe flag.
* refactor(ffmpeg): use command constant and select_streams for ProbeAudioStream
Move ffprobe arguments to a probeAudioStreamCmd constant, following the
same pattern as extractImageCmd and probeCmd. Add -select_streams a:0 to
only probe the first audio stream, avoiding unnecessary parsing of video
and artwork streams. Derive the ffprobe binary path safely using
filepath.Dir/Base instead of replacing within the full path string.
* refactor(transcode): decouple transcode token claims from auth.Claims
Remove six transcode-specific fields (MediaID, DirectPlay, UpdatedAt,
Channels, SampleRate, BitDepth) from auth.Claims, which is shared with
session and share tokens. Transcode tokens are signed parameter-passing
tokens, not authentication tokens, so coupling them to auth created
misleading dependencies.
The transcode package now owns its own JWT claim serialization via
Decision.toClaimsMap() and paramsFromToken(), using generic
auth.EncodeToken/DecodeAndVerifyToken wrappers that keep TokenAuth
encapsulated. Wire format (JWT claim keys) is unchanged, so in-flight
tokens remain compatible.
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcode): simplify code after review
Extract getIntClaim helper to eliminate repeated int/int64/float64 JWT
claim extraction pattern in paramsFromToken and ClaimsFromToken. Rewrite
checkIntLimitation as a one-liner delegating to applyIntLimitation.
Return probe result from ensureProbed to avoid redundant JSON round-trip.
Extract toResponseStreamDetails helper and mediaTypeSong constant in
the API layer, and use transcode.ProtocolHTTP constant instead of
hardcoded string.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ffmpeg): enhance bit_rate parsing logic for audio streams
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcode): improve code review findings across transcode implementation
- Fix parseProbeData to return nil on JSON unmarshal failure instead of
a zero-valued struct, preventing silent degradation of source stream details
- Use probe-resolved codec for lossless detection in buildSourceStream
instead of the potentially stale scanner data
- Remove MediaFile.IsLossless() (dead code) and consolidate lossless
detection in isLosslessFormat(), using codec name only — bit depth is
not reliable since lossy codecs like ADPCM report non-zero values
- Add "wavpack" to lossless codec list (ffprobe codec_name for WavPack)
- Guard bpsToKbps against negative input values
- Fix misleading comment in buildTemplateArgs about conditional injection
- Avoid leaking internal error details in Subsonic API responses
- Add missing test for ErrNotFound branch in GetTranscodeDecision
- Add TODO for hardcoded protocol in toResponseStreamDetails
* refactor(transcode): streamline transcoding command lookup and format resolution
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(transcode): implement server-side transcoding override for player formats
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcode): honor bit depth and channel constraints in transcoding selection
selectTranscodingOptions only checked sample rate when deciding whether
same-format transcoding was needed, ignoring requested bit depth and
channel reductions. This caused the streamer to return raw audio when
the transcode decision requested downmix or bit-depth conversion.
* refactor(transcode): unify streaming decision engine via MakeDecision
Move transcoding decision-making out of mediaStreamer and into the
subsonic Stream/Download handlers, using transcode.Decider.MakeDecision
as the single decision engine. This eliminates selectTranscodingOptions
and the mismatch between decision and streaming code paths (decision
used LookupTranscodeCommand with built-in fallbacks, while streaming
used FindByFormat which only checked the DB).
- Add DecisionOptions with SkipProbe to MakeDecision so the legacy
streaming path never calls ffprobe
- Add buildLegacyClientInfo to translate legacy stream params (format,
maxBitRate, DefaultDownsamplingFormat) into a synthetic ClientInfo
- Add resolveStreamRequest on the subsonic Router to resolve legacy
params into a fully specified StreamRequest via MakeDecision
- Simplify DoStream to a dumb executor that receives pre-resolved params
- Remove selectTranscodingOptions entirely
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcode): move MediaStreamer into core/transcode and unify StreamRequest
Moved MediaStreamer, Stream, TranscodingCache and related types from
core/media_streamer.go into core/transcode/, eliminating the duplicate
StreamRequest type. The transcode.StreamRequest now carries all fields
(ID, Format, BitRate, SampleRate, BitDepth, Channels, Offset) and
ResolveStream returns a fully-populated value, removing manual field
copying at every call site. Also moved buildLegacyClientInfo into the
transcode package alongside ResolveStream, and unexported
ParseTranscodeParams since it was only used internally by
ValidateTranscodeParams.
* refactor(transcode): rename Decider methods and unexport Params type
Rename ResolveStream → ResolveRequest and ValidateTranscodeParams →
ResolveRequestFromToken for clarity and consistency. The new
ResolveRequestFromToken returns a StreamRequest directly (instead of
the intermediate Params type), eliminating manual Params→StreamRequest
conversion in callers. Unexport Params to params since it is now only
used internally for JWT token parsing.
* test(transcode): remove redundant tests and use constants
Remove tests that duplicate coverage from integration-level tests
(toClaimsMap, paramsFromToken round-trips, applyServerOverride direct
call, duplicate 410 handler test). Replace raw "http" strings with
ProtocolHTTP constant. Consolidate lossy -sample_fmt tests into
DescribeTable.
* refactor(transcode): split oversized files into focused modules
Split transcode.go and transcode_test.go into focused files by concern:
- decider.go: decision engine (MakeDecision, direct play/transcode evaluation, probe)
- token.go: JWT token encode/decode (params, toClaimsMap, paramsFromToken, CreateTranscodeParams, ResolveRequestFromToken)
- legacy_client.go: legacy Subsonic bridge (buildLegacyClientInfo, ResolveRequest)
- codec_test.go: isLosslessFormat and normalizeProbeCodec tests
- token_test.go: token round-trip and ResolveRequestFromToken tests
Moved the Decider interface from types.go to decider.go to keep it near
its implementation, and cleaned up types.go to contain only pure type
definitions and constants. No public API changes.
* refactor(transcode): reorder parameters in applyServerOverride function
Signed-off-by: Deluan <deluan@navidrome.org>
* test(e2e): add NewTestStream function and implement spyStreamer for testing
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-08 19:57:49 -08:00
|
|
|
router = New(ds, 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-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-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): implement OpenSubsonic Transcoding extension (#4990)
* feat(subsonic): implement transcode decision logic and codec handling for media files
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(subsonic): update codec limitation structure and decision logic for improved clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): simplify container alias handling in matchesContainer function
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): enhance logging for transcode decision process and client info conversion
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): enhance transcoding config lookup logic for audio codecs
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): enhance transcoding options with sample rate support and improve command handling
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): add bit depth support for audio transcoding and enhance related logic
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): enhance AAC command handling and support for audio channels in streaming
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcoding): update default command handling and add codec support for transcoding
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: implement noopDecider for transcoding decision handling in tests
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: address review findings for OpenSubsonic transcoding PR
Fix multiple issues identified during code review of the transcoding
extension: add missing return after error in shared stream handler
preventing nil pointer panic, replace dead r.Body nil check with
MaxBytesReader size limit, distinguish not-found from other DB errors,
fix bpsToKbps integer truncation with rounding, add "pcm" to
isLosslessFormat for consistency with model.IsLossless(), add
sampleRate/bitDepth/channels to streaming log, fix outdated test
comment, and add tests for conversion functions and GetTranscodeStream
parameter passing.
* feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters
Signed-off-by: Deluan <deluan@navidrome.org>
* fix: small issues
Updated mock AAC transcoding command to use the new default (ipod with
fragmented MP4) matching the migration, ensuring tests exercise the same
buildDynamicArgs code path as production. Improved archiver test mock to
match on the whole StreamRequest struct instead of decomposing fields,
making it resilient to future field additions. Added named constants for
JWT claim keys in the transcode token and wrapped ParseTranscodeParams
errors with ErrTokenInvalid for consistency. Documented the IsLossless
BitDepth fallback heuristic as temporary until Codec column is populated.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcoding): adapt transcode claims to struct-based auth.Claims
Updated transcode token handling to use the struct-based auth.Claims
introduced on master, replacing the previous map[string]any approach.
Extended auth.Claims with transcoding-specific fields (MediaID, DirectPlay,
UpdatedAt, Channels, SampleRate, BitDepth) and added float64 fallback in
ClaimsFromToken for numeric claims that lose their Go type during JWT
string serialization. Also added the missing lyrics parameter to all
subsonic.New() calls in test files.
* feat(model): add ProbeData field and UpdateProbeData repository method
Add probe_data TEXT column to media_file for caching ffprobe results.
Add UpdateProbeData to MediaFileRepository interface and implementations.
Use hash:"ignore" tag so probe data doesn't affect MediaFile fingerprints.
* feat(ffmpeg): add ProbeAudioStream for authoritative audio metadata
Add ProbeAudioStream to FFmpeg interface, using ffprobe to extract
codec, profile, bitrate, sample rate, bit depth, and channels.
Parse bits_per_raw_sample as fallback for FLAC/ALAC bit depth.
Normalize "unknown" profile to empty string.
All parseProbeOutput tests use real ffprobe JSON from actual files.
* feat(transcoding): integrate ffprobe into transcode decisions
Add ensureProbed to probe media files on first transcode decision,
caching results in probe_data. Build SourceStream from probe data
with fallback to tag-based metadata.
Refactor decision logic to pass StreamDetails instead of MediaFile,
enabling codec profile limitations (e.g., audioProfile) to use
probe data. Add normalizeProbeCodec to map ffprobe codec names
(dsd_lsbf_planar, pcm_s16le) to internal names (dsd, pcm).
NewDecider now accepts ffmpeg.FFmpeg; wire_gen.go regenerated.
* feat(transcoding): add DevEnableMediaFileProbe config flag
Add DevEnableMediaFileProbe (default true) to allow disabling ffprobe-
based media file probing as a safety fallback. When disabled, the
decider uses tag-based metadata from the scanner instead.
* test(transcode): add ensureProbed unit tests
Test probing when ProbeData is empty, skipping when already set,
error propagation from ffprobe, and DevEnableMediaFileProbe flag.
* refactor(ffmpeg): use command constant and select_streams for ProbeAudioStream
Move ffprobe arguments to a probeAudioStreamCmd constant, following the
same pattern as extractImageCmd and probeCmd. Add -select_streams a:0 to
only probe the first audio stream, avoiding unnecessary parsing of video
and artwork streams. Derive the ffprobe binary path safely using
filepath.Dir/Base instead of replacing within the full path string.
* refactor(transcode): decouple transcode token claims from auth.Claims
Remove six transcode-specific fields (MediaID, DirectPlay, UpdatedAt,
Channels, SampleRate, BitDepth) from auth.Claims, which is shared with
session and share tokens. Transcode tokens are signed parameter-passing
tokens, not authentication tokens, so coupling them to auth created
misleading dependencies.
The transcode package now owns its own JWT claim serialization via
Decision.toClaimsMap() and paramsFromToken(), using generic
auth.EncodeToken/DecodeAndVerifyToken wrappers that keep TokenAuth
encapsulated. Wire format (JWT claim keys) is unchanged, so in-flight
tokens remain compatible.
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcode): simplify code after review
Extract getIntClaim helper to eliminate repeated int/int64/float64 JWT
claim extraction pattern in paramsFromToken and ClaimsFromToken. Rewrite
checkIntLimitation as a one-liner delegating to applyIntLimitation.
Return probe result from ensureProbed to avoid redundant JSON round-trip.
Extract toResponseStreamDetails helper and mediaTypeSong constant in
the API layer, and use transcode.ProtocolHTTP constant instead of
hardcoded string.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ffmpeg): enhance bit_rate parsing logic for audio streams
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcode): improve code review findings across transcode implementation
- Fix parseProbeData to return nil on JSON unmarshal failure instead of
a zero-valued struct, preventing silent degradation of source stream details
- Use probe-resolved codec for lossless detection in buildSourceStream
instead of the potentially stale scanner data
- Remove MediaFile.IsLossless() (dead code) and consolidate lossless
detection in isLosslessFormat(), using codec name only — bit depth is
not reliable since lossy codecs like ADPCM report non-zero values
- Add "wavpack" to lossless codec list (ffprobe codec_name for WavPack)
- Guard bpsToKbps against negative input values
- Fix misleading comment in buildTemplateArgs about conditional injection
- Avoid leaking internal error details in Subsonic API responses
- Add missing test for ErrNotFound branch in GetTranscodeDecision
- Add TODO for hardcoded protocol in toResponseStreamDetails
* refactor(transcode): streamline transcoding command lookup and format resolution
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(transcode): implement server-side transcoding override for player formats
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(transcode): honor bit depth and channel constraints in transcoding selection
selectTranscodingOptions only checked sample rate when deciding whether
same-format transcoding was needed, ignoring requested bit depth and
channel reductions. This caused the streamer to return raw audio when
the transcode decision requested downmix or bit-depth conversion.
* refactor(transcode): unify streaming decision engine via MakeDecision
Move transcoding decision-making out of mediaStreamer and into the
subsonic Stream/Download handlers, using transcode.Decider.MakeDecision
as the single decision engine. This eliminates selectTranscodingOptions
and the mismatch between decision and streaming code paths (decision
used LookupTranscodeCommand with built-in fallbacks, while streaming
used FindByFormat which only checked the DB).
- Add DecisionOptions with SkipProbe to MakeDecision so the legacy
streaming path never calls ffprobe
- Add buildLegacyClientInfo to translate legacy stream params (format,
maxBitRate, DefaultDownsamplingFormat) into a synthetic ClientInfo
- Add resolveStreamRequest on the subsonic Router to resolve legacy
params into a fully specified StreamRequest via MakeDecision
- Simplify DoStream to a dumb executor that receives pre-resolved params
- Remove selectTranscodingOptions entirely
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(transcode): move MediaStreamer into core/transcode and unify StreamRequest
Moved MediaStreamer, Stream, TranscodingCache and related types from
core/media_streamer.go into core/transcode/, eliminating the duplicate
StreamRequest type. The transcode.StreamRequest now carries all fields
(ID, Format, BitRate, SampleRate, BitDepth, Channels, Offset) and
ResolveStream returns a fully-populated value, removing manual field
copying at every call site. Also moved buildLegacyClientInfo into the
transcode package alongside ResolveStream, and unexported
ParseTranscodeParams since it was only used internally by
ValidateTranscodeParams.
* refactor(transcode): rename Decider methods and unexport Params type
Rename ResolveStream → ResolveRequest and ValidateTranscodeParams →
ResolveRequestFromToken for clarity and consistency. The new
ResolveRequestFromToken returns a StreamRequest directly (instead of
the intermediate Params type), eliminating manual Params→StreamRequest
conversion in callers. Unexport Params to params since it is now only
used internally for JWT token parsing.
* test(transcode): remove redundant tests and use constants
Remove tests that duplicate coverage from integration-level tests
(toClaimsMap, paramsFromToken round-trips, applyServerOverride direct
call, duplicate 410 handler test). Replace raw "http" strings with
ProtocolHTTP constant. Consolidate lossy -sample_fmt tests into
DescribeTable.
* refactor(transcode): split oversized files into focused modules
Split transcode.go and transcode_test.go into focused files by concern:
- decider.go: decision engine (MakeDecision, direct play/transcode evaluation, probe)
- token.go: JWT token encode/decode (params, toClaimsMap, paramsFromToken, CreateTranscodeParams, ResolveRequestFromToken)
- legacy_client.go: legacy Subsonic bridge (buildLegacyClientInfo, ResolveRequest)
- codec_test.go: isLosslessFormat and normalizeProbeCodec tests
- token_test.go: token round-trip and ResolveRequestFromToken tests
Moved the Decider interface from types.go to decider.go to keep it near
its implementation, and cleaned up types.go to contain only pure type
definitions and constants. No public API changes.
* refactor(transcode): reorder parameters in applyServerOverride function
Signed-off-by: Deluan <deluan@navidrome.org>
* test(e2e): add NewTestStream function and implement spyStreamer for testing
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-08 19:57:49 -08:00
|
|
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, 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
|
|
|
|
|
}
|