mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
30 Commits
9abc560ba3
...
a7b76feac2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b76feac2 | ||
|
|
85e9982b43 | ||
|
|
501c6eaf8f | ||
|
|
27209ed26a | ||
|
|
de6475bb49 | ||
|
|
1f3a7efa75 | ||
|
|
ab2f1b45de | ||
|
|
9b0bfc606b | ||
|
|
4570dec675 | ||
|
|
36a7be9eaf | ||
|
|
9e2c6adffd | ||
|
|
1de4e43d29 | ||
|
|
1044c173cb | ||
|
|
478845bc5d | ||
|
|
7834674381 | ||
|
|
c91721363b | ||
|
|
664217f3f7 | ||
|
|
991bd3ed21 | ||
|
|
d7baf6ee7f | ||
|
|
2018979bc3 | ||
|
|
e7c7cba873 | ||
|
|
93631cdee9 | ||
|
|
c87db92cee | ||
|
|
80c1e60259 | ||
|
|
5fdee40877 | ||
|
|
9b848bafa1 | ||
|
|
a09121d717 | ||
|
|
bd77ca1c96 | ||
|
|
d6114df91f | ||
|
|
6b89ea00e5 |
@ -55,6 +55,7 @@ linters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
@ -62,3 +63,4 @@ formatters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
|
||||
4
Makefile
4
Makefile
@ -1,6 +1,8 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5
|
||||
|
||||
comma:=,
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
|
||||
|
||||
# Set global environment variables, required for most targets
|
||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||
|
||||
@ -127,6 +127,17 @@ var _ = Describe("Extractor", func() {
|
||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
||||
// Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags
|
||||
// to `originaldate`.
|
||||
if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") {
|
||||
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
|
||||
}
|
||||
// MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping
|
||||
// for original release dates.
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("origyear", []string{"1998-07-28"}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}),
|
||||
))
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||
Expect(m.Tags).To(Or(
|
||||
|
||||
@ -271,4 +271,30 @@ var _ = Describe("Extractor", func() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("tags", func() {
|
||||
DescribeTable("test metadata tags across files, and special cases", func(file string, tags ...string) {
|
||||
mf := parseTestFile("tests/fixtures/" + file)
|
||||
Expect(mf.Tags[model.TagMetadataTag]).To(ConsistOf(tags))
|
||||
},
|
||||
// weirder cases
|
||||
Entry("file with multiple tags", "ape-v1-v2.mp3", "ape", "id3v1", "id3v2"),
|
||||
Entry("wavpak with both ape and id3v1", "ape-id3v1.wv", "ape", "id3v1"),
|
||||
Entry("flac with vorbis, id3v1 and id3v2", "vorbis-id3v1-id3v2.flac", "vorbis", "id3v1", "id3v2"),
|
||||
|
||||
// No Metadata at all
|
||||
Entry("mp3 with no tags", "empty.mp3"),
|
||||
Entry("wav with no tags", "empty.wav"),
|
||||
|
||||
// More standard cases
|
||||
Entry("normal flac", "test.flac", "vorbis"),
|
||||
Entry("normal m4a", "test.m4a", "mp4"),
|
||||
Entry("mp3 with id3v2", "no_replaygain.mp3", "id3v2"),
|
||||
Entry("normal wma", "test.wma", "asf"),
|
||||
Entry("normal opus", "test.ogg", "vorbis"),
|
||||
Entry("wavpak with ape", "test.wv", "ape"),
|
||||
Entry("nonempty wav", "test.wav", "id3v2"),
|
||||
Entry("nonempty aiff", "test.aiff", "id3v2"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -27,6 +27,13 @@ char has_cover(const TagLib::FileRef f);
|
||||
|
||||
static char TAGLIB_VERSION[16];
|
||||
|
||||
static char APE_TAG[] = "ape";
|
||||
static char ASF_TAG[] = "asf";
|
||||
static char ID3V1_TAG[] = "id3v1";
|
||||
static char ID3V2_TAG[] = "id3v2";
|
||||
static char MP4_TAG[] = "mp4";
|
||||
static char VORBIS_TAG[] = "vorbis";
|
||||
|
||||
char* taglib_version() {
|
||||
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
|
||||
return (char *)TAGLIB_VERSION;
|
||||
@ -103,11 +110,24 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
}
|
||||
|
||||
TagLib::ID3v2::Tag *id3Tags = NULL;
|
||||
bool has_tag = false;
|
||||
|
||||
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
||||
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
||||
if (mp3File != NULL) {
|
||||
id3Tags = mp3File->ID3v2Tag();
|
||||
if (mp3File->hasID3v2Tag()) {
|
||||
id3Tags = mp3File->ID3v2Tag();
|
||||
}
|
||||
|
||||
if (mp3File->hasID3v1Tag()) {
|
||||
goPutTagType(id, ID3V1_TAG);
|
||||
has_tag = true;
|
||||
}
|
||||
|
||||
if (mp3File->hasAPETag()) {
|
||||
goPutTagType(id, APE_TAG);
|
||||
has_tag = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (id3Tags == NULL) {
|
||||
@ -124,12 +144,21 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
}
|
||||
}
|
||||
|
||||
if (id3Tags == NULL) {
|
||||
if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
|
||||
id3Tags = dsffile->tag();
|
||||
}
|
||||
}
|
||||
|
||||
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
|
||||
// with many players, so they will not be parsed
|
||||
|
||||
if (id3Tags != NULL) {
|
||||
const auto &frames = id3Tags->frameListMap();
|
||||
|
||||
goPutTagType(id, ID3V2_TAG);
|
||||
has_tag = true;
|
||||
|
||||
for (const auto &kv: frames) {
|
||||
if (kv.first == "USLT") {
|
||||
for (const auto &tag: kv.second) {
|
||||
@ -189,12 +218,17 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
|
||||
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
|
||||
if (m4afile != NULL) {
|
||||
const auto itemListMap = m4afile->tag()->itemMap();
|
||||
for (const auto item: itemListMap) {
|
||||
char *key = const_cast<char*>(item.first.toCString(true));
|
||||
for (const auto value: item.second.toStringList()) {
|
||||
char *val = const_cast<char*>(value.toCString(true));
|
||||
goPutM4AStr(id, key, val);
|
||||
if (m4afile->hasMP4Tag()) {
|
||||
goPutTagType(id, MP4_TAG);
|
||||
has_tag = true;
|
||||
|
||||
const auto itemListMap = m4afile->tag()->itemMap();
|
||||
for (const auto item: itemListMap) {
|
||||
char *key = const_cast<char*>(item.first.toCString(true));
|
||||
for (const auto value: item.second.toStringList()) {
|
||||
char *val = const_cast<char*>(value.toCString(true));
|
||||
goPutM4AStr(id, key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -203,15 +237,21 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
|
||||
if (asfFile != NULL) {
|
||||
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
|
||||
const auto itemListMap = asfTags->attributeListMap();
|
||||
for (const auto item : itemListMap) {
|
||||
char *key = const_cast<char*>(item.first.toCString(true));
|
||||
|
||||
for (auto j = item.second.begin();
|
||||
j != item.second.end(); ++j) {
|
||||
if (asfTags != NULL) {
|
||||
goPutTagType(id, ASF_TAG);
|
||||
has_tag = true;
|
||||
|
||||
char *val = const_cast<char*>(j->toString().toCString(true));
|
||||
goPutStr(id, key, val);
|
||||
const auto itemListMap = asfTags->attributeListMap();
|
||||
for (const auto item : itemListMap) {
|
||||
char *key = const_cast<char*>(item.first.toCString(true));
|
||||
|
||||
for (auto j = item.second.begin();
|
||||
j != item.second.end(); ++j) {
|
||||
|
||||
char *val = const_cast<char*>(j->toString().toCString(true));
|
||||
goPutStr(id, key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -232,6 +272,34 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
goPutStr(id, (char *)"has_picture", (char *)"true");
|
||||
}
|
||||
|
||||
if (!has_tag) {
|
||||
if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
|
||||
if (flacFile->hasXiphComment()) {
|
||||
goPutTagType(id, VORBIS_TAG);
|
||||
}
|
||||
|
||||
if (flacFile->hasID3v2Tag()) {
|
||||
goPutTagType(id, ID3V2_TAG);
|
||||
}
|
||||
|
||||
if (flacFile->hasID3v1Tag()) {
|
||||
goPutTagType(id, ID3V1_TAG);
|
||||
}
|
||||
} else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
|
||||
goPutTagType(id, VORBIS_TAG);
|
||||
} else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
||||
goPutTagType(id, VORBIS_TAG);
|
||||
} else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
|
||||
if (wvFile->hasAPETag()) {
|
||||
goPutTagType(id, APE_TAG);
|
||||
}
|
||||
|
||||
if (wvFile->hasID3v1Tag()) {
|
||||
goPutTagType(id, ID3V1_TAG);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -270,7 +338,7 @@ char has_cover(const TagLib::FileRef f) {
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- AIFF
|
||||
// ----- AIFF
|
||||
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
|
||||
if (aiffFile->hasID3v2Tag()) {
|
||||
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
|
||||
|
||||
@ -155,3 +155,8 @@ func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
|
||||
m[k] = []string{formattedLine}
|
||||
}
|
||||
}
|
||||
|
||||
//export goPutTagType
|
||||
func goPutTagType(id C.ulong, tag *C.char) {
|
||||
doPutTag(id, "__tags", tag)
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ extern void goPutStr(unsigned long id, char *key, char *val);
|
||||
extern void goPutInt(unsigned long id, char *key, int val);
|
||||
extern void goPutLyrics(unsigned long id, char *lang, char *val);
|
||||
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
|
||||
extern void goPutTagType(unsigned long id, char *tag);
|
||||
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
|
||||
char* taglib_version();
|
||||
|
||||
|
||||
@ -70,6 +70,7 @@ type configOptions struct {
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverArtQuality int
|
||||
EnableWebPEncoding bool
|
||||
ArtistArtPriority string
|
||||
ArtistImageFolder string
|
||||
DiscArtPriority string
|
||||
@ -87,6 +88,7 @@ type configOptions struct {
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
UISearchDebounceMs int
|
||||
UICoverArtSize int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
@ -141,7 +143,6 @@ type configOptions struct {
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevJpegCoverArt bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@ -424,6 +425,13 @@ func Load(noConfigDump bool) {
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
|
||||
// Validate other options
|
||||
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
|
||||
newValue := max(200, min(1200, Server.UICoverArtSize))
|
||||
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
|
||||
Server.UICoverArtSize = newValue
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
@ -716,6 +724,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverartquality", 75)
|
||||
viper.SetDefault("enablewebpencoding", false)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("artistimagefolder", "")
|
||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||
@ -728,6 +737,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
@ -810,7 +820,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU()))
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
@ -826,7 +836,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
viper.SetDefault("devjpegcoverart", false)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@ -85,11 +85,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
UICoverArtSize = 600
|
||||
DefaultUICoverArtSize = 300
|
||||
)
|
||||
|
||||
var CacheWarmerImageSizes = []int{UICoverArtSize}
|
||||
|
||||
// Prometheus options
|
||||
const (
|
||||
PrometheusDefaultPath = "/metrics"
|
||||
|
||||
@ -380,24 +380,24 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
When("Square is false", func() {
|
||||
It("returns WebP even if original image is a PNG", func() {
|
||||
It("returns PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns WebP if original image is not a PNG", func() {
|
||||
It("returns JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
@ -430,24 +430,51 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", "webp", false, 200),
|
||||
Entry("landscape png image", "png", "webp", true, 200),
|
||||
Entry("portrait jpg image", "jpg", "webp", false, 200),
|
||||
Entry("landscape jpg image", "jpg", "webp", true, 200),
|
||||
Entry("portrait png image", "png", "png", false, 200),
|
||||
Entry("landscape png image", "png", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", "png", false, 200),
|
||||
Entry("landscape jpg image", "jpg", "png", true, 200),
|
||||
)
|
||||
})
|
||||
When("DevJpegCoverArt is true and square is false", func() {
|
||||
When("EnableWebPEncoding is true and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevJpegCoverArt = true
|
||||
conf.Server.EnableWebPEncoding = true
|
||||
})
|
||||
It("returns JPEG even if original image is a PNG", func() {
|
||||
It("returns WebP even if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns WebP if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("EnableWebPEncoding is false and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
@ -463,11 +490,11 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("DevJpegCoverArt is true and square is true", func() {
|
||||
When("EnableWebPEncoding is false and square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevJpegCoverArt = true
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG for square mode", func() {
|
||||
dirName := createImage("png", false, 200)
|
||||
|
||||
@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@ -24,7 +23,7 @@ type CacheWarmer interface {
|
||||
|
||||
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
|
||||
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
|
||||
// image size, as well as the size defined in the UICoverArtSize constant.
|
||||
// image size, as well as the size defined by the UICoverArtSize config option.
|
||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
// If image cache is disabled, return a NOOP implementation
|
||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||
@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
coverArtSize: conf.Server.UICoverArtSize,
|
||||
}
|
||||
|
||||
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
||||
@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
}
|
||||
|
||||
type cacheWarmer struct {
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
coverArtSize int
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, size := range consts.CacheWarmerImageSizes {
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
r.Close()
|
||||
return err
|
||||
size := a.coverArtSize
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
return nil
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func NoopCacheWarmer() CacheWarmer {
|
||||
|
||||
@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
|
||||
Eventually(func() []int {
|
||||
return aw.getCachedSizes()
|
||||
}).Should(ContainElements(consts.UICoverArtSize))
|
||||
}).Should(ContainElements(conf.Server.UICoverArtSize))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -61,7 +61,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
||||
func (a *albumArtworkReader) Key() string {
|
||||
hashInput := conf.Server.CoverArtPriority
|
||||
if conf.Server.EnableExternalServices {
|
||||
hashInput += conf.Server.Agents
|
||||
hashInput = conf.Server.Agents + hashInput
|
||||
}
|
||||
hash := md5.Sum([]byte(hashInput))
|
||||
return fmt.Sprintf(
|
||||
|
||||
@ -168,47 +168,38 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin
|
||||
}
|
||||
}
|
||||
|
||||
// extractDiscNumber extracts a disc number from a filename based on a glob pattern.
|
||||
// It finds the portion of the filename that the wildcard matched and parses leading
|
||||
// digits as the disc number. Returns (0, false) if the pattern doesn't match or
|
||||
// no leading digits are found in the wildcard portion.
|
||||
// globMetaChars holds the substitution metacharacters understood by
|
||||
// filepath.Match. The '\' escape character is intentionally excluded:
|
||||
// disc art patterns come from user config and never include escaped
|
||||
// metachars in practice, and treating '\' as a metachar would misalign
|
||||
// the literal-prefix extraction in extractDiscNumber.
|
||||
const globMetaChars = "*?["
|
||||
|
||||
// extractDiscNumber parses the disc number from a filename matched by a
|
||||
// filepath.Match-style glob pattern.
|
||||
//
|
||||
// Both pattern and filename must already be lowercased by the caller, which
|
||||
// is also expected to have verified that filepath.Match(pattern, filename)
|
||||
// is true before calling this function.
|
||||
func extractDiscNumber(pattern, filename string) (int, bool) {
|
||||
filename = strings.ToLower(filename)
|
||||
pattern = strings.ToLower(pattern)
|
||||
|
||||
matched, err := filepath.Match(pattern, filename)
|
||||
if err != nil || !matched {
|
||||
metaIdx := strings.IndexAny(pattern, globMetaChars)
|
||||
if metaIdx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Find the prefix before the first '*' in the pattern
|
||||
starIdx := strings.IndexByte(pattern, '*')
|
||||
if starIdx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
prefix := pattern[:starIdx]
|
||||
|
||||
// Strip the prefix from the filename to get the wildcard-matched portion
|
||||
prefix := pattern[:metaIdx]
|
||||
if !strings.HasPrefix(filename, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
remainder := filename[len(prefix):]
|
||||
|
||||
// Extract leading ASCII digits from the remainder
|
||||
var digits []byte
|
||||
for _, r := range remainder {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits = append(digits, byte(r))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
start := len(prefix)
|
||||
end := start
|
||||
for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
|
||||
if len(digits) == 0 {
|
||||
if end == start {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
num, err := strconv.Atoi(string(digits))
|
||||
num, err := strconv.Atoi(filename[start:end])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
@ -216,20 +207,16 @@ func extractDiscNumber(pattern, filename string) (int, bool) {
|
||||
}
|
||||
|
||||
// fromExternalFile returns a sourceFunc that matches image files against a glob
|
||||
// pattern with disc-number-aware filtering.
|
||||
//
|
||||
// Matching rules:
|
||||
// - If a disc number can be extracted from the filename, the file matches only if
|
||||
// the number equals the target disc number.
|
||||
// - If no number is found and this is a multi-folder album, the file matches if
|
||||
// it's in a folder containing tracks for this disc.
|
||||
// - If no number is found and this is a single-folder album, the file is skipped
|
||||
// (ambiguous).
|
||||
// pattern. A numbered filename whose number equals the target disc wins over
|
||||
// any unnumbered candidate; callers must pass a lowercase pattern.
|
||||
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
|
||||
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
var fallbacks []string
|
||||
for _, file := range d.imgFiles {
|
||||
_, name := filepath.Split(file)
|
||||
match, err := filepath.Match(pattern, strings.ToLower(name))
|
||||
name = strings.ToLower(name)
|
||||
match, err := filepath.Match(pattern, name)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
@ -238,24 +225,27 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to extract disc number from filename
|
||||
num, hasNum := extractDiscNumber(pattern, name)
|
||||
if hasNum {
|
||||
// File has a disc number — must match target disc
|
||||
if num != d.discNumber {
|
||||
continue
|
||||
if !isLiteral {
|
||||
if num, hasNum := extractDiscNumber(pattern, name); hasNum {
|
||||
if num != d.discNumber {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
} else if d.isMultiFolder {
|
||||
// No number, multi-folder: match by folder association
|
||||
dir := filepath.Dir(file)
|
||||
if !d.discFolders[dir] {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// No number, single-folder: ambiguous, skip
|
||||
continue
|
||||
}
|
||||
|
||||
if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] {
|
||||
continue
|
||||
}
|
||||
fallbacks = append(fallbacks, file)
|
||||
}
|
||||
|
||||
for _, file := range fallbacks {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
|
||||
@ -42,11 +42,24 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
// Case insensitive (filename already lowered by caller)
|
||||
Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
|
||||
|
||||
// Pattern doesn't match
|
||||
Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false),
|
||||
// HasPrefix guard: filename doesn't share the pattern's literal prefix
|
||||
Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false),
|
||||
|
||||
// Pattern with no wildcard before dot
|
||||
Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
|
||||
|
||||
// '?' single-char wildcard
|
||||
Entry("disc?.jpg with disc1.jpg", "disc?.jpg", "disc1.jpg", 1, true),
|
||||
Entry("disc?.jpg with disc2.jpg", "disc?.jpg", "disc2.jpg", 2, true),
|
||||
Entry("cd??.jpg with cd07.jpg", "cd??.jpg", "cd07.jpg", 7, true),
|
||||
|
||||
// '[...]' character class wildcard
|
||||
Entry("cd[12].jpg with cd1.jpg", "cd[12].jpg", "cd1.jpg", 1, true),
|
||||
Entry("cd[12].jpg with cd2.jpg", "cd[12].jpg", "cd2.jpg", 2, true),
|
||||
Entry("disc[0-9].jpg with disc5.jpg", "disc[0-9].jpg", "disc5.jpg", 5, true),
|
||||
|
||||
// Literal pattern (no wildcard) returns false
|
||||
Entry("shellac.png literal", "shellac.png", "shellac.png", 0, false),
|
||||
)
|
||||
})
|
||||
|
||||
@ -85,19 +98,186 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("skips file without number in single-folder album", func() {
|
||||
f1 := createFile("album/disc.jpg")
|
||||
It("matches file without number in single-folder album (shared disc art)", func() {
|
||||
f1 := createFile("album/cover.png")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, _, _ := sf()
|
||||
Expect(r).To(BeNil())
|
||||
sf := reader.fromExternalFile(ctx, "cover.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("returns shared disc art for every disc number in single-folder album", func() {
|
||||
f1 := createFile("album/shellac.png")
|
||||
makeReader := func(discNum int) *discArtworkReader {
|
||||
return &discArtworkReader{
|
||||
discNumber: discNum,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
}
|
||||
|
||||
for _, disc := range []int{1, 2, 5} {
|
||||
sf := makeReader(disc).fromExternalFile(ctx, "shellac.png")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred(), "disc %d", disc)
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1), "disc %d", disc)
|
||||
}
|
||||
})
|
||||
|
||||
It("numbered and unnumbered patterns both resolve against the same reader", func() {
|
||||
f1 := createFile("album/cover.png")
|
||||
f2 := createFile("album/disc1.jpg")
|
||||
f3 := createFile("album/disc2.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 2,
|
||||
imgFiles: []string{f1, f2, f3},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f3))
|
||||
|
||||
sf = reader.fromExternalFile(ctx, "cover.*")
|
||||
r, path, err = sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("respects DiscArtPriority order when both numbered and unnumbered patterns match", func() {
|
||||
f1 := createFile("album/cover.png")
|
||||
f2 := createFile("album/disc1.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
|
||||
Expect(ff).To(HaveLen(2))
|
||||
r, path, err := ff[0]()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(f2))
|
||||
r.Close()
|
||||
|
||||
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
|
||||
Expect(ff).To(HaveLen(2))
|
||||
r, path, err = ff[0]()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(f1))
|
||||
r.Close()
|
||||
})
|
||||
|
||||
DescribeTable("numbered match wins over shared fallback within a pattern",
|
||||
func(discNumber, expectedIdx int) {
|
||||
files := []string{
|
||||
createFile("album/disc.jpg"),
|
||||
createFile("album/disc1.jpg"),
|
||||
createFile("album/disc2.jpg"),
|
||||
}
|
||||
reader := &discArtworkReader{
|
||||
discNumber: discNumber,
|
||||
imgFiles: files,
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(files[expectedIdx]))
|
||||
},
|
||||
Entry("disc 2 picks disc2.jpg over the shared disc.jpg", 2, 2),
|
||||
Entry("disc 3 falls back to disc.jpg when no numbered match exists", 3, 0),
|
||||
)
|
||||
|
||||
It("tries the next fallback candidate when the first one cannot be opened", func() {
|
||||
f1 := createFile("album/cover.jpg")
|
||||
f2 := createFile("album/cover.png")
|
||||
// Remove f1 so os.Open will fail on it; f2 should still win.
|
||||
Expect(os.Remove(f1)).To(Succeed())
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "cover.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f2))
|
||||
})
|
||||
|
||||
It("keeps scanning literal-pattern matches so fallback retry still works", func() {
|
||||
// Guards against an 'early break on first literal match' optimization.
|
||||
// Multiple imgFiles entries can share a basename (symlinks, case-variant
|
||||
// duplicates on case-sensitive filesystems). If the loop breaks after
|
||||
// recording just the first, the fallback retry cannot recover when
|
||||
// that first file is unreadable.
|
||||
f1 := createFile("album/stale/cover.png")
|
||||
f2 := createFile("album/cover.png")
|
||||
Expect(os.Remove(f1)).To(Succeed())
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFolders: map[string]bool{
|
||||
filepath.Join(tmpDir, "album"): true,
|
||||
filepath.Join(tmpDir, "album/stale"): true,
|
||||
},
|
||||
isMultiFolder: true,
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "cover.png")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f2))
|
||||
})
|
||||
|
||||
DescribeTable("filters by disc number for non-'*' wildcard patterns",
|
||||
func(pattern string, discNumber, expectedIdx int) {
|
||||
files := []string{
|
||||
createFile("album/disc1.jpg"),
|
||||
createFile("album/disc2.jpg"),
|
||||
}
|
||||
reader := &discArtworkReader{
|
||||
discNumber: discNumber,
|
||||
imgFiles: files,
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, pattern)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(files[expectedIdx]))
|
||||
},
|
||||
Entry("disc?.jpg, target disc 1 → disc1.jpg", "disc?.jpg", 1, 0),
|
||||
Entry("disc?.jpg, target disc 2 → disc2.jpg", "disc?.jpg", 2, 1),
|
||||
Entry("disc[0-9].jpg, target disc 1 → disc1.jpg", "disc[0-9].jpg", 1, 0),
|
||||
Entry("disc[0-9].jpg, target disc 2 → disc2.jpg", "disc[0-9].jpg", 2, 1),
|
||||
)
|
||||
|
||||
It("matches file without number in multi-folder album by folder", func() {
|
||||
f1 := createFile("album/cd1/disc.jpg")
|
||||
f2 := createFile("album/cd2/disc.jpg")
|
||||
|
||||
@ -19,6 +19,16 @@ import (
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if err := webp.Dynamic(); err != nil {
|
||||
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
|
||||
} else {
|
||||
log.Debug("Using native libwebp for WebP encoding/decoding")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
@ -117,7 +127,7 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, _, err := image.Decode(bytes.NewReader(data))
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -157,14 +167,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
if conf.Server.DevJpegCoverArt {
|
||||
if square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
} else {
|
||||
if conf.Server.EnableWebPEncoding {
|
||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
||||
} else if format == "png" || square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
if err != nil {
|
||||
bufPool.Put(buf)
|
||||
|
||||
@ -49,6 +49,7 @@ type FFmpeg interface {
|
||||
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
||||
CmdPath() (string, error)
|
||||
IsAvailable() bool
|
||||
IsProbeAvailable() bool
|
||||
Version() string
|
||||
}
|
||||
|
||||
@ -224,6 +225,19 @@ func (e *ffmpeg) IsAvailable() bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (e *ffmpeg) IsProbeAvailable() bool {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return false
|
||||
}
|
||||
probeOnce.Do(func() {
|
||||
probePath := ffprobePath(ffmpegPath)
|
||||
if _, err := exec.LookPath(probePath); err == nil {
|
||||
probeAvail = true
|
||||
}
|
||||
})
|
||||
return probeAvail
|
||||
}
|
||||
|
||||
// Version executes ffmpeg -version and extracts the version from the output.
|
||||
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
||||
func (e *ffmpeg) Version() string {
|
||||
@ -373,18 +387,7 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
if opts.BitRate > 0 {
|
||||
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
|
||||
}
|
||||
if opts.SampleRate > 0 {
|
||||
args = append(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||
}
|
||||
if opts.Channels > 0 {
|
||||
args = append(args, "-ac", strconv.Itoa(opts.Channels))
|
||||
}
|
||||
// Only pass -sample_fmt for lossless output formats where bit depth matters.
|
||||
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
|
||||
// and passing interleaved formats like "s16" causes silent failures.
|
||||
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
||||
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
||||
}
|
||||
args = injectDynamicAudioFlags(args, opts)
|
||||
|
||||
args = append(args, "-v", "0")
|
||||
|
||||
@ -398,12 +401,19 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
|
||||
// buildTemplateArgs handles user-customized command templates, with dynamic injection
|
||||
// of sample rate, channels, and bit depth when requested by the transcode decision.
|
||||
// Note: these flags are injected unconditionally when non-zero, even if the template
|
||||
// already includes them. FFmpeg uses the last occurrence of duplicate flags.
|
||||
// Values in opts have already been clamped to codec limits upstream (see
|
||||
// core/stream/codec.go codecMax* helpers), so injecting them unconditionally is safe —
|
||||
// ffmpeg honors the last occurrence of a duplicate flag.
|
||||
func buildTemplateArgs(opts TranscodeOptions) []string {
|
||||
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
|
||||
return injectDynamicAudioFlags(args, opts)
|
||||
}
|
||||
|
||||
// Dynamically inject -ar, -ac, and -sample_fmt before the output target
|
||||
// injectDynamicAudioFlags appends -ar, -ac, and -sample_fmt flags based on opts.
|
||||
// Only passes -sample_fmt for lossless output formats where bit depth matters:
|
||||
// lossy codecs (mp3, aac, opus) handle sample format conversion internally, and
|
||||
// passing interleaved formats like "s16" causes silent failures.
|
||||
func injectDynamicAudioFlags(args []string, opts TranscodeOptions) []string {
|
||||
if opts.SampleRate > 0 {
|
||||
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||
}
|
||||
@ -533,4 +543,6 @@ var (
|
||||
ffOnce sync.Once
|
||||
ffmpegPath string
|
||||
ffmpegErr error
|
||||
probeOnce sync.Once
|
||||
probeAvail bool
|
||||
)
|
||||
|
||||
@ -195,6 +195,8 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
||||
data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload
|
||||
data.Config.CoverArtQuality = conf.Server.CoverArtQuality
|
||||
data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
|
||||
data.Config.UICoverArtSize = conf.Server.UICoverArtSize
|
||||
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||
|
||||
@ -65,6 +65,8 @@ type Data struct {
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
||||
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
||||
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
|
||||
@ -45,6 +45,9 @@ func PublicURL(req *http.Request, u string, params url.Values) string {
|
||||
}
|
||||
buildUrl.Scheme = shareUrl.Scheme
|
||||
buildUrl.Host = shareUrl.Host
|
||||
if basePath := strings.TrimRight(shareUrl.Path, "/"); basePath != "" {
|
||||
buildUrl.Path = path.Join(basePath, buildUrl.Path)
|
||||
}
|
||||
if len(params) > 0 {
|
||||
buildUrl.RawQuery = params.Encode()
|
||||
}
|
||||
|
||||
@ -56,6 +56,31 @@ var _ = Describe("Public URL Utilities", func() {
|
||||
})
|
||||
})
|
||||
|
||||
When("ShareURL includes a path", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = "https://example.com/navi"
|
||||
})
|
||||
|
||||
It("prepends the ShareURL path to the resource", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.PublicURL(r, "/share/img/hash", nil)
|
||||
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
|
||||
})
|
||||
|
||||
It("prepends the ShareURL path and includes query parameters", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
params := url.Values{"size": []string{"600"}}
|
||||
result := publicurl.PublicURL(r, "/share/img/hash", params)
|
||||
Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600"))
|
||||
})
|
||||
|
||||
It("handles trailing slash in ShareURL path", func() {
|
||||
conf.Server.ShareURL = "https://example.com/navi/"
|
||||
result := publicurl.PublicURL(nil, "/share/img/hash", nil)
|
||||
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
|
||||
})
|
||||
})
|
||||
|
||||
When("ShareURL is not set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = ""
|
||||
|
||||
@ -75,3 +75,16 @@ func codecMaxSampleRate(codec string) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// codecMaxChannels returns the hard maximum number of audio channels a codec
|
||||
// supports. Returns 0 if the codec has no hard limit (or is unknown), in which
|
||||
// case the source/profile constraints applied upstream are authoritative.
|
||||
func codecMaxChannels(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "mp3":
|
||||
return 2
|
||||
case "opus":
|
||||
return 8
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -66,4 +66,26 @@ var _ = Describe("Codec", func() {
|
||||
Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("codecMaxChannels", func() {
|
||||
It("returns 2 for mp3", func() {
|
||||
Expect(codecMaxChannels("mp3")).To(Equal(2))
|
||||
})
|
||||
|
||||
It("returns 8 for opus", func() {
|
||||
Expect(codecMaxChannels("opus")).To(Equal(8))
|
||||
})
|
||||
|
||||
It("is case-insensitive", func() {
|
||||
Expect(codecMaxChannels("MP3")).To(Equal(2))
|
||||
Expect(codecMaxChannels("Opus")).To(Equal(8))
|
||||
})
|
||||
|
||||
It("returns 0 for codecs with no hard limit", func() {
|
||||
Expect(codecMaxChannels("aac")).To(Equal(0))
|
||||
Expect(codecMaxChannels("flac")).To(Equal(0))
|
||||
Expect(codecMaxChannels("vorbis")).To(Equal(0))
|
||||
Expect(codecMaxChannels("")).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -44,10 +44,14 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile,
|
||||
|
||||
var probe *ffmpeg.AudioProbeResult
|
||||
if !opts.SkipProbe {
|
||||
var err error
|
||||
probe, err = s.ensureProbed(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if !s.ff.IsProbeAvailable() {
|
||||
log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID)
|
||||
} else {
|
||||
var err error
|
||||
probe, err = s.ensureProbed(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +199,17 @@ func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// matchesPCMWAVBridge bridges Navidrome's internal "pcm" codec name with the
|
||||
// "wav" codec name that browsers use to advertise audio/wav support. The match
|
||||
// is scoped to WAV-container sources so AIFF files (which also normalize to
|
||||
// codec "pcm" but use a different container) cannot false-match a codec-only
|
||||
// ["wav"] profile.
|
||||
func matchesPCMWAVBridge(src *Details, profile *DirectPlayProfile) bool {
|
||||
return strings.EqualFold(src.Codec, "pcm") &&
|
||||
strings.EqualFold(src.Container, "wav") &&
|
||||
containsIgnoreCase(profile.AudioCodecs, "wav")
|
||||
}
|
||||
|
||||
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
||||
// or a typed reason string if it doesn't match.
|
||||
func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
|
||||
@ -205,17 +220,17 @@ func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPla
|
||||
|
||||
// Check container
|
||||
if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
|
||||
return "container not supported"
|
||||
return fmt.Sprintf("container '%s' not supported by profile %s", src.Container, profile)
|
||||
}
|
||||
|
||||
// Check codec
|
||||
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) {
|
||||
return "audio codec not supported"
|
||||
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) {
|
||||
return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile)
|
||||
}
|
||||
|
||||
// Check channels
|
||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
||||
return "audio channels not supported"
|
||||
return fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels)
|
||||
}
|
||||
|
||||
// Check codec-specific limitations
|
||||
@ -279,14 +294,19 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai
|
||||
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
|
||||
ts.SampleRate = maxRate
|
||||
}
|
||||
if maxCh := codecMaxChannels(ts.Codec); maxCh > 0 && ts.Channels > maxCh {
|
||||
ts.Channels = maxCh
|
||||
}
|
||||
|
||||
// Determine target bitrate (all in kbps)
|
||||
if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Apply MaxAudioChannels from the transcoding profile
|
||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
||||
// Apply MaxAudioChannels from the transcoding profile. Compare against the
|
||||
// already-clamped ts.Channels (not src.Channels) so the codec hard limit
|
||||
// applied above is never raised by a looser profile setting.
|
||||
if profile.MaxAudioChannels > 0 && ts.Channels > profile.MaxAudioChannels {
|
||||
ts.Channels = profile.MaxAudioChannels
|
||||
}
|
||||
|
||||
|
||||
@ -76,7 +76,10 @@ var _ = Describe("Decider", func() {
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
|
||||
Expect(decision.TranscodeReasons).To(ContainElement(And(
|
||||
ContainSubstring("container 'flac' not supported"),
|
||||
ContainSubstring("[mp3]"),
|
||||
)))
|
||||
})
|
||||
|
||||
It("rejects direct play when codec doesn't match", func() {
|
||||
@ -89,7 +92,10 @@ var _ = Describe("Decider", func() {
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported"))
|
||||
Expect(decision.TranscodeReasons).To(ContainElement(And(
|
||||
ContainSubstring("audio codec 'alac' not supported"),
|
||||
ContainSubstring("[m4a/aac]"),
|
||||
)))
|
||||
})
|
||||
|
||||
It("rejects direct play when channels exceed limit", func() {
|
||||
@ -102,7 +108,44 @@ var _ = Describe("Decider", func() {
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported"))
|
||||
Expect(decision.TranscodeReasons).To(ContainElement(And(
|
||||
ContainSubstring("audio channels 6 not supported"),
|
||||
ContainSubstring("[flac]"),
|
||||
ContainSubstring("(max 2)"),
|
||||
)))
|
||||
})
|
||||
|
||||
It("accepts WAV source against a wav codec profile (pcm->wav bridge)", func() {
|
||||
// ffprobe normalizes PCM variants (pcm_s16le etc) to codec "pcm", but
|
||||
// browsers advertise WAV support as audioCodecs:["wav"] via audio/wav MIME.
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "wav", Codec: "pcm", BitRate: 1411, Channels: 2})
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"wav"}, AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not accept AIFF (pcm in non-wav container) against a wav codec profile", func() {
|
||||
// AIFF files also normalize to codec="pcm" but use container="aiff".
|
||||
// Without the container guard they would falsely match a codec-only
|
||||
// ["wav"] profile and be direct-played as if they were WAV.
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "aiff", Codec: "pcm", BitRate: 1411, Channels: 2})
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement(ContainSubstring("audio codec 'pcm'")))
|
||||
})
|
||||
|
||||
It("handles container aliases (aac -> m4a)", func() {
|
||||
@ -216,7 +259,10 @@ var _ = Describe("Decider", func() {
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||
Expect(decision.TargetBitrate).To(Equal(256)) // kbps
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
|
||||
Expect(decision.TranscodeReasons).To(ContainElement(And(
|
||||
ContainSubstring("container 'flac' not supported"),
|
||||
ContainSubstring("[mp3]"),
|
||||
)))
|
||||
})
|
||||
|
||||
It("rejects lossy to lossless transcoding", func() {
|
||||
@ -724,6 +770,73 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("Codec channel limits", func() {
|
||||
It("clamps 6-channel FLAC to 2 channels when transcoding to MP3", 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})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||
Expect(decision.TranscodeStream.Channels).To(Equal(2))
|
||||
Expect(decision.TargetChannels).To(Equal(2))
|
||||
})
|
||||
|
||||
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})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 1},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.Channels).To(Equal(1))
|
||||
Expect(decision.TargetChannels).To(Equal(1))
|
||||
})
|
||||
|
||||
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})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 4},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.Channels).To(Equal(2))
|
||||
Expect(decision.TargetChannels).To(Equal(2))
|
||||
})
|
||||
|
||||
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})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "m4a", AudioCodec: "aac", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("aac"))
|
||||
Expect(decision.TranscodeStream.Channels).To(Equal(6))
|
||||
Expect(decision.TargetChannels).To(Equal(6))
|
||||
})
|
||||
})
|
||||
|
||||
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"
|
||||
@ -901,9 +1014,12 @@ var _ = Describe("Decider", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(HaveLen(3))
|
||||
Expect(decision.TranscodeReasons[0]).To(Equal("container not supported"))
|
||||
Expect(decision.TranscodeReasons[1]).To(Equal("container not supported"))
|
||||
Expect(decision.TranscodeReasons[2]).To(Equal("container not supported"))
|
||||
Expect(decision.TranscodeReasons[0]).To(ContainSubstring("container 'ogg' not supported"))
|
||||
Expect(decision.TranscodeReasons[0]).To(ContainSubstring("[flac]"))
|
||||
Expect(decision.TranscodeReasons[1]).To(ContainSubstring("container 'ogg' not supported"))
|
||||
Expect(decision.TranscodeReasons[1]).To(ContainSubstring("[mp3/mp3]"))
|
||||
Expect(decision.TranscodeReasons[2]).To(ContainSubstring("container 'ogg' not supported"))
|
||||
Expect(decision.TranscodeReasons[2]).To(ContainSubstring("[m4a,mp4/aac]"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -1115,6 +1231,7 @@ var _ = Describe("Decider", func() {
|
||||
Expect(bitrate).To(Equal(fallbackBitrate))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("ensureProbed", func() {
|
||||
|
||||
@ -2,6 +2,7 @@ package stream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -47,6 +48,18 @@ type DirectPlayProfile struct {
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
func (p DirectPlayProfile) String() string {
|
||||
containers := strings.Join(p.Containers, ",")
|
||||
if containers == "" {
|
||||
containers = "*"
|
||||
}
|
||||
codecs := strings.Join(p.AudioCodecs, ",")
|
||||
if codecs == "" {
|
||||
return "[" + containers + "]"
|
||||
}
|
||||
return "[" + containers + "/" + codecs + "]"
|
||||
}
|
||||
|
||||
// Profile describes a transcoding target the client supports
|
||||
type Profile struct {
|
||||
Container string
|
||||
|
||||
@ -81,12 +81,12 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
|
||||
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
|
||||
// This will lock out other writes that could happen at the same time
|
||||
done, err := backupOp.Step(-1)
|
||||
if !done {
|
||||
return fmt.Errorf("backup not done with step -1")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during backup step: %w", err)
|
||||
}
|
||||
if !done {
|
||||
return fmt.Errorf("backup not done with step -1")
|
||||
}
|
||||
|
||||
err = backupOp.Finish()
|
||||
if err != nil {
|
||||
|
||||
55
db/migrations/20260405124200_fix_schema_inconsistencies.sql
Normal file
55
db/migrations/20260405124200_fix_schema_inconsistencies.sql
Normal file
@ -0,0 +1,55 @@
|
||||
-- +goose Up
|
||||
|
||||
-- NOTE: This migration recreates two tables to fix schema inconsistencies.
|
||||
-- On large production databases, the data copy may take some time as tables are locked during the transaction.
|
||||
-- This is necessary because SQLite does not support altering table constraints directly.
|
||||
-- Consider applying this migration during a maintenance window if the tables are large.
|
||||
|
||||
-- Fix library_artist table: Remove contradictory 'default null' from 'not null' column
|
||||
-- This is a cosmetic fix (NOT NULL takes precedence), but improves schema consistency
|
||||
CREATE TABLE library_artist_new
|
||||
(
|
||||
library_id integer NOT NULL DEFAULT 1
|
||||
REFERENCES library(id) ON DELETE CASCADE,
|
||||
artist_id varchar NOT NULL
|
||||
REFERENCES artist(id) ON DELETE CASCADE,
|
||||
stats text DEFAULT '{}',
|
||||
CONSTRAINT library_artist_ux UNIQUE (library_id, artist_id)
|
||||
);
|
||||
|
||||
INSERT INTO library_artist_new (library_id, artist_id, stats)
|
||||
SELECT library_id, artist_id, stats FROM library_artist;
|
||||
|
||||
DROP TABLE library_artist;
|
||||
|
||||
ALTER TABLE library_artist_new RENAME TO library_artist;
|
||||
|
||||
-- Fix scrobble_buffer table: Remove duplicate user_id from unique constraint
|
||||
-- Original constraint had: UNIQUE (user_id, service, media_file_id, play_time, user_id)
|
||||
-- Fixed constraint is: UNIQUE (user_id, service, media_file_id, play_time)
|
||||
CREATE TABLE scrobble_buffer_new
|
||||
(
|
||||
user_id varchar NOT NULL
|
||||
CONSTRAINT scrobble_buffer_user_id_fk
|
||||
REFERENCES user ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
service varchar NOT NULL,
|
||||
media_file_id varchar NOT NULL
|
||||
CONSTRAINT scrobble_buffer_media_file_id_fk
|
||||
REFERENCES media_file ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
play_time datetime NOT NULL,
|
||||
enqueue_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
id varchar NOT NULL DEFAULT '',
|
||||
CONSTRAINT scrobble_buffer_pk UNIQUE (user_id, service, media_file_id, play_time)
|
||||
);
|
||||
|
||||
INSERT INTO scrobble_buffer_new (user_id, service, media_file_id, play_time, enqueue_time, id)
|
||||
SELECT user_id, service, media_file_id, play_time, enqueue_time, id FROM scrobble_buffer;
|
||||
|
||||
DROP TABLE scrobble_buffer;
|
||||
|
||||
ALTER TABLE scrobble_buffer_new RENAME TO scrobble_buffer;
|
||||
|
||||
CREATE UNIQUE INDEX scrobble_buffer_id_ix ON scrobble_buffer (id);
|
||||
|
||||
-- +goose Down
|
||||
-- Down migration is intentionally a no-op: Navidrome does not run down migrations.
|
||||
22
db/migrations/20260410201914_fix_zero_album_created_at.sql
Normal file
22
db/migrations/20260410201914_fix_zero_album_created_at.sql
Normal file
@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Backfill album.created_at for rows poisoned by early scanner versions or
|
||||
-- propagated via CopyAttributes during metadata-driven ID changes. Prefer the
|
||||
-- oldest valid birth_time from the album's media files, fall back to updated_at.
|
||||
UPDATE album
|
||||
SET created_at = COALESCE(
|
||||
(SELECT MIN(birth_time)
|
||||
FROM media_file
|
||||
WHERE media_file.album_id = album.id
|
||||
AND birth_time IS NOT NULL
|
||||
AND birth_time != ''
|
||||
AND birth_time NOT LIKE '0001-%'),
|
||||
updated_at
|
||||
)
|
||||
WHERE created_at IS NULL
|
||||
OR created_at = ''
|
||||
OR created_at LIKE '0001-%';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
SELECT 1;
|
||||
2
go.mod
2
go.mod
@ -3,7 +3,7 @@ module github.com/navidrome/navidrome
|
||||
go 1.25.0
|
||||
|
||||
// Fork to implement raw tags support
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
|
||||
4
go.sum
4
go.sum
@ -34,8 +34,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7 h1:RpRSTEsAdLHx3Ci0d3M5wtpjcBZiKzhnGfnNAxGXrAE=
|
||||
github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw=
|
||||
github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
|
||||
@ -35,6 +35,7 @@ var fieldMap = map[string]*mappedField{
|
||||
"releasedate": {field: "media_file.release_date"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"missing": {field: "media_file.missing"},
|
||||
"explicitstatus": {field: "media_file.explicit_status"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
@ -49,9 +50,11 @@ var fieldMap = map[string]*mappedField{
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
"filetype": {field: "media_file.suffix"},
|
||||
"codec": {field: "media_file.codec"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bitdepth": {field: "media_file.bit_depth"},
|
||||
"samplerate": {field: "media_file.sample_rate"},
|
||||
"bpm": {field: "media_file.bpm"},
|
||||
"channels": {field: "media_file.channels"},
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
|
||||
@ -361,6 +361,9 @@ func older(t1, t2 time.Time) time.Time {
|
||||
if t1.IsZero() {
|
||||
return t2
|
||||
}
|
||||
if t2.IsZero() {
|
||||
return t1
|
||||
}
|
||||
if t1.After(t2) {
|
||||
return t2
|
||||
}
|
||||
|
||||
@ -119,6 +119,20 @@ var _ = Describe("MediaFiles", func() {
|
||||
Expect(a.MinYear).To(Equal(1999))
|
||||
})
|
||||
})
|
||||
Context("CreatedAt aggregation", func() {
|
||||
It("ignores zero BirthTime values when computing the oldest", func() {
|
||||
mfs = MediaFiles{
|
||||
{BirthTime: t("2022-12-19 08:30")},
|
||||
{BirthTime: time.Time{}},
|
||||
{BirthTime: t("2022-12-18 10:00")},
|
||||
}
|
||||
Expect(mfs.ToAlbum().CreatedAt).To(Equal(t("2022-12-18 10:00")))
|
||||
})
|
||||
It("returns zero when all BirthTime values are zero", func() {
|
||||
mfs = MediaFiles{{BirthTime: time.Time{}}, {BirthTime: time.Time{}}}
|
||||
Expect(mfs.ToAlbum().CreatedAt).To(BeZero())
|
||||
})
|
||||
})
|
||||
})
|
||||
When("we have multiple songs with same dates", func() {
|
||||
BeforeEach(func() {
|
||||
|
||||
@ -12,88 +12,85 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type hashFunc = func(...string) string
|
||||
|
||||
// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata
|
||||
// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes
|
||||
// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
|
||||
// For each field, it gets all its attributes values and concatenates them, then hashes the result.
|
||||
// If a field is empty, it is skipped and the function looks for the next field.
|
||||
type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string
|
||||
|
||||
func createGetPID(hash hashFunc) getPIDFunc {
|
||||
var getPID getPIDFunc
|
||||
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string {
|
||||
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||
switch attr {
|
||||
case "albumid":
|
||||
if spec == conf.Server.PID.Album {
|
||||
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
|
||||
return ""
|
||||
// computePID calculates the persistent ID for a given spec. The spec is a
|
||||
// pipe-separated list of fields, where each field is a comma-separated list of
|
||||
// attributes. Attributes can be either tags or processed values like folder,
|
||||
// albumid, albumartistid, etc. For each field, it gets all its attribute values
|
||||
// and concatenates them, then hashes the result. If a field is empty, it is
|
||||
// skipped and the function looks for the next field.
|
||||
//
|
||||
// Taking hash as a parameter (instead of closing over it in a factory) keeps
|
||||
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
|
||||
// heap on every call.
|
||||
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
|
||||
switch spec {
|
||||
case "track_legacy":
|
||||
return legacyTrackID(mf, prependLibId)
|
||||
case "album_legacy":
|
||||
return legacyAlbumID(mf, md, prependLibId)
|
||||
}
|
||||
pid := ""
|
||||
fields := strings.SplitSeq(spec, "|")
|
||||
for field := range fields {
|
||||
attributes := strings.Split(field, ",")
|
||||
values := make([]string, len(attributes))
|
||||
hasValue := false
|
||||
for i, attr := range attributes {
|
||||
v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
|
||||
if v != "" {
|
||||
hasValue = true
|
||||
}
|
||||
return getPID(mf, md, conf.Server.PID.Album, prependLibId)
|
||||
case "folder":
|
||||
return filepath.Dir(mf.Path)
|
||||
case "albumartistid":
|
||||
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
|
||||
case "title":
|
||||
return mf.Title
|
||||
case "album":
|
||||
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
|
||||
values[i] = v
|
||||
}
|
||||
if hasValue {
|
||||
pid += strings.Join(values, "\\")
|
||||
break
|
||||
}
|
||||
return md.String(model.TagName(attr))
|
||||
}
|
||||
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||
pid := ""
|
||||
fields := strings.SplitSeq(spec, "|")
|
||||
for field := range fields {
|
||||
attributes := strings.Split(field, ",")
|
||||
hasValue := false
|
||||
values := slice.Map(attributes, func(attr string) string {
|
||||
v := getAttr(mf, md, attr, prependLibId, spec)
|
||||
if v != "" {
|
||||
hasValue = true
|
||||
}
|
||||
return v
|
||||
})
|
||||
if hasValue {
|
||||
pid += strings.Join(values, "\\")
|
||||
break
|
||||
}
|
||||
}
|
||||
if prependLibId {
|
||||
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
|
||||
}
|
||||
return hash(pid)
|
||||
if prependLibId {
|
||||
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
|
||||
}
|
||||
return hash(pid)
|
||||
}
|
||||
|
||||
return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||
switch spec {
|
||||
case "track_legacy":
|
||||
return legacyTrackID(mf, prependLibId)
|
||||
case "album_legacy":
|
||||
return legacyAlbumID(mf, md, prependLibId)
|
||||
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
|
||||
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||
switch attr {
|
||||
case "albumid":
|
||||
if spec == conf.Server.PID.Album {
|
||||
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
|
||||
return ""
|
||||
}
|
||||
return getPID(mf, md, spec, prependLibId)
|
||||
return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
|
||||
case "folder":
|
||||
return filepath.Dir(mf.Path)
|
||||
case "albumartistid":
|
||||
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
|
||||
case "title":
|
||||
return mf.Title
|
||||
case "album":
|
||||
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
|
||||
}
|
||||
return md.String(model.TagName(attr))
|
||||
}
|
||||
|
||||
func (md Metadata) trackPID(mf model.MediaFile) string {
|
||||
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true)
|
||||
return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
|
||||
}
|
||||
|
||||
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
|
||||
return createGetPID(id.NewHash)(mf, md, pidConf, true)
|
||||
return computePID(mf, md, pidConf, true, id.NewHash)
|
||||
}
|
||||
|
||||
// BFR Must be configurable?
|
||||
func (md Metadata) artistID(name string) string {
|
||||
mf := model.MediaFile{AlbumArtist: name}
|
||||
return createGetPID(id.NewHash)(mf, md, "albumartistid", false)
|
||||
return computePID(mf, md, "albumartistid", false, id.NewHash)
|
||||
}
|
||||
|
||||
func (md Metadata) mapTrackTitle() string {
|
||||
|
||||
@ -12,15 +12,16 @@ import (
|
||||
|
||||
var _ = Describe("getPID", func() {
|
||||
var (
|
||||
md Metadata
|
||||
mf model.MediaFile
|
||||
sum hashFunc
|
||||
getPID getPIDFunc
|
||||
md Metadata
|
||||
mf model.MediaFile
|
||||
sum hashFunc
|
||||
)
|
||||
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||
return computePID(mf, md, spec, prependLibId, sum)
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
|
||||
getPID = createGetPID(sum)
|
||||
})
|
||||
|
||||
Context("attributes are tags", func() {
|
||||
|
||||
@ -192,6 +192,7 @@ const (
|
||||
TagISRC TagName = "isrc"
|
||||
TagBPM TagName = "bpm"
|
||||
TagExplicitStatus TagName = "explicitstatus"
|
||||
TagMetadataTag TagName = "tags"
|
||||
|
||||
// Dates and years
|
||||
|
||||
|
||||
@ -252,7 +252,17 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
|
||||
}
|
||||
to := make(map[string]any)
|
||||
for _, col := range columns {
|
||||
to[col] = from[col]
|
||||
v := from[col]
|
||||
// created_at is aggregated from song birth_times and must never be
|
||||
// overwritten with a zero/poisoned value, or it propagates forward on
|
||||
// every metadata-driven album ID change.
|
||||
if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) {
|
||||
continue
|
||||
}
|
||||
to[col] = v
|
||||
}
|
||||
if len(to) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID}))
|
||||
return err
|
||||
|
||||
@ -41,6 +41,32 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CopyAttributes", func() {
|
||||
var srcTime, dstTime time.Time
|
||||
BeforeEach(func() {
|
||||
srcTime = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
dstTime = time.Date(2024, 6, 7, 8, 9, 10, 0, time.UTC)
|
||||
Expect(albumRepo.Put(&model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed())
|
||||
Expect(albumRepo.Put(&model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed())
|
||||
Expect(albumRepo.Put(&model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed())
|
||||
DeferCleanup(func() {
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}}))
|
||||
})
|
||||
})
|
||||
It("copies a valid created_at from source to destination", func() {
|
||||
Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed())
|
||||
got, err := albumRepo.Get("copy-dst")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.CreatedAt).To(BeTemporally("~", srcTime, time.Second))
|
||||
})
|
||||
It("leaves destination untouched when source created_at is zero", func() {
|
||||
Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "created_at")).To(Succeed())
|
||||
got, err := albumRepo.Get("copy-dst")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
|
||||
albums, err := albumRepo.GetAll(opts...)
|
||||
|
||||
@ -102,6 +102,11 @@ components:
|
||||
mbzReleaseTrackId:
|
||||
type: string
|
||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
path:
|
||||
type: string
|
||||
description: |-
|
||||
Path is the full path to the track file, relative to the library root.
|
||||
Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
|
||||
@ -68,6 +68,9 @@ type TrackInfo struct {
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// NowPlayingRequest is the request for now playing notification.
|
||||
|
||||
@ -128,6 +128,11 @@ components:
|
||||
mbzReleaseTrackId:
|
||||
type: string
|
||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
path:
|
||||
type: string
|
||||
description: |-
|
||||
Path is the full path to the track file, relative to the library root.
|
||||
Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
@ -35,6 +36,8 @@ type kvstoreServiceImpl struct {
|
||||
pluginName string
|
||||
db *sql.DB
|
||||
maxSize int64
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database.
|
||||
@ -74,12 +77,15 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm
|
||||
|
||||
log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)))
|
||||
|
||||
cleanupCtx, cancel := context.WithCancel(ctx)
|
||||
svc := &kvstoreServiceImpl{
|
||||
pluginName: pluginName,
|
||||
db: db,
|
||||
maxSize: maxSize,
|
||||
cancel: cancel,
|
||||
}
|
||||
go svc.cleanupLoop(ctx)
|
||||
svc.wg.Add(1)
|
||||
go svc.cleanupLoop(cleanupCtx)
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
@ -335,6 +341,7 @@ func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[st
|
||||
// cleanupLoop periodically removes expired keys from the database.
|
||||
// It stops when the provided context is cancelled.
|
||||
func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@ -359,17 +366,12 @@ func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close runs a final cleanup and closes the SQLite database connection.
|
||||
// The cleanup goroutine is stopped by the context passed to newKVStoreService.
|
||||
// Close stops the cleanup goroutine and closes the SQLite database connection.
|
||||
func (s *kvstoreServiceImpl) Close() error {
|
||||
if s.db != nil {
|
||||
log.Debug("Closing plugin kvstore", "plugin", s.pluginName)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.cleanupExpired(ctx)
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
log.Debug("Closing plugin kvstore", "plugin", s.pluginName)
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Compile-time verification
|
||||
|
||||
@ -445,6 +445,36 @@ var _ = Describe("KVStoreService", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Close", func() {
|
||||
It("does not race with cleanupLoop goroutine", func() {
|
||||
// Create a service with a dedicated context so we can verify
|
||||
// that Close() properly waits for the cleanup goroutine.
|
||||
closeCtx, closeCancel := context.WithCancel(ctx)
|
||||
defer closeCancel()
|
||||
|
||||
maxSize := "1KB"
|
||||
svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Insert an expired key so cleanup has work to do
|
||||
_, err = svc.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('cleanup_race', 'old', 3, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Close should not panic or produce "database is closed" errors.
|
||||
// Before the fix, the cleanup goroutine could race with db.Close().
|
||||
err = svc.Close()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify the database is actually closed (further queries should fail)
|
||||
_, err = svc.db.Exec(`SELECT 1`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("database is closed"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SetWithTTL", func() {
|
||||
It("stores value that is retrievable before expiry", func() {
|
||||
err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600)
|
||||
|
||||
@ -31,7 +31,7 @@ type LyricsPlugin struct {
|
||||
// using model.ToLyrics.
|
||||
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
req := capabilities.GetLyricsRequest{
|
||||
Track: mediaFileToTrackInfo(mf),
|
||||
Track: mediaFileToTrackInfo(l.plugin, mf),
|
||||
}
|
||||
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
|
||||
ctx, l.plugin, FuncLyricsGetLyrics, req,
|
||||
|
||||
@ -301,7 +301,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
}
|
||||
|
||||
// Configure filesystem access for library permission
|
||||
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem {
|
||||
if pkg.Manifest.HasLibraryFilesystemPermission() {
|
||||
adminCtx := adminContext(ctx)
|
||||
libraries, err := m.ds.Library(adminCtx).GetAll()
|
||||
if err != nil {
|
||||
@ -384,6 +384,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
metrics: m.metrics,
|
||||
allowedUserIDs: allowedUsers,
|
||||
allUsers: p.AllUsers,
|
||||
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ type plugin struct {
|
||||
metrics PluginMetricsRecorder
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
libraries libraryAccess
|
||||
}
|
||||
|
||||
// instance creates a new plugin instance for the given context.
|
||||
@ -47,3 +48,30 @@ func (p *plugin) Close() error {
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (p *plugin) hasLibraryFilesystemAccess(libID int) bool {
|
||||
return p.manifest.HasLibraryFilesystemPermission() && p.libraries.contains(libID)
|
||||
}
|
||||
|
||||
// libraryAccess captures the set of libraries a plugin is permitted to see,
|
||||
// precomputed at load time for O(1) lookup.
|
||||
type libraryAccess struct {
|
||||
allLibraries bool
|
||||
libraryIDSet map[int]struct{}
|
||||
}
|
||||
|
||||
func newLibraryAccess(allowedLibraryIDs []int, allLibraries bool) libraryAccess {
|
||||
set := make(map[int]struct{}, len(allowedLibraryIDs))
|
||||
for _, id := range allowedLibraryIDs {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
return libraryAccess{allLibraries: allLibraries, libraryIDSet: set}
|
||||
}
|
||||
|
||||
func (a libraryAccess) contains(libID int) bool {
|
||||
if a.allLibraries {
|
||||
return true
|
||||
}
|
||||
_, ok := a.libraryIDSet[libID]
|
||||
return ok
|
||||
}
|
||||
|
||||
34
plugins/manager_plugin_test.go
Normal file
34
plugins/manager_plugin_test.go
Normal file
@ -0,0 +1,34 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("plugin", func() {
|
||||
Describe("hasLibraryFilesystemAccess", func() {
|
||||
fsManifest := &Manifest{
|
||||
Permissions: &Permissions{
|
||||
Library: &LibraryPermission{Filesystem: true},
|
||||
},
|
||||
}
|
||||
|
||||
It("returns false when the manifest does not grant filesystem permission", func() {
|
||||
p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess(nil, true)}
|
||||
Expect(p.hasLibraryFilesystemAccess(1)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true for any library when allLibraries is set", func() {
|
||||
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess(nil, true)}
|
||||
Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue())
|
||||
Expect(p.hasLibraryFilesystemAccess(42)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true only for libraries in the allowed list", func() {
|
||||
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1, 3}, false)}
|
||||
Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue())
|
||||
Expect(p.hasLibraryFilesystemAccess(3)).To(BeTrue())
|
||||
Expect(p.hasLibraryFilesystemAccess(2)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -86,3 +86,10 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
|
||||
func (m *Manifest) HasExperimentalThreads() bool {
|
||||
return m.Experimental != nil && m.Experimental.Threads != nil
|
||||
}
|
||||
|
||||
// HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries.
|
||||
func (m *Manifest) HasLibraryFilesystemPermission() bool {
|
||||
return m.Permissions != nil &&
|
||||
m.Permissions.Library != nil &&
|
||||
m.Permissions.Library.Filesystem
|
||||
}
|
||||
|
||||
@ -68,6 +68,9 @@ type TrackInfo struct {
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Lyrics requires all methods to be implemented.
|
||||
|
||||
@ -65,6 +65,9 @@ type TrackInfo struct {
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Lyrics requires all methods to be implemented.
|
||||
|
||||
@ -92,6 +92,9 @@ type TrackInfo struct {
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Scrobbler requires all methods to be implemented.
|
||||
|
||||
@ -89,6 +89,9 @@ type TrackInfo struct {
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
// Path is the full path to the track file, relative to the library root.
|
||||
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Scrobbler requires all methods to be implemented.
|
||||
|
||||
@ -102,6 +102,10 @@ pub struct TrackInfo {
|
||||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_release_track_id: String,
|
||||
/// Path is the full path to the track file, relative to the library root.
|
||||
/// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Error represents an error from a capability method.
|
||||
|
||||
@ -122,6 +122,10 @@ pub struct TrackInfo {
|
||||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_release_track_id: String,
|
||||
/// Path is the full path to the track file, relative to the library root.
|
||||
/// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Error represents an error from a capability method.
|
||||
|
||||
@ -80,7 +80,7 @@ func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *
|
||||
username := getUsernameFromContext(ctx)
|
||||
input := capabilities.NowPlayingRequest{
|
||||
Username: username,
|
||||
Track: mediaFileToTrackInfo(track),
|
||||
Track: mediaFileToTrackInfo(s.plugin, track),
|
||||
Position: int32(position),
|
||||
}
|
||||
|
||||
@ -93,7 +93,7 @@ func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobb
|
||||
username := getUsernameFromContext(ctx)
|
||||
input := capabilities.ScrobbleRequest{
|
||||
Username: username,
|
||||
Track: mediaFileToTrackInfo(&sc.MediaFile),
|
||||
Track: mediaFileToTrackInfo(s.plugin, &sc.MediaFile),
|
||||
Timestamp: sc.TimeStamp.Unix(),
|
||||
}
|
||||
|
||||
@ -109,9 +109,11 @@ func getUsernameFromContext(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo
|
||||
func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
|
||||
return capabilities.TrackInfo{
|
||||
// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo.
|
||||
// Path is populated only when the plugin is allowed filesystem access to the
|
||||
// track's library.
|
||||
func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo {
|
||||
ti := capabilities.TrackInfo{
|
||||
ID: mf.ID,
|
||||
Title: mf.Title,
|
||||
Album: mf.Album,
|
||||
@ -127,6 +129,10 @@ func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
|
||||
MBZReleaseGroupID: mf.MbzReleaseGroupID,
|
||||
MBZReleaseTrackID: mf.MbzReleaseTrackID,
|
||||
}
|
||||
if p.hasLibraryFilesystemAccess(mf.LibraryID) {
|
||||
ti.Path = mf.Path
|
||||
}
|
||||
return ti
|
||||
}
|
||||
|
||||
// participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef
|
||||
|
||||
@ -240,6 +240,40 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
Expect(names).ToNot(ContainElement("test-metadata-agent"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("mediaFileToTrackInfo", func() {
|
||||
var track *model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
track = &model.MediaFile{
|
||||
ID: "track-1",
|
||||
Title: "Test Song",
|
||||
Path: "/music/test.flac",
|
||||
LibraryID: 1,
|
||||
}
|
||||
})
|
||||
|
||||
fsManifest := &Manifest{
|
||||
Permissions: &Permissions{
|
||||
Library: &LibraryPermission{Filesystem: true},
|
||||
},
|
||||
}
|
||||
|
||||
It("includes Path when the plugin has filesystem access to the track's library", func() {
|
||||
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)}
|
||||
Expect(mediaFileToTrackInfo(p, track).Path).To(Equal("/music/test.flac"))
|
||||
})
|
||||
|
||||
It("omits Path when the plugin lacks filesystem permission", func() {
|
||||
p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)}
|
||||
Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("omits Path when the track's library is not in the allowed set", func() {
|
||||
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)}
|
||||
Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("mapScrobblerError", func() {
|
||||
|
||||
@ -43,8 +43,9 @@ FFMPEG_FILE="ffmpeg-n${FFMPEG_VERSION}-latest-${WIN_ARCH}-gpl-${FFMPEG_VERSION}"
|
||||
wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \
|
||||
"https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip"
|
||||
rm -rf "${DOWNLOAD_FOLDER}/extracted_ffmpeg"
|
||||
unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe"
|
||||
unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" "*/ffprobe.exe"
|
||||
cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUTPUT_DIR"
|
||||
cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffprobe.exe "$MSI_OUTPUT_DIR"
|
||||
|
||||
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
||||
cp "$BINARY" "$MSI_OUTPUT_DIR"
|
||||
|
||||
@ -67,6 +67,10 @@
|
||||
<File Id='ffmpeg.exe' Name='ffmpeg.exe' DiskId='1' Source='ffmpeg.exe' KeyPath='yes' />
|
||||
</Component>
|
||||
|
||||
<Component Id='FFProbeExecutable' Guid='f8a3b2c1-5d4e-4f6a-9b8c-7e2d1a0f3c5b' Win64="$(var.Win64)">
|
||||
<File Id='ffprobe.exe' Name='ffprobe.exe' DiskId='1' Source='ffprobe.exe' KeyPath='yes' />
|
||||
</Component>
|
||||
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
@ -87,6 +91,7 @@
|
||||
<ComponentRef Id='Configuration'/>
|
||||
<ComponentRef Id='MainExecutable' />
|
||||
<ComponentRef Id='FFMpegExecutable' />
|
||||
<ComponentRef Id='FFProbeExecutable' />
|
||||
<ComponentRef Id='PackageFile' />
|
||||
</Feature>
|
||||
</Product>
|
||||
|
||||
@ -36,7 +36,9 @@
|
||||
"bitDepth": "Bitprofundo",
|
||||
"sampleRate": "Elprena rapido",
|
||||
"missing": "Mankaj",
|
||||
"libraryName": "Biblioteko"
|
||||
"libraryName": "Biblioteko",
|
||||
"composer": "",
|
||||
"disc": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ludi Poste",
|
||||
@ -46,7 +48,8 @@
|
||||
"download": "Elŝuti",
|
||||
"playNext": "Ludu Poste",
|
||||
"info": "Akiri Informon",
|
||||
"showInPlaylist": "Montri en Ludlisto"
|
||||
"showInPlaylist": "Montri en Ludlisto",
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +331,82 @@
|
||||
"scanInProgress": "Skano progresas...",
|
||||
"noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"version": "Versio",
|
||||
"author": "Aŭtoro",
|
||||
"website": "Retejo",
|
||||
"permissions": "Permesoj",
|
||||
"enabled": "Ebligite",
|
||||
"status": "",
|
||||
"path": "Vojo",
|
||||
"lastError": "Eraro",
|
||||
"hasError": "Eraro",
|
||||
"updatedAt": "Ĝisdatigite",
|
||||
"createdAt": "",
|
||||
"configKey": "Ŝlosilo",
|
||||
"configValue": "",
|
||||
"allUsers": "",
|
||||
"selectedUsers": "",
|
||||
"allLibraries": "",
|
||||
"selectedLibraries": "",
|
||||
"allowWriteAccess": ""
|
||||
},
|
||||
"sections": {
|
||||
"status": "",
|
||||
"info": "",
|
||||
"configuration": "",
|
||||
"manifest": "",
|
||||
"usersPermission": "",
|
||||
"libraryPermission": ""
|
||||
},
|
||||
"status": {
|
||||
"enabled": "",
|
||||
"disabled": ""
|
||||
},
|
||||
"actions": {
|
||||
"enable": "",
|
||||
"disable": "",
|
||||
"disabledDueToError": "",
|
||||
"disabledUsersRequired": "",
|
||||
"disabledLibrariesRequired": "",
|
||||
"addConfig": "",
|
||||
"rescan": ""
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "",
|
||||
"disabled": "",
|
||||
"updated": "",
|
||||
"error": ""
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": ""
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "",
|
||||
"clickPermissions": "",
|
||||
"noConfig": "",
|
||||
"allUsersHelp": "",
|
||||
"noUsers": "",
|
||||
"permissionReason": "",
|
||||
"usersRequired": "",
|
||||
"allLibrariesHelp": "",
|
||||
"noLibraries": "",
|
||||
"librariesRequired": "",
|
||||
"requiredHosts": "",
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": "",
|
||||
"allowWriteAccessHelp": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "",
|
||||
"configValue": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +590,14 @@
|
||||
"remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn",
|
||||
"remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.",
|
||||
"noSimilarSongsFound": "Neniuj similaj kantoj trovitaj",
|
||||
"noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj"
|
||||
"noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj",
|
||||
"startingInstantMix": "",
|
||||
"uploadCover": "",
|
||||
"removeCover": "",
|
||||
"coverUploaded": "",
|
||||
"coverRemoved": "",
|
||||
"coverUploadError": "",
|
||||
"coverRemoveError": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteko",
|
||||
@ -597,7 +683,8 @@
|
||||
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
|
||||
"exportFailed": "Malsukcesis kopii agordojn",
|
||||
"devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
|
||||
"devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj"
|
||||
"devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj",
|
||||
"downloadToml": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"channels": "Kanalak",
|
||||
"disc": "%{discNumber}. diskoa",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
"starred": "Gogokoa",
|
||||
"comment": "Iruzkina",
|
||||
@ -355,7 +356,8 @@
|
||||
"allUsers": "Baimendu erabiltzaile guztiak",
|
||||
"selectedUsers": "Hautatutako erabiltzaileak",
|
||||
"allLibraries": "Baimendu liburutegi guztiak",
|
||||
"selectedLibraries": "Hautatutako liburutegiak"
|
||||
"selectedLibraries": "Hautatutako liburutegiak",
|
||||
"allowWriteAccess": "Eman idazteko baimena"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Egoera",
|
||||
@ -400,6 +402,7 @@
|
||||
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
||||
"noLibraries": "Ez da liburutegirik hautatu",
|
||||
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
|
||||
"allowWriteAccessHelp": "Gaituta dagoenean, pluginak liburutegien direktorioko fitxategiak moldatu ditzake. Defektuz, pluginek bakarrik irakurtzeko baimena dute.",
|
||||
"requiredHosts": "Beharrezko ostatatzaileak"
|
||||
},
|
||||
"placeholders": {
|
||||
@ -554,6 +557,12 @@
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"uploadCover": "Igo azala",
|
||||
"removeCover": "Kendu azala",
|
||||
"coverUploaded": "Diskoaren azala eguneratu da",
|
||||
"coverRemoved": "Diskoaren azala kendu da",
|
||||
"coverUploadError": "Errorea diskoaren azala igotzean",
|
||||
"coverRemoveError": "Errorea diskoaren azala kentzean",
|
||||
"note": "OHARRA",
|
||||
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
|
||||
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
||||
@ -673,6 +682,7 @@
|
||||
"currentValue": "Uneko balioa",
|
||||
"configurationFile": "Konfigurazio-fitxategia",
|
||||
"exportToml": "Esportatu konfigurazioa (TOML)",
|
||||
"downloadToml": "Deskargatu konfigurazioa (TOML)",
|
||||
"exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
|
||||
"exportFailed": "Konfigurazioa kopiatzeak huts egin du",
|
||||
"devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",
|
||||
|
||||
@ -37,7 +37,8 @@
|
||||
"sampleRate": "Sample waarde",
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek",
|
||||
"composer": ""
|
||||
"composer": "Componist",
|
||||
"disc": "Schijf %{discNumber}"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
@ -48,7 +49,7 @@
|
||||
"playNext": "Volgende",
|
||||
"info": "Meer info",
|
||||
"showInPlaylist": "Toon in afspeellijst",
|
||||
"instantMix": ""
|
||||
"instantMix": "Instant mix"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -350,10 +351,11 @@
|
||||
"createdAt": "Geinstalleerd",
|
||||
"configKey": "Sleutel",
|
||||
"configValue": "Waarde",
|
||||
"allUsers": "Alle gebruikers toelaten",
|
||||
"allUsers": "Sta toe voor alle gebruikers",
|
||||
"selectedUsers": "Geselecteerde gebruikers",
|
||||
"allLibraries": "Alle bibliotheken toestaan",
|
||||
"selectedLibraries": "Geselecteerde bibliotheken"
|
||||
"allLibraries": "Sta toe voor alle bibliotheken",
|
||||
"selectedLibraries": "Geselecteerde bibliotheken",
|
||||
"allowWriteAccess": "Sta schrijftoegang toe"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
@ -379,26 +381,27 @@
|
||||
"notifications": {
|
||||
"enabled": "Plugin actief",
|
||||
"disabled": "Plugin niet actief",
|
||||
"updated": "Plugin geupdate",
|
||||
"updated": "Plugin bijgewerkt",
|
||||
"error": "Fout bij updaten plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Configuratie moet geldige JSON zijn"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "",
|
||||
"configHelp": "Configureer de plug-in met key-value paren. Leeglaten als de plug-in niet geconfigueerd hoeft te worden.",
|
||||
"clickPermissions": "Klik op permissie voor details",
|
||||
"noConfig": "Geen configuratie ingesteld",
|
||||
"allUsersHelp": "",
|
||||
"allUsersHelp": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.",
|
||||
"noUsers": "Geen gebruikers geselecteerd",
|
||||
"permissionReason": "Reden",
|
||||
"usersRequired": "",
|
||||
"allLibrariesHelp": "",
|
||||
"usersRequired": "Deze plug-in heeft toegang nodig tot gebruikersinformatie. Selecteer welke gebruikers de plug-in toegang toe heeft, of schakel 'sta toe voor alle gebruikers' in.",
|
||||
"allLibrariesHelp": "Als dit aanstaat, heeft de plug-in toegang tot alle bibliotheken, inclusief toekomstige.",
|
||||
"noLibraries": "Geen bibliotheken geselecteerd",
|
||||
"librariesRequired": "",
|
||||
"librariesRequired": "Deze plug-in heeft toegang nodig tot bibliotheek informatie. Selecteer welke bibliotheken de plug-in toegang to heeft, of schakel 'sta toe voor alle bibliotheken' in.",
|
||||
"requiredHosts": "Benodigde hosts",
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": ""
|
||||
"configValidationError": "Configuratiecheck mislukt",
|
||||
"schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.",
|
||||
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "Sleutel",
|
||||
@ -588,7 +591,13 @@
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
|
||||
"noTopSongsFound": "Geen beste nummers gevonden",
|
||||
"startingInstantMix": ""
|
||||
"startingInstantMix": "Laden van Instant mix...",
|
||||
"uploadCover": "Albumhoes toevoegen",
|
||||
"removeCover": "Verwijder albumhoes",
|
||||
"coverUploaded": "Albumhoes bijgewerkt",
|
||||
"coverRemoved": "Albumhoes verwijderd",
|
||||
"coverUploadError": "Fout bij het toevoegen albumhoes",
|
||||
"coverRemoveError": "Fout bij verwijderen albumhoes"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
@ -674,7 +683,8 @@
|
||||
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
|
||||
"exportFailed": "Kopiëren van configuratie mislukt",
|
||||
"devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
|
||||
"devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd"
|
||||
"devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd",
|
||||
"downloadToml": "Download configuratie (TOML)"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"bitDepth": "位深度",
|
||||
"sampleRate": "采样率",
|
||||
"channels": "声道",
|
||||
"disc": "碟片 %{discNumber}",
|
||||
"discSubtitle": "碟片副标题",
|
||||
"starred": "收藏",
|
||||
"comment": "注释",
|
||||
@ -355,7 +356,8 @@
|
||||
"allUsers": "允许所有用户",
|
||||
"selectedUsers": "指定用户",
|
||||
"allLibraries": "允许所有媒体库",
|
||||
"selectedLibraries": "指定媒体库"
|
||||
"selectedLibraries": "指定媒体库",
|
||||
"allowWriteAccess": "允许写入权限"
|
||||
},
|
||||
"sections": {
|
||||
"status": "状态",
|
||||
@ -400,6 +402,7 @@
|
||||
"allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。",
|
||||
"noLibraries": "未选择媒体库",
|
||||
"librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。",
|
||||
"allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。",
|
||||
"requiredHosts": "必需的主机"
|
||||
},
|
||||
"placeholders": {
|
||||
@ -554,6 +557,12 @@
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"uploadCover": "上传封面",
|
||||
"removeCover": "移除封面",
|
||||
"coverUploaded": "封面已上传",
|
||||
"coverRemoved": "封面已移除",
|
||||
"coverUploadError": "上传封面时出错",
|
||||
"coverRemoveError": "移除封面时出错",
|
||||
"note": "注意",
|
||||
"transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。",
|
||||
"transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。",
|
||||
@ -673,6 +682,7 @@
|
||||
"currentValue": "当前值",
|
||||
"configurationFile": "配置文件",
|
||||
"exportToml": "导出配置(TOML)",
|
||||
"downloadToml": "下载配置(TOML)",
|
||||
"exportSuccess": "配置以 TOML 格式导出到剪贴板完成",
|
||||
"exportFailed": "复制配置失败",
|
||||
"devFlagsHeader": "开发标志(可能会更改/删除)",
|
||||
|
||||
@ -116,7 +116,7 @@ main:
|
||||
aliases: [ comm:description, comment, ©cmt, description, icmt ]
|
||||
maxLength: 4096
|
||||
originaldate:
|
||||
aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear ]
|
||||
aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear, origyear, ----:com.apple.itunes:origyear ]
|
||||
type: date
|
||||
recordingdate:
|
||||
aliases: [ tdrc, date, recordingdate, icrd, record date ]
|
||||
@ -202,6 +202,9 @@ main:
|
||||
# Additional tags. You can add new tags without the need to modify the code. They will be available as fields
|
||||
# for smart playlists
|
||||
additional:
|
||||
# Internal tag type, represents metadata tag(s) found in the file
|
||||
tags:
|
||||
aliases: [ __tags ]
|
||||
asin:
|
||||
aliases: [ txxx:asin, asin, ----:com.apple.itunes:asin ]
|
||||
barcode:
|
||||
|
||||
@ -172,6 +172,10 @@ func buildTestFS() storagetest.FakeFS {
|
||||
"title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus",
|
||||
"bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220),
|
||||
}),
|
||||
"Test/Transcode Formats/07 - TC FLAC Multichannel.flac": file(tcBase, _t{
|
||||
"title": "TC FLAC Multichannel", "track": 7, "suffix": "flac",
|
||||
"bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180),
|
||||
}),
|
||||
|
||||
// _empty folder (directory with no audio)
|
||||
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
|
||||
@ -337,6 +341,7 @@ func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (
|
||||
|
||||
func (n noopFFmpeg) CmdPath() (string, error) { return "", nil }
|
||||
func (n noopFFmpeg) IsAvailable() bool { return false }
|
||||
func (n noopFFmpeg) IsProbeAvailable() bool { return true }
|
||||
func (n noopFFmpeg) Version() string { return "noop" }
|
||||
|
||||
// noopArchiver implements core.Archiver
|
||||
|
||||
@ -117,7 +117,7 @@ var _ = Describe("Search Endpoints", func() {
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).To(HaveLen(6))
|
||||
Expect(resp.SearchResult3.Album).To(HaveLen(7))
|
||||
Expect(resp.SearchResult3.Song).To(HaveLen(13))
|
||||
Expect(resp.SearchResult3.Song).To(HaveLen(14))
|
||||
})
|
||||
|
||||
It("finds across all entity types simultaneously", func() {
|
||||
|
||||
@ -13,8 +13,9 @@ import (
|
||||
|
||||
var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
||||
var (
|
||||
mp3TrackID string // Come Together (mp3, 320kbps)
|
||||
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
||||
mp3TrackID string // Come Together (mp3, 320kbps)
|
||||
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
||||
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
@ -30,6 +31,8 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
||||
Expect(mp3TrackID).ToNot(BeEmpty())
|
||||
flacTrackID = byTitle["TC FLAC Standard"]
|
||||
Expect(flacTrackID).ToNot(BeEmpty())
|
||||
flacMultichTrackID = byTitle["TC FLAC Multichannel"]
|
||||
Expect(flacMultichTrackID).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
Describe("raw / direct play", func() {
|
||||
@ -101,6 +104,13 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
||||
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
||||
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
|
||||
})
|
||||
|
||||
It("clamps multichannel FLAC to 2 channels when transcoding to mp3 (#5336)", func() {
|
||||
w := doRawReq("stream", "id", flacMultichTrackID, "format", "mp3", "maxBitRate", "256")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
||||
Expect(streamerSpy.LastRequest.Channels).To(Equal(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("downsampling with maxBitRate only", func() {
|
||||
|
||||
@ -114,13 +114,14 @@ const (
|
||||
var _ = Describe("Transcode Endpoints", Ordered, func() {
|
||||
// Track IDs resolved in BeforeAll
|
||||
var (
|
||||
mp3TrackID string // Come Together (mp3, 320kbps)
|
||||
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
||||
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
|
||||
alacTrackID string // TC ALAC Track (m4a, alac)
|
||||
dsdTrackID string // TC DSD Track (dsf, dsd)
|
||||
opusTrackID string // TC Opus Track (opus, 128kbps)
|
||||
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
|
||||
mp3TrackID string // Come Together (mp3, 320kbps)
|
||||
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
||||
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
|
||||
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
|
||||
alacTrackID string // TC ALAC Track (m4a, alac)
|
||||
dsdTrackID string // TC DSD Track (dsf, dsd)
|
||||
opusTrackID string // TC Opus Track (opus, 128kbps)
|
||||
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
@ -140,6 +141,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
|
||||
mp3TrackID = ensureGetTrackID("Come Together")
|
||||
flacTrackID = ensureGetTrackID("TC FLAC Standard")
|
||||
flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes")
|
||||
flacMultichTrackID = ensureGetTrackID("TC FLAC Multichannel")
|
||||
alacTrackID = ensureGetTrackID("TC ALAC Track")
|
||||
dsdTrackID = ensureGetTrackID("TC DSD Track")
|
||||
opusTrackID = ensureGetTrackID("TC Opus Track")
|
||||
@ -353,6 +355,19 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
|
||||
// maxTranscodingAudioBitrate is 192000 bps = 192 kbps → response in bps
|
||||
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
||||
})
|
||||
|
||||
It("clamps multichannel FLAC to 2 channels when transcoding to MP3 (#5336)", func() {
|
||||
// mp3OnlyClient has no MaxAudioChannels set, so this exercises the
|
||||
// codec-intrinsic clamp in core/stream/codec.go (codecMaxChannels).
|
||||
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacMultichTrackID, "mediaType", "song")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||
Expect(resp.TranscodeDecision.SourceStream.AudioChannels).To(Equal(int32(6)))
|
||||
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3"))
|
||||
Expect(resp.TranscodeDecision.TranscodeStream.AudioChannels).To(Equal(int32(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("response structure", func() {
|
||||
|
||||
@ -68,13 +68,16 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error {
|
||||
func checkFFmpegInstallation() {
|
||||
f := ffmpeg.New()
|
||||
_, err := f.CmdPath()
|
||||
if err == nil {
|
||||
if err != nil {
|
||||
log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err)
|
||||
if conf.Server.Scanner.Extractor == "ffmpeg" {
|
||||
log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib")
|
||||
conf.Server.Scanner.Extractor = "taglib"
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err)
|
||||
if conf.Server.Scanner.Extractor == "ffmpeg" {
|
||||
log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib")
|
||||
conf.Server.Scanner.Extractor = "taglib"
|
||||
if !f.IsProbeAvailable() {
|
||||
log.Warn("Unable to find ffprobe. Transcoding decisions will be limited")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/publicurl"
|
||||
@ -81,7 +82,7 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s
|
||||
|
||||
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
|
||||
s.URL = ShareURL(r, s.ID)
|
||||
s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), consts.UICoverArtSize)
|
||||
s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize)
|
||||
for i := range s.Tracks {
|
||||
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
|
||||
}
|
||||
|
||||
@ -55,6 +55,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"defaultLanguage": conf.Server.DefaultLanguage,
|
||||
"defaultUIVolume": conf.Server.DefaultUIVolume,
|
||||
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
||||
"uiCoverArtSize": conf.Server.UICoverArtSize,
|
||||
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
||||
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
||||
"gaTrackingId": conf.Server.GATrackingID,
|
||||
|
||||
@ -86,6 +86,7 @@ var _ = Describe("serveIndex", func() {
|
||||
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
||||
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
||||
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
||||
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
|
||||
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
||||
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
||||
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@ -17,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"
|
||||
@ -215,7 +217,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
child.Path = fakePath(mf)
|
||||
}
|
||||
child.DiscNumber = int32(mf.DiscNumber)
|
||||
child.Created = &mf.BirthTime
|
||||
child.Created = P(mf.BirthTime)
|
||||
child.AlbumId = mf.AlbumID
|
||||
child.ArtistId = mf.ArtistID
|
||||
child.Type = "music"
|
||||
@ -317,6 +319,20 @@ func sanitizeSlashes(target string) string {
|
||||
return strings.ReplaceAll(target, "/", "_")
|
||||
}
|
||||
|
||||
// albumCreatedAt returns a best-effort timestamp for the album's `created`
|
||||
// field, which is required by the OpenSubsonic spec but may be zero on legacy
|
||||
// DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all
|
||||
// three are unset.
|
||||
func albumCreatedAt(al model.Album) time.Time {
|
||||
if !al.CreatedAt.IsZero() {
|
||||
return al.CreatedAt
|
||||
}
|
||||
if !al.UpdatedAt.IsZero() {
|
||||
return al.UpdatedAt
|
||||
}
|
||||
return al.ImportedAt
|
||||
}
|
||||
|
||||
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = al.ID
|
||||
@ -329,7 +345,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
||||
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
|
||||
child.Genre = al.Genre
|
||||
child.CoverArt = al.CoverArtID().String()
|
||||
child.Created = &al.CreatedAt
|
||||
child.Created = P(albumCreatedAt(al))
|
||||
child.Parent = al.AlbumArtistID
|
||||
child.ArtistId = al.AlbumArtistID
|
||||
child.Duration = int32(al.Duration)
|
||||
@ -391,9 +407,12 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle {
|
||||
return nil
|
||||
}
|
||||
var discTitles []responses.DiscTitle
|
||||
// Hoist UpdatedAt to a single stack-local so &updatedAt doesn't force the
|
||||
// whole model.Album parameter onto the heap.
|
||||
updatedAt := a.UpdatedAt
|
||||
for num, title := range a.Discs {
|
||||
artID := model.NewArtworkID(model.KindDiscArtwork,
|
||||
model.DiscArtworkID(a.ID, num), &a.UpdatedAt)
|
||||
model.DiscArtworkID(a.ID, num), &updatedAt)
|
||||
discTitles = append(discTitles, responses.DiscTitle{
|
||||
Disc: int32(num),
|
||||
Title: title,
|
||||
@ -421,9 +440,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
||||
dir.PlayCount = album.PlayCount
|
||||
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
|
||||
dir.Genre = album.Genre
|
||||
if !album.CreatedAt.IsZero() {
|
||||
dir.Created = &album.CreatedAt
|
||||
}
|
||||
dir.Created = P(albumCreatedAt(album))
|
||||
if album.Starred {
|
||||
dir.Starred = album.StarredAt
|
||||
}
|
||||
|
||||
@ -571,6 +571,38 @@ var _ = Describe("helpers", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("buildAlbumID3 Created field", func() {
|
||||
It("uses CreatedAt when set", func() {
|
||||
t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
al := model.Album{ID: "a1", Name: "A", CreatedAt: t}
|
||||
dir := buildAlbumID3(ctx, al)
|
||||
Expect(dir.Created).ToNot(BeNil())
|
||||
Expect(*dir.Created).To(Equal(t))
|
||||
})
|
||||
|
||||
It("falls back to UpdatedAt when CreatedAt is zero", func() {
|
||||
updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC)
|
||||
al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated}
|
||||
dir := buildAlbumID3(ctx, al)
|
||||
Expect(dir.Created).ToNot(BeNil())
|
||||
Expect(*dir.Created).To(Equal(updated))
|
||||
})
|
||||
|
||||
It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() {
|
||||
imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC)
|
||||
al := model.Album{ID: "a3", Name: "A", ImportedAt: imported}
|
||||
dir := buildAlbumID3(ctx, al)
|
||||
Expect(dir.Created).ToNot(BeNil())
|
||||
Expect(*dir.Created).To(Equal(imported))
|
||||
})
|
||||
|
||||
It("never leaves Created nil even when all timestamps are zero", func() {
|
||||
al := model.Album{ID: "a4", Name: "A"}
|
||||
dir := buildAlbumID3(ctx, al)
|
||||
Expect(dir.Created).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnableAverageRating config", func() {
|
||||
It("excludes averageRating when disabled", func() {
|
||||
conf.Server.Subsonic.EnableAverageRating = false
|
||||
|
||||
BIN
tests/fixtures/01 Invisible (RED) Edit Version.m4a
vendored
BIN
tests/fixtures/01 Invisible (RED) Edit Version.m4a
vendored
Binary file not shown.
BIN
tests/fixtures/ape-id3v1.wv
vendored
Normal file
BIN
tests/fixtures/ape-id3v1.wv
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/ape-v1-v2.mp3
vendored
Normal file
BIN
tests/fixtures/ape-v1-v2.mp3
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/empty.mp3
vendored
Normal file
BIN
tests/fixtures/empty.mp3
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/empty.wav
vendored
Normal file
BIN
tests/fixtures/empty.wav
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/test.aiff
vendored
BIN
tests/fixtures/test.aiff
vendored
Binary file not shown.
BIN
tests/fixtures/test.flac
vendored
BIN
tests/fixtures/test.flac
vendored
Binary file not shown.
BIN
tests/fixtures/test.m4a
vendored
BIN
tests/fixtures/test.m4a
vendored
Binary file not shown.
BIN
tests/fixtures/test.mp3
vendored
BIN
tests/fixtures/test.mp3
vendored
Binary file not shown.
BIN
tests/fixtures/test.ogg
vendored
BIN
tests/fixtures/test.ogg
vendored
Binary file not shown.
BIN
tests/fixtures/test.opus
vendored
BIN
tests/fixtures/test.opus
vendored
Binary file not shown.
BIN
tests/fixtures/test.wav
vendored
BIN
tests/fixtures/test.wav
vendored
Binary file not shown.
BIN
tests/fixtures/test.wma
vendored
BIN
tests/fixtures/test.wma
vendored
Binary file not shown.
BIN
tests/fixtures/test.wv
vendored
BIN
tests/fixtures/test.wv
vendored
Binary file not shown.
BIN
tests/fixtures/vorbis-id3v1-id3v2.flac
vendored
Normal file
BIN
tests/fixtures/vorbis-id3v1-id3v2.flac
vendored
Normal file
Binary file not shown.
@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func NewMockFFmpeg(data string) *MockFFmpeg {
|
||||
return &MockFFmpeg{Reader: strings.NewReader(data)}
|
||||
return &MockFFmpeg{Reader: strings.NewReader(data), ProbeAvailable: true}
|
||||
}
|
||||
|
||||
type MockFFmpeg struct {
|
||||
@ -21,12 +21,17 @@ type MockFFmpeg struct {
|
||||
closed atomic.Bool
|
||||
Error error
|
||||
ProbeAudioResult *ffmpeg.AudioProbeResult
|
||||
ProbeAvailable bool
|
||||
}
|
||||
|
||||
func (ff *MockFFmpeg) IsAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (ff *MockFFmpeg) IsProbeAvailable() bool {
|
||||
return ff.ProbeAvailable
|
||||
}
|
||||
|
||||
func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) {
|
||||
if ff.Error != nil {
|
||||
return nil, ff.Error
|
||||
|
||||
2757
ui/package-lock.json
generated
2757
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -77,7 +77,7 @@
|
||||
"prettier": "^3.6.2",
|
||||
"ra-test": "^3.19.12",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.1.12",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vitest": "^4.0.3"
|
||||
},
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import config from '../config'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import subsonic from '../subsonic'
|
||||
import {
|
||||
@ -32,7 +32,6 @@ import {
|
||||
useAlbumsPerPage,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
import AlbumExternalLinks from './AlbumExternalLinks'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
@ -255,7 +254,7 @@ const AlbumDetails = (props) => {
|
||||
})
|
||||
}, [record])
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE)
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
return (
|
||||
|
||||
@ -20,7 +20,8 @@ import {
|
||||
OverflowTooltip,
|
||||
useImageUrl,
|
||||
} from '../common'
|
||||
import { COVER_ART_SIZE, DraggableTypes } from '../consts'
|
||||
import config from '../config'
|
||||
import { DraggableTypes } from '../consts'
|
||||
import clsx from 'clsx'
|
||||
import { AlbumDatesField } from './AlbumDatesField.jsx'
|
||||
|
||||
@ -135,7 +136,7 @@ const Cover = withContentRect('bounds')(({
|
||||
[record],
|
||||
)
|
||||
|
||||
const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)
|
||||
const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
|
||||
const { imgUrl, loading: imageLoading } = useImageUrl(url)
|
||||
|
||||
return (
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import AlbumInfo from '../album/AlbumInfo'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
||||
@ -110,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
|
||||
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
||||
@ -113,7 +112,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
|
||||
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
|
||||
@ -2,7 +2,7 @@ import { useRecordContext } from 'react-admin'
|
||||
import { Avatar } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import clsx from 'clsx'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import config from '../config'
|
||||
import subsonic from '../subsonic'
|
||||
import { useImageUrl } from './useImageUrl'
|
||||
|
||||
@ -28,7 +28,7 @@ export const CoverArtAvatar = ({
|
||||
const record = recordProp || recordContext
|
||||
const square = variant !== 'circular'
|
||||
const url = record
|
||||
? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)
|
||||
? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square)
|
||||
: null
|
||||
const { imgUrl } = useImageUrl(url)
|
||||
if (!record) return null
|
||||
|
||||
@ -21,6 +21,7 @@ const defaultConfig = {
|
||||
defaultLanguage: '',
|
||||
defaultUIVolume: 100,
|
||||
uiSearchDebounceMs: 200,
|
||||
uiCoverArtSize: 600,
|
||||
enableUserEditing: true,
|
||||
enableArtworkUpload: true,
|
||||
enableSharing: true,
|
||||
|
||||
@ -26,8 +26,6 @@ DraggableTypes.ALL.push(
|
||||
|
||||
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
|
||||
|
||||
export const COVER_ART_SIZE = 600
|
||||
|
||||
export const DEFAULT_SHARE_BITRATE = 128
|
||||
|
||||
export const BITRATE_CHOICES = [
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
OverflowTooltip,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import { COVER_ART_SIZE } from '../consts'
|
||||
import config from '../config'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
@ -107,7 +107,7 @@ const PlaylistDetails = (props) => {
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user