feat(smartplaylist): extend isMissing/isPresent to bpm, bitDepth and many text fields (#5603)

* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields

Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable
in the criteria field map, then extend missingExpr to handle string columns
where absence is encoded as NULL or empty string (plus '[]' for lyrics). The
Numeric/Boolean path (ReplayGain) is preserved via an explicit type check.

* refactor(model): make MediaFile BPM and BitDepth nullable pointers

Convert BPM and BitDepth fields in model.MediaFile from int to *int so that
'tag absent' is distinguishable from zero. The metadata mapper now uses
NullableFloat for BPM (nil when absent or zero/unparseable) and only sets
BitDepth when the audio property is non-zero (lossy codecs report 0).

All read sites use gg.V() for zero-fallback deref so Subsonic API output and
transcoding behaviour are byte-identical to before. The persistence layer
bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0
back to nil on read in PostMapArgs/PostScan; a later migration task will drop
those constraints.

Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil
*int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue,
so no files will be spuriously re-imported after this change.

Extra files touched beyond the plan's list: core/stream/legacy_client_test.go
(BitDepth in model.MediaFile literals), persistence/mediafile_repository.go
(NOT NULL bridge).

* test(model): pin pre-conversion golden hashes for BPM/BitDepth

* feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth

* feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL

Drop the NOT NULL constraint on media_file.bpm and bit_depth via a
lossless migration that converts legacy 0-means-absent values to real
NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging
the old NOT NULL columns to the *int model fields. Add round-trip
persistence tests asserting NULL storage for nil pointers and correct
value round-trip for non-nil pointers.

* test(e2e): verify isMissing/isPresent partition for nullable fields

Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id:
for each field, isMissing + isPresent song counts must equal the total
library count, proving the nullable-column SQL is exhaustive and correct.

* test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial

* fix(model): omit bitDepth from JSON when absent instead of emitting null

* feat(smartplaylist): support isMissing/isPresent on more string fields

Enable isMissing/isPresent operators for album, comment, catalognumber,
discsubtitle, albumcomment, sorttitle, sortalbum, sortartist,
sortalbumartist, and explicitstatus by marking them Nullable in fieldMap.

* refactor(smartplaylist): unify missingExpr column logic into one flow

Collapse the numeric/string fork in missingExpr into a single
empties-driven loop (numeric/boolean fields simply have no empties),
and replace the duplicated IsTag/IsRole guard with a three-way switch
that expresses the dispatch model once. No SQL semantics change for
string fields; numeric/boolean fields now emit a single-element Or/And
which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare
`col IS NULL`) — update the affected test expectations accordingly.
This commit is contained in:
Deluan Quintão 2026-06-13 13:15:20 -04:00 committed by GitHub
parent 5ec6e6a8d4
commit da56df3160
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 352 additions and 100 deletions

View file

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/gg"
)
const fallbackBitrate = 256 // kbps
@ -142,7 +143,7 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Deta
sd.Codec = mf.AudioCodec()
sd.Bitrate = mf.BitRate
sd.SampleRate = mf.SampleRate
sd.BitDepth = mf.BitDepth
sd.BitDepth = gg.V(mf.BitDepth)
sd.Channels = mf.Channels
}
sd.IsLossless = isLosslessFormat(sd.Codec)

