Commit Graph

22 Commits

Author SHA1 Message Date
Deluan Quintão
27209ed26a fix(transcoding): clamp target channels to codec limit (#5336) (#5345)
* fix(transcoding): clamp target channels to codec limit (#5336)

When transcoding a multi-channel source (e.g. 6-channel FLAC) to MP3, the
decider passed the source channel count through to ffmpeg unchanged. The
default MP3 command path then emitted `-ac 6`, and the template path injected
`-ac 6` after the template's own `-ac 2`, causing ffmpeg to honor the last
occurrence and fail with exit code 234 since libmp3lame only supports up to
2 channels.

Introduce `codecMaxChannels()` in core/stream/codec.go (mp3→2, opus→8),
mirroring the existing `codecMaxSampleRate` pattern, and apply the clamp in
`computeTranscodedStream` right after the sample-rate clamps. Also fix a
pre-existing ordering bug where the profile's MaxAudioChannels check compared
against src.Channels rather than ts.Channels, which would have let a looser
profile setting raise the codec-clamped value back up. Comparing against the
already-clamped ts.Channels makes profile limits strictly narrowing, which
matches how the sample-rate block already behaves.

The ffmpeg buildTemplateArgs comment is refreshed to point at the new upstream
clamp, since the flags it injects are now always codec-safe.

Adds unit tests for codecMaxChannels and four decider scenarios covering the
literal issue repro (6-ch FLAC→MP3 clamps to 2), a stricter profile limit
winning over the codec clamp, a looser profile limit leaving the codec clamp
intact, and a codec with no hard limit (AAC) passing 6 channels through.

* test(e2e): pin codec channel clamp at the Subsonic API surface (#5336)

Add a 6-channel FLAC fixture to the e2e test suite and use it to assert the
codec channel clamp end-to-end on both Subsonic streaming endpoints:

- getTranscodeDecision (mp3OnlyClient, no MaxAudioChannels in profile):
  expects TranscodeStream.AudioChannels == 2 for the 6-channel source. This
  exercises the new codecMaxChannels() helper through the OpenSubsonic
  decision endpoint, with no profile-level channel limit masking the bug.

- /rest/stream (legacy): requests format=mp3 against the multichannel
  fixture and asserts streamerSpy.LastRequest.Channels == 2, confirming
  the clamp propagates through ResolveRequest into the stream.Request that
  the streamer receives.

The fixture is metadata-only (channels: 6 plumbed via the existing
storagetest.File helper) — no real audio bytes required, since the e2e
suite uses a spy streamer rather than invoking ffmpeg. Bumps the empty-query
search3 song count expectation from 13 to 14 to account for the new fixture.

* test(decider): clarify codec-clamp comment terminology

Distinguish "transcoding profile MaxAudioChannels" (Profile.MaxAudioChannels
field) from "LimitationAudioChannels" (CodecProfile rule constant). The
regression test bypasses the former, not the latter.
2026-04-11 23:15:07 -04:00
Deluan Quintão
36a7be9eaf fix(transcoding): include ffprobe in MSI and fall back gracefully when absent (#5326)
* fix(msi): include ffprobe executable in MSI build

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ffmpeg): add IsProbeAvailable() to FFmpeg interface

Add runtime check for ffprobe binary availability with cached result
and startup logging. When ffprobe is missing, logs a warning at startup.

* feat(stream): guard MakeDecision behind ffprobe availability

When ffprobe is not available, MakeDecision returns a decision with
ErrorReason set and both CanDirectPlay and CanTranscode false, instead
of failing with an opaque exec error.

* feat(subsonic): only advertise transcoding extension when ffprobe is available

The OpenSubsonic transcoding extension is now conditionally included
based on ffprobe availability, so clients know not to call
getTranscodeDecision when ffprobe is missing.

* refactor(ffmpeg): move ffprobe startup warning to initial_setup

Move the ffprobe availability warning from the lazy IsProbeAvailable()
check to checkFFmpegInstallation() in server/initial_setup.go, alongside
the existing ffmpeg warning. This ensures the warning appears at startup
rather than on first endpoint call.

* fix(e2e): set noopFFmpeg.IsProbeAvailable to true

The e2e tests use pre-populated probe data and don't need a real ffprobe
binary. Setting IsProbeAvailable to true allows the transcode decision
logic to proceed normally in e2e tests.

* fix(stream): only guard on ffprobe when probing is needed

Move the IsProbeAvailable() guard inside the SkipProbe check so that
legacy stream requests (which pass SkipProbe: true) are not blocked
when ffprobe is missing. The guard only applies when probing is
actually required (i.e., getTranscodeDecision endpoint).

* refactor(stream): fall back to tag metadata when ffprobe is unavailable

Instead of blocking getTranscodeDecision when ffprobe is missing,
fall back to tag-based metadata (same behavior as /rest/stream).
The transcoding extension is always advertised. A startup warning
still alerts admins when ffprobe is not found.

* fix(stream): downgrade ffprobe-unavailable log to Debug

Avoids log spam when clients call getTranscodeDecision repeatedly
without ffprobe installed. The startup warning in initial_setup.go
already alerts admins at Warn level.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-07 20:11:38 -04:00
Deluan Quintão
3f7226d253 fix(server): improve transcoding failure diagnostics and error responses (#5227)
* fix(server): capture ffmpeg stderr and warn on empty transcoded output

When ffmpeg fails during transcoding (e.g., missing codec like libopus),
the error was silently discarded because stderr was sent to io.Discard
and the HTTP response returned 200 OK with a 0-byte body.

- Capture ffmpeg stderr in a bounded buffer (4KB) and include it in the
  error message when the process exits with a non-zero status code
- Log a warning when transcoded output is 0 bytes, guiding users to
  check codec support and enable Trace logging for details
- Remove log level guard so transcoding errors are always logged, not
  just at Debug level

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): return proper error responses for empty transcoded output

Instead of returning HTTP 200 with 0-byte body when transcoding fails,
return a Subsonic error response (for stream/download/getTranscodeStream)
or HTTP 500 (for public shared streams). This gives clients a clear
signal that the request failed rather than a misleading empty success.

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): add tests for empty transcoded stream error responses

Add E2E tests verifying that stream and download endpoints return
Subsonic error responses when transcoding produces empty output.
Extend spyStreamer with SimulateEmptyStream and SimulateError fields
to support failure injection in tests.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(server): extract stream serving logic into Stream.Serve method

Extract the duplicated non-seekable stream serving logic (header setup,
estimateContentLength, HEAD draining, io.Copy with error/empty detection)
from server/subsonic/stream.go and server/public/handle_streams.go into a
single Stream.Serve method on core/stream. Both callers now delegate to it,
eliminating ~30 lines of near-identical code.

* fix(server): return 200 with empty body for stream/download on empty transcoded output

Don't return a Subsonic error response when transcoding produces empty
output on stream/download endpoints — just log the error and return 200
with an empty body. The getTranscodeStream and public share endpoints
still return HTTP 500 for empty output. Stream.Serve now returns
(int64, error) so callers can check the byte count.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-18 12:39:03 -04:00
Deluan Quintão
ab8a58157a feat: add artist image uploads and image-folder artwork source (#5198)
* feat: add shared ImageUploadService for entity image management

* feat: add UploadedImage field and methods to Artist model

* feat: add uploaded_image column to artist table

* feat: add ArtistImageFolder config option

* refactor: wire ImageUploadService and delegate playlist file ops to it

Wire ImageUploadService into the DI container and refactor the playlist
service to delegate image file operations (SetImage/RemoveImage) to the
shared ImageUploadService, removing duplicated file I/O logic. A local
ImageUploadService interface is defined in core/playlists to avoid an
import cycle between core and core/playlists.

* feat: artist artwork reader checks uploaded image first

* feat: add image-folder priority source for artist artwork

* feat: cache key invalidation for image-folder and uploaded images

* refactor: extract shared image upload HTTP helpers

* feat: add artist image upload/delete API endpoints

* refactor: playlist handlers use shared image upload helpers

* feat: add shared ImageUploadOverlay component

* feat: add i18n keys for artist image upload

* feat: add image upload overlay to artist detail pages

* refactor: playlist details uses shared ImageUploadOverlay component

* fix: add gosec nolint directive for ParseMultipartForm

* refactor: deduplicate image upload code and optimize dir scanning

- Remove dead ImageFilename methods from Artist and Playlist models
  (production code uses core.imageFilename exclusively)
- Extract shared uploadedImagePath helper in model/image.go
- Extract findImageInArtistFolder to deduplicate dir-scanning logic
  between fromArtistImageFolder and getArtistImageFolderModTime
- Fix fileInputRef in useCallback dependency array

* fix: include artist UpdatedAt in artwork cache key

Without this, uploading or deleting an artist image would not
invalidate the cached artwork because the cache key was only based
on album folder timestamps, not the artist's own UpdatedAt field.

* feat: add Portuguese translations for artist image upload

* refactor: use shared i18n keys for cover art upload messages

Move cover art upload/remove translations from per-entity sections
(artist, playlist) to a shared top-level "message" section, avoiding
duplication across entity types and translation files.

* refactor: move cover art i18n keys to shared message section for all languages

* refactor: simplify image upload code and eliminate redundancies

Extracted duplicate image loading/lightbox state logic from
DesktopArtistDetails and MobileArtistDetails into a shared
useArtistImageState hook. Moved entity type constants to the consts
package and replaced raw string literals throughout model, core, and
nativeapi packages. Exported model.UploadedImagePath and reused it in
core/image_upload.go to consolidate path construction. Cached the
ArtistImageFolder lookup result in artistReader to eliminate a redundant
os.ReadDir call on every artwork request.

Signed-off-by: Deluan <deluan@navidrome.org>

* style: fix prettier formatting in ImageUploadOverlay

* fix: address code review feedback on image upload error handling

- RemoveImage now returns errors instead of swallowing them
- Artist handlers distinguish not-found from other DB errors
- Defer multipart temp file cleanup after parsing

* fix: enforce hard request size limit with MaxBytesReader for image uploads

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-15 22:19:55 -04:00
Deluan Quintão
a50b2a1e72 feat(artwork): preserve animated image artwork during resize (#5184)
* feat(artwork): preserve animated image artwork during resize

Detect animated GIFs, WebPs, and APNGs via lightweight byte scanning
and preserve their animation when serving resized artwork. Animated GIFs
are converted to animated WebP via ffmpeg with optional downscaling;
animated WebP/APNG are returned as-is since ffmpeg cannot re-encode them.

Adds ConvertAnimatedImage to the FFmpeg interface for piping stdin data
through ffmpeg with animated WebP output.

* fix(artwork): address code review feedback for animated artwork

Fix ReadCloser leak where ffmpeg pipe's Close was discarded by
io.NopCloser wrapping — now preserves ReadCloser semantics when the
resized reader already supports Close. Use uint64 for PNG chunk position
to prevent potential overflow on 32-bit platforms. Add integration tests
for the animation branching logic in resizeImage.
2026-03-13 18:11:12 -04:00
Kendall Garner
903e3f070f fix(subsonic): always return required playqueue fields (#5172) 2026-03-12 08:29:37 -04:00
Deluan
5ecbe31a06 fix: implement fallback to DefaultDownsamplingFormat for unknown formats
Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-11 09:46:13 -04:00
Deluan
75e5bc4e81 refactor: rename spy to streamerSpy in e2e tests for clarity
Renamed the spy variable to streamerSpy across all e2e test files so
that its purpose is immediately clear without needing to look up the
declaration.
2026-03-10 17:19:25 -04:00
Deluan
053a0fd6c0 fix: prevent raw file being returned when explicit transcode format is requested
When a client requests transcoding with an explicit format (e.g.,
format=opus) but no maxBitRate, buildLegacyClientInfo was adding a
direct play profile matching the source format. Since there was no
bitrate constraint to block it, MakeDecision would match the source
against the direct play profile and return the raw file instead of
transcoding. This fix only adds the direct play profile when no
explicit format was requested (bitrate-only downsampling) or when the
requested format matches the source format (allowing direct play when
no actual transcoding is needed).
2026-03-10 17:14:21 -04:00
Deluan Quintão
767744a301 refactor: rename core/transcode to core/stream, simplify MediaStreamer (#5166)
* refactor: rename core/transcode directory to core/stream

* refactor: update all imports from core/transcode to core/stream

* refactor: rename exported symbols to fit core/stream package name

* refactor: simplify MediaStreamer interface to single NewStream method

Remove the two-method interface (NewStream + DoStream) in favor of a
single NewStream(ctx, mf, req) method. Callers are now responsible for
fetching the MediaFile before calling NewStream. This removes the
implicit DB lookup from the streamer, making it a pure streaming
concern.

* refactor: update all callers from DoStream to NewStream

* chore: update wire_gen.go and stale comment for core/stream rename

* refactor: update wire command to handle GO_BUILD_TAGS correctly

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: distinguish not-found from internal errors in public stream handler

* refactor: remove unused ID field from stream.Request

* refactor: simplify ResolveRequestFromToken to receive *model.MediaFile

Move MediaFile fetching responsibility to callers, making the method
focused on token validation and request resolution. Remove ErrMediaNotFound
(no longer produced). Update GetTranscodeStream handler to fetch the
media file before calling ResolveRequestFromToken.

* refactor: extend tokenTTL from 12 to 48 hours

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-09 22:22:58 -04:00
Deluan Quintão
d7c3a50f86 fix: player MaxBitRate cap, format-aware defaults, browser profile filtering (#5165)
* feat(transcode): apply player MaxBitRate cap and use format-aware default bitrates

Add player MaxBitRate cap to the transcode decider so server-side player
bitrate limits are respected when making OpenSubsonic transcode decisions.
The player cap is applied only when it is more restrictive than the client's
maxAudioBitrate (or when the client has no limit).

Also replace the hardcoded 256 kbps default with a format-aware lookup that
checks the DB first (for user-customized values), then built-in defaults,
and finally falls back to 256 kbps. For lossless→lossy transcoding, prefer
maxTranscodingAudioBitrate over maxAudioBitrate when available.

* test(e2e): add tests for player MaxBitRate cap and format-aware default bitrates

Add e2e tests covering:
- Player MaxBitRate forcing transcode when source exceeds cap
- Player MaxBitRate having no effect when source is under cap
- Client limit winning when more restrictive than player MaxBitRate
- Player MaxBitRate winning when more restrictive than client limit
- Player MaxBitRate=0 having no effect
- Format-aware defaults: mp3 (192kbps), opus (128kbps) instead of hardcoded 256
- maxAudioBitrate fallback for lossless→lossy when no maxTranscodingAudioBitrate
- maxTranscodingAudioBitrate taking priority over maxAudioBitrate
- Combined player + client limits flowing correctly through decision→stream

* feat(transcode): update transcoding profiles to add flac, filter by supported codecs, and ensure mp3 fallback

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(db): ensure all default transcodings exist on upgrade

Older installations that were seeded before aac/flac were added to
DefaultTranscodings may be missing these entries. The previous migration
only added flac; this one ensures all default transcodings are present
without touching user-customized entries.

* test: remove duplication

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-09 16:47:34 -04:00
Deluan
7c5aa1fafa test(e2e): add transcode endpoint e2e tests and clean up test helpers
Add comprehensive e2e tests for getTranscodeDecision and
getTranscodeStream endpoints covering direct play, transcoding,
error handling, and round-trip token validation. Refactor
buildPostReq to reuse buildReq for auth params, remove unused
WAV/AAC test tracks, and consolidate duplicate test assertions.
2026-03-09 09:43:55 -04:00
Deluan Quintão
ae1e0ddb11 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 23:57:49 -04:00
Deluan Quintão
f03ca44a8e feat(plugins): add lyrics provider plugin capability (#5126)
* feat(plugins): add lyrics provider plugin capability

Refactor the lyrics system from a static function to an interface-based
service that supports WASM plugin providers. Plugins listed in the
LyricsPriority config (alongside "embedded" and file extensions) are
now resolved through the plugin system.

Includes capability definition, Go/Rust PDK, adapter, Wire integration,
and tests for plugin fallback behavior.

* test(plugins): add lyrics capability integration test with test plugin

* fix(plugins): default lyrics language to 'xxx' when plugin omits it

Per the OpenSubsonic spec, the server must return 'und' or 'xxx' when
the lyrics language is unknown. The lyrics plugin adapter was passing
an empty string through when a plugin didn't provide a language value.
This defaults the language to 'xxx', consistent with all other callers
of model.ToLyrics() in the codebase.

* refactor(plugins): rename lyrics import to improve clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(lyrics): update TrackInfo description for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(lyrics): enhance lyrics plugin handling and case sensitivity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): update payload type to string with byte format for task data

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-03 15:48:39 -05:00
Deluan Quintão
b59eb32961 feat(subsonic): sort search3 results by relevance (#5086)
* fix(subsonic): optimize search3 for high-cardinality FTS queries

Use a two-phase query strategy for FTS5 searches to avoid the
performance penalty of expensive LEFT JOINs (annotation, bookmark,
library) on high-cardinality results like "the".

Phase 1 runs a lightweight query (main table + FTS index only) to get
sorted, paginated rowids. Phase 2 hydrates only those few rowids with
the full JOINs, making them nearly free.

For queries with complex ORDER BY expressions that reference joined
tables (e.g. artist search sorted by play count), the optimization is
skipped and the original single-query approach is used.

* fix(search): update order by clauses to include 'rank' for FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): reintroduce 'rank' in Phase 2 ORDER BY for FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): remove 'rank' from ORDER BY in non-FTS queries and adjust two-phase query handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): update FTS ranking to use bm25 weights and simplify ORDER BY qualification

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refine FTS query handling and improve comments for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refactor full-text search handling to streamline query strategy selection and improve LIKE fallback logic.

Increase e2e coverage for search3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: enhance FTS column definitions and relevance weights

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refactor Search method signatures to remove offset and size parameters, streamline query handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): allow single-character queries in search strategies and update related tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): make FTS Phase 1 treat Max=0 as no limit, reorganize tests

FTS Phase 1 unconditionally called Limit(uint64(options.Max)), which
produced LIMIT 0 when Max was zero. This diverged from applyOptions
where Max=0 means no limit. Now Phase 1 mirrors applyOptions: only add
LIMIT/OFFSET when the value is positive. Also moved legacy backend
integration tests from sql_search_fts_test.go to sql_search_like_test.go
and added regression tests for the Max=0 behavior on both backends.

* refactor: simplify callSearch function by removing variadic options and directly using QueryOptions

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): implement ftsQueryDegraded function to detect significant content loss in FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-23 08:51:54 -05:00
Deluan
d02bf9a53d test(e2e): add MusicBrainz ID tests for song and album searches
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-22 00:32:14 -05:00
Deluan
ec75808153 fix(subsonic): handle empty quoted phrases in FTS5 query and search expression
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 22:00:00 -05:00
Deluan Quintão
7ad2907719 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 19:57:13 -05:00
Deluan Quintão
54de0dbc52 feat(server): implement FTS5-based full-text search (#5079)
* build: add sqlite_fts5 build tag to enable FTS5 support

* feat: add SearchBackend config option (default: fts)

* feat: add buildFTS5Query for safe FTS5 query preprocessing

* feat: add FTS5 search backend with config toggle, refactor legacy search

- Add searchExprFunc type and getSearchExpr() for backend selection
- Rename fullTextExpr to legacySearchExpr
- Add ftsSearchExpr using FTS5 MATCH subquery
- Update fullTextFilter in sql_restful.go to use configured backend

* feat: add FTS5 migration with virtual tables, triggers, and search_participants

Creates FTS5 virtual tables for media_file, album, and artist with
unicode61 tokenizer and diacritic folding. Adds search_participants
column, populates from JSON, and sets up INSERT/UPDATE/DELETE triggers.

* feat: populate search_participants in PostMapArgs for FTS5 indexing

* test: add FTS5 search integration tests

* fix: exclude FTS5 virtual tables from e2e DB restore

The restoreDB function iterates all tables in sqlite_master and
runs DELETE + INSERT to reset state. FTS5 contentless virtual tables
cannot be directly deleted from. Since triggers handle FTS5 sync
automatically, simply skip tables matching *_fts and *_fts_* patterns.

* build: add compile-time guard for sqlite_fts5 build tag

Same pattern as netgo: compilation fails with a clear error if
the sqlite_fts5 build tag is missing.

* build: add sqlite_fts5 tag to reflex dev server config

* build: extract GO_BUILD_TAGS variable in Makefile to avoid duplication

* fix: strip leading * from FTS5 queries to prevent "unknown special query" error

* feat: auto-append prefix wildcard to FTS5 search tokens for broader matching

Every plain search token now gets a trailing * appended (e.g., "love" becomes
"love*"), so searching for "love" also matches "lovelace", "lovely", etc.
Quoted phrases are preserved as exact matches without wildcards. Results are
ordered alphabetically by name/title, so shorter exact matches naturally
appear first.

* fix: clarify comments about FTS5 operator neutralization

The comments said "strip" but the code lowercases operators to
neutralize them (FTS5 operators are case-sensitive). Updated comments
to accurately describe the behavior.

* fix: use fmt.Sprintf for FTS5 phrase placeholders

The previous encoding used rune('0'+index) which silently breaks with
10+ quoted phrases. Use fmt.Sprintf for arbitrary index support.

* fix: validate and normalize SearchBackend config option

Normalize the value to lowercase and fall back to "fts" with a log
warning for unrecognized values. This prevents silent misconfiguration
from typos like "FTS", "Legacy", or "fts5".

* refactor: improve documentation for build tags and FTS5 requirements

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: convert FTS5 query and search backend normalization tests to DescribeTable format

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: add sqlite_fts5 build tag to golangci configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add UISearchDebounceMs configuration option and update related components

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: fall back to legacy search when SearchFullString is enabled

FTS5 is token-based and cannot match substrings within words, so
getSearchExpr now returns legacySearchExpr when SearchFullString
is true, regardless of SearchBackend setting.

* fix: add sqlite_fts5 build tag to CI pipeline and Dockerfile

* fix: add WHEN clauses to FTS5 AFTER UPDATE triggers

Added WHEN clauses to the media_file_fts_au, album_fts_au, and
artist_fts_au triggers so they only fire when FTS-indexed columns
actually change. Previously, every row update (e.g., play count, rating,
starred status) triggered an unnecessary delete+insert cycle in the FTS
shadow tables. The WHEN clauses use IS NOT for NULL-safe comparison of
each indexed column, avoiding FTS index churn for non-indexed updates.

* feat: add SearchBackend configuration option to data and insights components

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: enhance input sanitization for FTS5 by stripping additional punctuation and special characters

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add search_normalized column for punctuated name search (R.E.M., AC/DC)

Add index-time normalization and query-time single-letter collapsing to
fix FTS5 search for punctuated names. A new search_normalized column
stores concatenated forms of punctuated words (e.g., "R.E.M." → "REM",
"AC/DC" → "ACDC") and is indexed in FTS5 tables. At query time, runs of
consecutive single letters (from dot-stripping) are collapsed into OR
expressions like ("R E M" OR REM*) to match both the original tokens and
the normalized form. This enables searching by "R.E.M.", "REM", "AC/DC",
"ACDC", "A-ha", or "Aha" and finding the correct results.

* refactor: simplify isSingleUnicodeLetter to avoid []rune allocation

Use utf8.DecodeRuneInString to check for a single Unicode letter
instead of converting the entire string to a []rune slice.

* feat: define ftsSearchColumns for flexible FTS5 search column inclusion

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: update collapseSingleLetterRuns to return quoted phrases for abbreviations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: punctuated word handling to improve processing of artist/album names

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add CJK support for search queries with LIKE filters

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance FTS5 search by adding album version support and CJK handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: search configuration to use structured options

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance search functionality to support punctuation-only queries and update related tests

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 17:52:42 -05:00
Deluan
897de02a84 docs: documents how subsonic e2e tests are structured 2026-02-11 22:49:41 -05:00
Deluan
e05a7e230f fix: prevent data race on conf.Server during cleanup in e2e tests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-10 11:25:17 -05:00
Deluan Quintão
8319905d2c test(subsonic): add comprehensive e2e test suite for Subsonic API (#5003)
* test(e2e): add comprehensive tests for Subsonic API endpoints

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(e2e): improve database handling and snapshot restoration in tests

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): add tests for album sharing and user isolation scenarios

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): add tests for multi-library support and user access control

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): tests are fast, no need to skip on -short

Signed-off-by: Deluan <deluan@navidrome.org>

* address gemini comments

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(tests): prevent MockDataStore from caching repos with stale context

When RealDS is set, MockDataStore previously cached repository instances
on first access, binding them to the initial caller's context. This meant
repos created with an admin context would skip library filtering for all
subsequent non-admin calls, silently masking access control bugs. Changed
MockDataStore to delegate to RealDS on every call without caching, so each
caller gets a fresh repo with the correct context. Removed the pre-warm
calls in e2e setupTestDB that were working around the old caching behavior.

* test(e2e): route subsonic tests through full HTTP middleware stack

Replace direct router method calls with full HTTP round-trips via
router.ServeHTTP(w, r) across all 15 e2e test files. Tests now exercise
the complete chi middleware chain including postFormToQueryParams,
checkRequiredParameters, authenticate, UpdateLastAccessMiddleware,
getPlayer, and sendResponse/sendError serialization.

New helpers (doReq, doReqWithUser, doRawReq, buildReq, parseJSONResponse)
use plaintext password auth and JSON response format. Old helpers that
injected context directly (newReq, newReqWithUser, newRawReq) are removed.
Sharing tests now set conf.Server.EnableSharing before router creation to
ensure sharing routes are registered.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 08:24:37 -05:00