View file

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -23,7 +24,7 @@ func withProbe(mf *model.MediaFile) *model.MediaFile {
Codec: mf.AudioCodec(),
BitRate: mf.BitRate,
SampleRate: mf.SampleRate,
BitDepth: mf.BitDepth,
BitDepth: gg.V(mf.BitDepth),
Channels: mf.Channels,
}
data, _ := json.Marshal(probe)
@ -243,7 +244,7 @@ var _ = Describe("Decider", func() {
Context("Transcoding", func() {
It("selects transcoding when direct play isn't possible", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256, // kbps
DirectPlayProfiles: []DirectPlayProfile{
@ -278,7 +279,7 @@ var _ = Describe("Decider", func() {
})
It("uses default bitrate when client doesn't specify", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "mp3", Protocol: ProtocolHTTP},
@ -331,7 +332,7 @@ var _ = Describe("Decider", func() {
})
It("selects first valid transcoding profile in order", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
DirectPlayProfiles: []DirectPlayProfile{
@ -351,7 +352,7 @@ var _ = Describe("Decider", func() {
Context("Lossless to lossless transcoding", func() {
It("allows lossless to lossless when samplerate needs downsampling", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: new(1)})
ci := &ClientInfo{
MaxAudioBitrate: 1000,
DirectPlayProfiles: []DirectPlayProfile{
@ -369,7 +370,7 @@ var _ = Describe("Decider", func() {
It("sets IsLossless=true on transcoded stream when target is lossless", func() {
// Transcoding to mp3 (lossy) should result in IsLossless=false.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -526,7 +527,7 @@ var _ = Describe("Decider", func() {
})
It("rejects direct play due to samplerate limitation", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
@ -573,7 +574,7 @@ var _ = Describe("Decider", func() {
})
It("applies channel limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -596,7 +597,7 @@ var _ = Describe("Decider", func() {
})
It("applies samplerate limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -619,7 +620,7 @@ var _ = Describe("Decider", func() {
})
It("applies bitdepth limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -642,7 +643,7 @@ var _ = Describe("Decider", func() {
})
It("preserves source bit depth when no limitation applies", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(24)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -656,7 +657,7 @@ var _ = Describe("Decider", func() {
})
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -680,7 +681,7 @@ var _ = Describe("Decider", func() {
Context("DSD sample rate conversion", func() {
It("converts DSD sample rate to PCM-equivalent in decision", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -700,7 +701,7 @@ var _ = Describe("Decider", func() {
})
It("converts DSD sample rate for FLAC target without codec limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -719,7 +720,7 @@ var _ = Describe("Decider", func() {
})
It("applies codec profile limit to DSD-converted FLAC sample rate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -746,7 +747,7 @@ var _ = Describe("Decider", func() {
})
It("applies audioBitdepth limitation to DSD-converted bit depth", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -775,7 +776,7 @@ var _ = Describe("Decider", func() {
// Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels.
// The decider must clamp to the codec's hard limit even when no
// transcoding profile MaxAudioChannels is configured.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -791,7 +792,7 @@ var _ = Describe("Decider", func() {
})
It("honors a stricter profile MaxAudioChannels over the codec clamp", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -806,7 +807,7 @@ var _ = Describe("Decider", func() {
})
It("applies the codec clamp when the profile limit is looser", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -821,7 +822,7 @@ var _ = Describe("Decider", func() {
})
It("passes channels through unchanged for codecs with no hard limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -840,7 +841,7 @@ var _ = Describe("Decider", func() {
Context("Probe-based lossless detection", func() {
It("uses probe codec name for lossless detection", func() {
// WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}
probe := ffmpeg.AudioProbeResult{
Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2,
}
@ -884,7 +885,7 @@ var _ = Describe("Decider", func() {
Context("Opus fixed sample rate", func() {
It("sets Opus output to 48000Hz regardless of input", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 128,
TranscodingProfiles: []Profile{
@ -901,7 +902,7 @@ var _ = Describe("Decider", func() {
})
It("sets Opus output to 48000Hz even for 96kHz input", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 128,
TranscodingProfiles: []Profile{
@ -917,7 +918,7 @@ var _ = Describe("Decider", func() {
Context("Container vs format separation", func() {
It("preserves mp4 container when falling back to aac format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256,
TranscodingProfiles: []Profile{
@ -935,7 +936,7 @@ var _ = Describe("Decider", func() {
})
It("uses container as format when container matches transcoding config", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256,
TranscodingProfiles: []Profile{
@ -952,7 +953,7 @@ var _ = Describe("Decider", func() {
Context("MP3 max sample rate", func() {
It("caps sample rate at 48000 for MP3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -966,7 +967,7 @@ var _ = Describe("Decider", func() {
})
It("preserves sample rate at 44100 for MP3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -982,7 +983,7 @@ var _ = Describe("Decider", func() {
Context("AAC max sample rate", func() {
It("caps sample rate at 96000 for AAC", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -1025,7 +1026,7 @@ var _ = Describe("Decider", func() {
Context("Source stream details", func() {
It("populates source stream correctly with kbps bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24), Duration: 300.5, Size: 50000000})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
@ -1058,7 +1059,7 @@ var _ = Describe("Decider", func() {
})
It("ignores player MaxBitRate in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
@ -1074,7 +1075,7 @@ var _ = Describe("Decider", func() {
Context("Format-aware default bitrate", func() {
It("uses opus default bitrate from DB", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
@ -1087,7 +1088,7 @@ var _ = Describe("Decider", func() {
})
It("uses aac default bitrate from DB", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},

View file

@ -138,7 +138,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("transcodes to requested format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "opus", 0, 0)
@ -147,7 +147,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("transcodes to requested format with bitrate limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0)
@ -169,7 +169,7 @@ var _ = Describe("ResolveRequest", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "", 128, 0)
@ -179,7 +179,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("passes offset through", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "opus", 128, 30)
@ -259,7 +259,7 @@ var _ = Describe("ResolveRequest", func() {
Context("Player MaxBitRate cap", func() {
It("applies player MaxBitRate cap when client has no limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
@ -270,7 +270,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
decider := svc.(*deciderService)
@ -332,7 +332,7 @@ var _ = Describe("ResolveRequest", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0)

View file

@ -0,0 +1,35 @@
-- +goose Up
drop index if exists media_file_bpm;
alter table media_file add column bpm_new integer;
alter table media_file add column bit_depth_new integer;
update media_file set
bpm_new = nullif(bpm, 0),
bit_depth_new = nullif(bit_depth, 0);
alter table media_file drop column bpm;
alter table media_file drop column bit_depth;
alter table media_file rename column bpm_new to bpm;
alter table media_file rename column bit_depth_new to bit_depth;
create index if not exists media_file_bpm on media_file (bpm);
-- +goose Down
drop index if exists media_file_bpm;
alter table media_file add column bpm_old integer default 0 not null;
alter table media_file add column bit_depth_old integer default 0 not null;
update media_file set
bpm_old = coalesce(bpm, 0),
bit_depth_old = coalesce(bit_depth, 0);
alter table media_file drop column bpm;
alter table media_file drop column bit_depth;
alter table media_file rename column bpm_old to bpm;
alter table media_file rename column bit_depth_old to bit_depth;
create index if not exists media_file_bpm on media_file (bpm);

View file

@ -4,12 +4,14 @@ import "strings"
// FieldInfo contains semantic metadata about a criteria field.
type FieldInfo struct {
Alias string // If set, this field is a backward-compat alias for another canonical name
IsTag bool
IsRole bool
Numeric bool
Boolean bool
Nullable bool // If set, this column field can be NULL, so isMissing/isPresent are supported on it
Alias string // If set, this field is a backward-compat alias for another canonical name
IsTag bool
IsRole bool
Numeric bool
Boolean bool
// Nullable: isMissing/isPresent are supported on this column field. For numeric/boolean
// fields, missing means NULL; for string fields it means NULL or empty string.
Nullable bool
tagAlias string // If set, a tag name from mappings.yaml that resolves to this field
name string // Canonical name, populated by LookupField from the map key
@ -22,7 +24,7 @@ func (f FieldInfo) Name() string {
var fieldMap = map[string]FieldInfo{
"title": {},
"album": {},
"album": {Nullable: true},
"hascoverart": {Boolean: true},
"tracknumber": {},
"discnumber": {},
@ -35,26 +37,26 @@ var fieldMap = map[string]FieldInfo{
"size": {},
"compilation": {Boolean: true},
"missing": {Boolean: true},
"explicitstatus": {},
"explicitstatus": {Nullable: true},
"dateadded": {},
"datemodified": {},
"discsubtitle": {},
"comment": {},
"lyrics": {},
"sorttitle": {},
"sortalbum": {},
"sortartist": {},
"sortalbumartist": {},
"albumcomment": {},
"catalognumber": {},
"discsubtitle": {Nullable: true},
"comment": {Nullable: true},
"lyrics": {Nullable: true},
"sorttitle": {Nullable: true},
"sortalbum": {Nullable: true},
"sortartist": {Nullable: true},
"sortalbumartist": {Nullable: true},
"albumcomment": {Nullable: true},
"catalognumber": {Nullable: true},
"filepath": {},
"filetype": {},
"codec": {},
"duration": {},
"bitrate": {},
"bitdepth": {},
"bitdepth": {Numeric: true, Nullable: true},
"samplerate": {},
"bpm": {},
"bpm": {Numeric: true, Nullable: true},
"channels": {},
"loved": {Boolean: true},
"dateloved": {},
@ -75,12 +77,12 @@ var fieldMap = map[string]FieldInfo{
"artistlastplayed": {},
"artistdateloved": {},
"artistdaterated": {},
"mbz_album_id": {},
"mbz_album_artist_id": {},
"mbz_artist_id": {},
"mbz_recording_id": {},
"mbz_release_track_id": {},
"mbz_release_group_id": {},
"mbz_album_id": {Nullable: true},
"mbz_album_artist_id": {Nullable: true},
"mbz_artist_id": {Nullable: true},
"mbz_recording_id": {Nullable: true},
"mbz_release_track_id": {Nullable: true},
"mbz_release_group_id": {Nullable: true},
"rgalbumgain": {Numeric: true, Nullable: true},
"rgalbumpeak": {Numeric: true, Nullable: true},
"rgtrackgain": {Numeric: true, Nullable: true},

View file

@ -75,5 +75,17 @@ var _ = Describe("fields", func() {
gomega.Expect(field.IsTag).To(gomega.BeFalse())
})
It("marks mbz_* and lyrics string fields as nullable (empty means missing)", func() {
for _, name := range []string{"mbz_album_id", "mbz_album_artist_id", "mbz_artist_id",
"mbz_recording_id", "mbz_release_track_id", "mbz_release_group_id", "lyrics",
"album", "comment", "catalognumber", "discsubtitle", "albumcomment",
"sorttitle", "sortalbum", "sortartist", "sortalbumartist", "explicitstatus"} {
field, ok := LookupField(name)
gomega.Expect(ok).To(gomega.BeTrue(), name)
gomega.Expect(field.Nullable).To(gomega.BeTrue(), name)
gomega.Expect(field.Numeric).To(gomega.BeFalse(), name)
}
})
})
})

View file

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
)
@ -54,7 +55,7 @@ type MediaFile struct {
Duration float32 `structs:"duration" json:"duration"`
BitRate int `structs:"bit_rate" json:"bitRate"`
SampleRate int `structs:"sample_rate" json:"sampleRate"`
BitDepth int `structs:"bit_depth" json:"bitDepth"`
BitDepth *int `structs:"bit_depth" json:"bitDepth,omitempty"`
Channels int `structs:"channels" json:"channels"`
Codec string `structs:"codec" json:"codec"`
ProbeData string `structs:"probe_data" json:"-" hash:"ignore"`
@ -71,7 +72,7 @@ type MediaFile struct {
Compilation bool `structs:"compilation" json:"compilation"`
Comment string `structs:"comment" json:"comment,omitempty"`
Lyrics string `structs:"lyrics" json:"lyrics"`
BPM int `structs:"bpm" json:"bpm,omitempty"`
BPM *int `structs:"bpm" json:"bpm,omitempty"`
ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"`
@ -225,7 +226,7 @@ func (mf MediaFile) inferCodecFromSuffix() string {
return "dsd"
case "m4a":
// AAC if BitDepth==0, ALAC if BitDepth>0
if mf.BitDepth > 0 {
if gg.V(mf.BitDepth) > 0 {
return "alac"
}
return "aac"

View file

@ -564,7 +564,7 @@ var _ = Describe("MediaFile", func() {
DescribeTable("infers codec from suffix when Codec field is empty",
func(suffix string, bitDepth int, expected string) {
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
mf := MediaFile{Suffix: suffix, BitDepth: new(bitDepth)}
Expect(mf.AudioCodec()).To(Equal(expected))
},
Entry("mp3", "mp3", 0, "mp3"),
@ -597,13 +597,30 @@ var _ = Describe("MediaFile", func() {
)
It("prefers stored codec over suffix inference", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
})
})
var _ = Describe("MediaFile.Hash", func() {
// Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes,
// or every file would be spuriously re-imported on the next scan.
// Golden hashes were captured at 46221d516 when those fields were plain ints.
It("keeps hashes identical to the pre-pointer-conversion values", func() {
// Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning
// them guarantees the pointer conversion cannot trigger a full-library re-import.
Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622"))
Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689"))
})
It("changes the hash when a pointer field has a value", func() {
base := MediaFile{Title: "Song"}
Expect(base.Equals(MediaFile{Title: "Song", BPM: new(120)})).To(BeFalse())
Expect(base.Equals(MediaFile{Title: "Song", BitDepth: new(24)})).To(BeFalse())
})
})
func t(v string) time.Time {
var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"}
for _, f := range timeFormats {

View file

@ -35,7 +35,11 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.DiscSubtitle = md.String(model.TagDiscSubtitle)
mf.CatalogNum = md.String(model.TagCatalogNumber)
mf.Comment = md.String(model.TagComment)
mf.BPM = int(math.Round(md.Float(model.TagBPM)))
if f := md.NullableFloat(model.TagBPM); f != nil {
if v := int(math.Round(*f)); v != 0 {
mf.BPM = new(v)
}
}
mf.Lyrics = md.mapLyrics()
mf.ExplicitStatus = md.mapExplicitStatusTag()
@ -63,7 +67,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.Duration = md.Length()
mf.BitRate = md.AudioProperties().BitRate
mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth
if bd := md.AudioProperties().BitDepth; bd > 0 {
mf.BitDepth = new(bd)
}
mf.Channels = md.AudioProperties().Channels
mf.Codec = md.AudioProperties().Codec
mf.Path = md.FilePath()

View file

@ -117,4 +117,32 @@ var _ = Describe("ToMediaFile", func() {
Expect(actual).To(Equal(expected))
})
})
Describe("BPM", func() {
It("maps the BPM tag rounded to the nearest integer", func() {
mf = toMediaFile(model.RawTags{"BPM": {"120.6"}})
Expect(mf.BPM).To(Equal(new(121)))
})
It("leaves BPM nil when the tag is absent", func() {
mf = toMediaFile(model.RawTags{})
Expect(mf.BPM).To(BeNil())
})
It("leaves BPM nil when the tag is zero or unparseable", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil())
Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil())
})
})
Describe("BitDepth", func() {
It("maps the bit depth when present", func() {
props.AudioProperties = metadata.AudioProperties{BitDepth: 24}
mf = toMediaFile(model.RawTags{})
Expect(mf.BitDepth).To(Equal(new(24)))
})
It("leaves BitDepth nil when zero (lossy codecs have no bit depth)", func() {
props.AudioProperties = metadata.AudioProperties{BitDepth: 0}
mf = toMediaFile(model.RawTags{})
Expect(mf.BitDepth).To(BeNil())
})
})
})

View file

@ -28,9 +28,10 @@ func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
}
type smartPlaylistField struct {
expr string
order string
joinType smartPlaylistJoinType
expr string
order string
joinType smartPlaylistJoinType
emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics)
}
type smartPlaylistCriteria struct {
@ -72,7 +73,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{
"datemodified": {expr: "media_file.updated_at"},
"discsubtitle": {expr: "media_file.disc_subtitle"},
"comment": {expr: "media_file.comment"},
"lyrics": {expr: "media_file.lyrics"},
"lyrics": {expr: "media_file.lyrics", emptyValues: []string{"[]"}},
"sorttitle": {expr: "media_file.sort_title"},
"sortalbum": {expr: "media_file.sort_album_name"},
"sortartist": {expr: "media_file.sort_artist_name"},
@ -218,30 +219,44 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er
}
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
if !info.IsTag && !info.IsRole && !info.Nullable {
return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field)
}
b, ok := value.(bool)
if !ok {
return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value)
}
negate := checkAbsence == b
// Nullable column fields (e.g. ReplayGain) are stored in dedicated columns, not in the tags
// JSON, so "missing" maps to a NULL check on the column rather than a json_tree lookup.
if info.Nullable && !info.IsTag && !info.IsRole {
col, ok := fieldExpr(info.Name())
if !ok || col == "" {
switch {
case info.IsTag || info.IsRole:
return jsonExpr(info, nil, negate), nil
case info.Nullable:
// Nullable column fields are stored in dedicated columns, not in the tags JSON, so
// "missing" maps to a column check rather than a json_tree lookup. Numeric/boolean
// columns (e.g. ReplayGain, BPM) encode absence as NULL only; string columns (e.g.
// mbz_* IDs, lyrics) additionally treat empty string — and any field-specific empty
// encodings (e.g. '[]' for lyrics) — as missing. The unified flow below handles both:
// numeric/boolean fields simply have no empties, so the loops are no-ops.
f, ok := smartPlaylistFields[info.Name()]
if !ok || f.expr == "" {
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
if negate {
return squirrel.Eq{col: nil}, nil
col := f.expr
var empties []string
if !info.Numeric && !info.Boolean {
empties = append([]string{""}, f.emptyValues...)
}
return squirrel.NotEq{col: nil}, nil
missing := squirrel.Or{squirrel.Eq{col: nil}}
present := squirrel.And{squirrel.NotEq{col: nil}}
for _, e := range empties {
missing = append(missing, squirrel.Eq{col: e})
present = append(present, squirrel.NotEq{col: e})
}
if negate {
return missing, nil
}
return present, nil
default:
return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field)
}
return jsonExpr(info, nil, negate), nil
}
func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {

View file

@ -87,18 +87,68 @@ var _ = Describe("Smart playlist criteria SQL", func() {
"not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
// isMissing/isPresent — nullable column fields (ReplayGain)
Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true},
"media_file.rg_album_gain IS NULL"),
"(media_file.rg_album_gain IS NULL)"),
Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false},
"media_file.rg_album_gain IS NOT NULL"),
"(media_file.rg_album_gain IS NOT NULL)"),
Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true},
"media_file.rg_track_peak IS NOT NULL"),
"(media_file.rg_track_peak IS NOT NULL)"),
Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false},
"media_file.rg_track_peak IS NULL"),
"(media_file.rg_track_peak IS NULL)"),
// isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584)
Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true},
"media_file.rg_album_gain IS NULL"),
"(media_file.rg_album_gain IS NULL)"),
Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true},
"media_file.rg_album_gain IS NOT NULL"),
"(media_file.rg_album_gain IS NOT NULL)"),
// isMissing/isPresent — string column fields (empty string means missing)
Entry("isMissing mbz_recording_id [true]", criteria.IsMissing{"mbz_recording_id": true},
"(media_file.mbz_recording_id IS NULL OR media_file.mbz_recording_id = ?)", ""),
Entry("isMissing mbz_recording_id [false]", criteria.IsMissing{"mbz_recording_id": false},
"(media_file.mbz_recording_id IS NOT NULL AND media_file.mbz_recording_id <> ?)", ""),
Entry("isPresent mbz_album_id [true]", criteria.IsPresent{"mbz_album_id": true},
"(media_file.mbz_album_id IS NOT NULL AND media_file.mbz_album_id <> ?)", ""),
Entry("isPresent mbz_album_id [false]", criteria.IsPresent{"mbz_album_id": false},
"(media_file.mbz_album_id IS NULL OR media_file.mbz_album_id = ?)", ""),
// lyrics: absence is encoded as '' or '[]' (empty serialized LyricList)
Entry("isMissing lyrics [true]", criteria.IsMissing{"lyrics": true},
"(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"),
Entry("isPresent lyrics [true]", criteria.IsPresent{"lyrics": true},
"(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"),
Entry("isMissing lyrics [false]", criteria.IsMissing{"lyrics": false},
"(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"),
Entry("isPresent lyrics [false]", criteria.IsPresent{"lyrics": false},
"(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"),
// isMissing/isPresent — nullable numeric columns (BPM, BitDepth)
Entry("isMissing bpm [true]", criteria.IsMissing{"bpm": true},
"(media_file.bpm IS NULL)"),
Entry("isPresent bpm [true]", criteria.IsPresent{"bpm": true},
"(media_file.bpm IS NOT NULL)"),
Entry("isMissing bitdepth [true]", criteria.IsMissing{"bitdepth": true},
"(media_file.bit_depth IS NULL)"),
Entry("isPresent bitdepth [false]", criteria.IsPresent{"bitdepth": false},
"(media_file.bit_depth IS NULL)"),
// isMissing/isPresent — more string column fields (empty string means missing)
Entry("isMissing album [true]", criteria.IsMissing{"album": true},
"(media_file.album IS NULL OR media_file.album = ?)", ""),
Entry("isMissing comment [true]", criteria.IsMissing{"comment": true},
"(media_file.comment IS NULL OR media_file.comment = ?)", ""),
Entry("isMissing catalognumber [true]", criteria.IsMissing{"catalognumber": true},
"(media_file.catalog_num IS NULL OR media_file.catalog_num = ?)", ""),
Entry("isMissing discsubtitle [true]", criteria.IsMissing{"discsubtitle": true},
"(media_file.disc_subtitle IS NULL OR media_file.disc_subtitle = ?)", ""),
Entry("isMissing albumcomment [true]", criteria.IsMissing{"albumcomment": true},
"(media_file.mbz_album_comment IS NULL OR media_file.mbz_album_comment = ?)", ""),
Entry("isMissing sorttitle [true]", criteria.IsMissing{"sorttitle": true},
"(media_file.sort_title IS NULL OR media_file.sort_title = ?)", ""),
Entry("isMissing sortalbum [true]", criteria.IsMissing{"sortalbum": true},
"(media_file.sort_album_name IS NULL OR media_file.sort_album_name = ?)", ""),
Entry("isMissing sortartist [true]", criteria.IsMissing{"sortartist": true},
"(media_file.sort_artist_name IS NULL OR media_file.sort_artist_name = ?)", ""),
Entry("isMissing sortalbumartist [true]", criteria.IsMissing{"sortalbumartist": true},
"(media_file.sort_album_artist_name IS NULL OR media_file.sort_album_artist_name = ?)", ""),
Entry("isMissing explicitstatus [true]", criteria.IsMissing{"explicitstatus": true},
"(media_file.explicit_status IS NULL OR media_file.explicit_status = ?)", ""),
Entry("isPresent comment [true]", criteria.IsPresent{"comment": true},
"(media_file.comment IS NOT NULL AND media_file.comment <> ?)", ""),
)
Describe("playlist permissions", func() {

View file

@ -824,4 +824,49 @@ var _ = Describe("MediaRepository", func() {
Expect(mediafiles[0].ID).To(Equal("mf1"))
})
})
Describe("BPM and BitDepth nullable round-trip", func() {
It("stores nil BPM and BitDepth as NULL and retrieves them as nil", func() {
newID := id.NewRandom()
mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-nil.mp3"}
Expect(mr.Put(&mf)).To(Succeed())
retrieved, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.BPM).To(BeNil())
Expect(retrieved.BitDepth).To(BeNil())
// Also verify via raw SQL that the columns are truly NULL (not 0)
db := GetDBXBuilder()
var row struct {
BPM *int `db:"bpm"`
BitDepth *int `db:"bit_depth"`
}
err = db.NewQuery("SELECT bpm, bit_depth FROM media_file WHERE id={:id}").
Bind(dbx.Params{"id": newID}).
One(&row)
Expect(err).ToNot(HaveOccurred())
Expect(row.BPM).To(BeNil(), "bpm should be stored as NULL in the database")
Expect(row.BitDepth).To(BeNil(), "bit_depth should be stored as NULL in the database")
_ = mr.Delete(newID)
})
It("stores non-nil BPM and BitDepth and retrieves correct values", func() {
newID := id.NewRandom()
bpm := 120
bitDepth := 24
mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-set.mp3", BPM: &bpm, BitDepth: &bitDepth}
Expect(mr.Put(&mf)).To(Succeed())
retrieved, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.BPM).ToNot(BeNil())
Expect(*retrieved.BPM).To(Equal(120))
Expect(retrieved.BitDepth).ToNot(BeNil())
Expect(*retrieved.BitDepth).To(Equal(24))
_ = mr.Delete(newID)
})
})
})

View file

@ -133,7 +133,7 @@ func buildTestFS() storagetest.FakeFS {
// Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID),
// "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID).
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
_t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec})),
_t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec, "bpm": 120})),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
_t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})),
// Rock / The Beatles / Help! (no MBIDs)

View file

@ -646,5 +646,43 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
stringResp := doReq("getPlaylist", "id", stringPls.ID)
Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount))
})
DescribeTable("isMissing/isPresent partition all songs for nullable column fields",
func(fieldName string) {
allPls := &model.Playlist{
Name: "All Songs " + fieldName,
OwnerID: adminUser.ID,
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}},
}
Expect(ds.Playlist(ctx).Put(allPls)).To(Succeed())
missingPls := &model.Playlist{
Name: "Missing " + fieldName,
OwnerID: adminUser.ID,
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{fieldName: true}}},
}
Expect(ds.Playlist(ctx).Put(missingPls)).To(Succeed())
presentPls := &model.Playlist{
Name: "Present " + fieldName,
OwnerID: adminUser.ID,
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{fieldName: true}}},
}
Expect(ds.Playlist(ctx).Put(presentPls)).To(Succeed())
allResp := doReq("getPlaylist", "id", allPls.ID)
missingResp := doReq("getPlaylist", "id", missingPls.ID)
presentResp := doReq("getPlaylist", "id", presentPls.ID)
Expect(allResp.Status).To(Equal(responses.StatusOK))
Expect(allResp.Playlist.SongCount).To(BeNumerically(">", int32(0)))
Expect(missingResp.Playlist.SongCount + presentResp.Playlist.SongCount).
To(Equal(allResp.Playlist.SongCount))
},
Entry("bpm", "bpm"),
Entry("bitdepth", "bitdepth"),
Entry("lyrics", "lyrics"),
Entry("mbz_recording_id", "mbz_recording_id"),
Entry("album", "album"),
Entry("comment", "comment"),
)
})
})

View file

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
@ -250,7 +251,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
}
child.Comment = mf.Comment
child.SortName = sortName(mf.SortTitle, mf.OrderTitle)
child.BPM = int32(mf.BPM)
child.BPM = int32(gg.V(mf.BPM))
child.MediaType = responses.MediaTypeSong
child.MusicBrainzId = mf.MbzRecordingID
child.Isrc = mf.Tags.Values(model.TagISRC)
@ -262,7 +263,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
}
child.ChannelCount = int32(mf.Channels)
child.SamplingRate = int32(mf.SampleRate)
child.BitDepth = int32(mf.BitDepth)
child.BitDepth = int32(gg.V(mf.BitDepth))
child.Genres = toItemGenres(mf.Genres)
child.Moods = mf.Tags.Values(model.TagMood)
child.Groupings = mf.Tags.Values(model.TagGrouping)

View file

@ -205,7 +205,7 @@ var _ = Describe("Transcode endpoints", func() {
It("includes transcode stream when transcoding", func() {
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24},
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)},
})
mockTD.decision = &stream.TranscodeDecision{
MediaID: "song-2",