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$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
formatters:
|
formatters:
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
@ -62,3 +63,4 @@ formatters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
|
|||||||
4
Makefile
4
Makefile
@ -1,6 +1,8 @@
|
|||||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||||
NODE_VERSION=$(shell cat .nvmrc)
|
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
|
# Set global environment variables, required for most targets
|
||||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
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("albumartist", []string{"Album Artist"}))
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
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(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
Expect(m.Tags).To(Or(
|
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 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() {
|
char* taglib_version() {
|
||||||
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
|
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
|
||||||
return (char *)TAGLIB_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;
|
TagLib::ID3v2::Tag *id3Tags = NULL;
|
||||||
|
bool has_tag = false;
|
||||||
|
|
||||||
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
||||||
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
||||||
if (mp3File != NULL) {
|
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) {
|
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
|
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
|
||||||
// with many players, so they will not be parsed
|
// with many players, so they will not be parsed
|
||||||
|
|
||||||
if (id3Tags != NULL) {
|
if (id3Tags != NULL) {
|
||||||
const auto &frames = id3Tags->frameListMap();
|
const auto &frames = id3Tags->frameListMap();
|
||||||
|
|
||||||
|
goPutTagType(id, ID3V2_TAG);
|
||||||
|
has_tag = true;
|
||||||
|
|
||||||
for (const auto &kv: frames) {
|
for (const auto &kv: frames) {
|
||||||
if (kv.first == "USLT") {
|
if (kv.first == "USLT") {
|
||||||
for (const auto &tag: kv.second) {
|
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
|
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
|
||||||
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
|
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
|
||||||
if (m4afile != NULL) {
|
if (m4afile != NULL) {
|
||||||
const auto itemListMap = m4afile->tag()->itemMap();
|
if (m4afile->hasMP4Tag()) {
|
||||||
for (const auto item: itemListMap) {
|
goPutTagType(id, MP4_TAG);
|
||||||
char *key = const_cast<char*>(item.first.toCString(true));
|
has_tag = true;
|
||||||
for (const auto value: item.second.toStringList()) {
|
|
||||||
char *val = const_cast<char*>(value.toCString(true));
|
const auto itemListMap = m4afile->tag()->itemMap();
|
||||||
goPutM4AStr(id, key, val);
|
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()));
|
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
|
||||||
if (asfFile != NULL) {
|
if (asfFile != NULL) {
|
||||||
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
|
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();
|
if (asfTags != NULL) {
|
||||||
j != item.second.end(); ++j) {
|
goPutTagType(id, ASF_TAG);
|
||||||
|
has_tag = true;
|
||||||
|
|
||||||
char *val = const_cast<char*>(j->toString().toCString(true));
|
const auto itemListMap = asfTags->attributeListMap();
|
||||||
goPutStr(id, key, val);
|
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");
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -155,3 +155,8 @@ func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
|
|||||||
m[k] = []string{formattedLine}
|
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 goPutInt(unsigned long id, char *key, int val);
|
||||||
extern void goPutLyrics(unsigned long id, char *lang, char *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 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);
|
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
|
||||||
char* taglib_version();
|
char* taglib_version();
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,7 @@ type configOptions struct {
|
|||||||
MPVCmdTemplate string
|
MPVCmdTemplate string
|
||||||
CoverArtPriority string
|
CoverArtPriority string
|
||||||
CoverArtQuality int
|
CoverArtQuality int
|
||||||
|
EnableWebPEncoding bool
|
||||||
ArtistArtPriority string
|
ArtistArtPriority string
|
||||||
ArtistImageFolder string
|
ArtistImageFolder string
|
||||||
DiscArtPriority string
|
DiscArtPriority string
|
||||||
@ -87,6 +88,7 @@ type configOptions struct {
|
|||||||
DefaultLanguage string
|
DefaultLanguage string
|
||||||
DefaultUIVolume int
|
DefaultUIVolume int
|
||||||
UISearchDebounceMs int
|
UISearchDebounceMs int
|
||||||
|
UICoverArtSize int
|
||||||
EnableReplayGain bool
|
EnableReplayGain bool
|
||||||
EnableCoverAnimation bool
|
EnableCoverAnimation bool
|
||||||
EnableNowPlaying bool
|
EnableNowPlaying bool
|
||||||
@ -141,7 +143,6 @@ type configOptions struct {
|
|||||||
DevOptimizeDB bool
|
DevOptimizeDB bool
|
||||||
DevPreserveUnicodeInExternalCalls bool
|
DevPreserveUnicodeInExternalCalls bool
|
||||||
DevEnableMediaFileProbe bool
|
DevEnableMediaFileProbe bool
|
||||||
DevJpegCoverArt bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
@ -424,6 +425,13 @@ func Load(noConfigDump bool) {
|
|||||||
// Removed options
|
// Removed options
|
||||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
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
|
// Call init hooks
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
hook()
|
hook()
|
||||||
@ -716,6 +724,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
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("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||||
viper.SetDefault("coverartquality", 75)
|
viper.SetDefault("coverartquality", 75)
|
||||||
|
viper.SetDefault("enablewebpencoding", false)
|
||||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||||
viper.SetDefault("artistimagefolder", "")
|
viper.SetDefault("artistimagefolder", "")
|
||||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||||
@ -728,6 +737,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("defaultlanguage", "")
|
viper.SetDefault("defaultlanguage", "")
|
||||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||||
|
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
||||||
viper.SetDefault("enablereplaygain", true)
|
viper.SetDefault("enablereplaygain", true)
|
||||||
viper.SetDefault("enablecoveranimation", true)
|
viper.SetDefault("enablecoveranimation", true)
|
||||||
viper.SetDefault("enablenowplaying", true)
|
viper.SetDefault("enablenowplaying", true)
|
||||||
@ -810,7 +820,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("devuishowconfig", true)
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devneweventstream", true)
|
viper.SetDefault("devneweventstream", true)
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
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("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||||
@ -826,7 +836,6 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("devoptimizedb", true)
|
viper.SetDefault("devoptimizedb", true)
|
||||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||||
viper.SetDefault("devenablemediafileprobe", true)
|
viper.SetDefault("devenablemediafileprobe", true)
|
||||||
viper.SetDefault("devjpegcoverart", false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@ -85,11 +85,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
UICoverArtSize = 600
|
DefaultUICoverArtSize = 300
|
||||||
)
|
)
|
||||||
|
|
||||||
var CacheWarmerImageSizes = []int{UICoverArtSize}
|
|
||||||
|
|
||||||
// Prometheus options
|
// Prometheus options
|
||||||
const (
|
const (
|
||||||
PrometheusDefaultPath = "/metrics"
|
PrometheusDefaultPath = "/metrics"
|
||||||
|
|||||||
@ -380,24 +380,24 @@ var _ = Describe("Artwork", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
When("Square is false", 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"
|
conf.Server.CoverArtPriority = "front.png"
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
img, format, err := image.Decode(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
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().X).To(Equal(15))
|
||||||
Expect(img.Bounds().Size().Y).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"
|
conf.Server.CoverArtPriority = "cover.jpg"
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
img, format, err := image.Decode(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(format).To(Equal("webp"))
|
Expect(format).To(Equal("jpeg"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||||
Expect(img.Bounds().Size().Y).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().X).To(Equal(size))
|
||||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||||
},
|
},
|
||||||
Entry("portrait png image", "png", "webp", false, 200),
|
Entry("portrait png image", "png", "png", false, 200),
|
||||||
Entry("landscape png image", "png", "webp", true, 200),
|
Entry("landscape png image", "png", "png", true, 200),
|
||||||
Entry("portrait jpg image", "jpg", "webp", false, 200),
|
Entry("portrait jpg image", "jpg", "png", false, 200),
|
||||||
Entry("landscape jpg image", "jpg", "webp", true, 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() {
|
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"
|
conf.Server.CoverArtPriority = "front.png"
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
img, format, err := image.Decode(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
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().X).To(Equal(15))
|
||||||
Expect(img.Bounds().Size().Y).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))
|
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
|
var alCover model.Album
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.DevJpegCoverArt = true
|
conf.Server.EnableWebPEncoding = false
|
||||||
})
|
})
|
||||||
It("returns PNG for square mode", func() {
|
It("returns PNG for square mode", func() {
|
||||||
dirName := createImage("png", false, 200)
|
dirName := createImage("png", false, 200)
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"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
|
// 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
|
// 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 {
|
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||||
// If image cache is disabled, return a NOOP implementation
|
// If image cache is disabled, return a NOOP implementation
|
||||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||||
@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a := &cacheWarmer{
|
a := &cacheWarmer{
|
||||||
artwork: artwork,
|
artwork: artwork,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
buffer: make(map[model.ArtworkID]struct{}),
|
buffer: make(map[model.ArtworkID]struct{}),
|
||||||
wakeSignal: make(chan struct{}, 1),
|
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
|
// 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 {
|
type cacheWarmer struct {
|
||||||
artwork Artwork
|
artwork Artwork
|
||||||
buffer map[model.ArtworkID]struct{}
|
buffer map[model.ArtworkID]struct{}
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
cache cache.FileCache
|
cache cache.FileCache
|
||||||
wakeSignal chan struct{}
|
wakeSignal chan struct{}
|
||||||
|
coverArtSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
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)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
for _, size := range consts.CacheWarmerImageSizes {
|
size := a.coverArtSize
|
||||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||||
}
|
|
||||||
_, err = io.Copy(io.Discard, r)
|
|
||||||
r.Close()
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
_, err = io.Copy(io.Discard, r)
|
||||||
|
r.Close()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func NoopCacheWarmer() CacheWarmer {
|
func NoopCacheWarmer() CacheWarmer {
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() {
|
|||||||
|
|
||||||
Eventually(func() []int {
|
Eventually(func() []int {
|
||||||
return aw.getCachedSizes()
|
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 {
|
func (a *albumArtworkReader) Key() string {
|
||||||
hashInput := conf.Server.CoverArtPriority
|
hashInput := conf.Server.CoverArtPriority
|
||||||
if conf.Server.EnableExternalServices {
|
if conf.Server.EnableExternalServices {
|
||||||
hashInput += conf.Server.Agents
|
hashInput = conf.Server.Agents + hashInput
|
||||||
}
|
}
|
||||||
hash := md5.Sum([]byte(hashInput))
|
hash := md5.Sum([]byte(hashInput))
|
||||||
return fmt.Sprintf(
|
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.
|
// globMetaChars holds the substitution metacharacters understood by
|
||||||
// It finds the portion of the filename that the wildcard matched and parses leading
|
// filepath.Match. The '\' escape character is intentionally excluded:
|
||||||
// digits as the disc number. Returns (0, false) if the pattern doesn't match or
|
// disc art patterns come from user config and never include escaped
|
||||||
// no leading digits are found in the wildcard portion.
|
// 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) {
|
func extractDiscNumber(pattern, filename string) (int, bool) {
|
||||||
filename = strings.ToLower(filename)
|
metaIdx := strings.IndexAny(pattern, globMetaChars)
|
||||||
pattern = strings.ToLower(pattern)
|
if metaIdx < 0 {
|
||||||
|
|
||||||
matched, err := filepath.Match(pattern, filename)
|
|
||||||
if err != nil || !matched {
|
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
prefix := pattern[:metaIdx]
|
||||||
// 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
|
|
||||||
if !strings.HasPrefix(filename, prefix) {
|
if !strings.HasPrefix(filename, prefix) {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
remainder := filename[len(prefix):]
|
|
||||||
|
|
||||||
// Extract leading ASCII digits from the remainder
|
start := len(prefix)
|
||||||
var digits []byte
|
end := start
|
||||||
for _, r := range remainder {
|
for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
|
||||||
if r >= '0' && r <= '9' {
|
end++
|
||||||
digits = append(digits, byte(r))
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if end == start {
|
||||||
if len(digits) == 0 {
|
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
num, err := strconv.Atoi(filename[start:end])
|
||||||
num, err := strconv.Atoi(string(digits))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false
|
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
|
// fromExternalFile returns a sourceFunc that matches image files against a glob
|
||||||
// pattern with disc-number-aware filtering.
|
// pattern. A numbered filename whose number equals the target disc wins over
|
||||||
//
|
// any unnumbered candidate; callers must pass a lowercase pattern.
|
||||||
// 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).
|
|
||||||
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
|
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
|
||||||
|
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
|
||||||
return func() (io.ReadCloser, string, error) {
|
return func() (io.ReadCloser, string, error) {
|
||||||
|
var fallbacks []string
|
||||||
for _, file := range d.imgFiles {
|
for _, file := range d.imgFiles {
|
||||||
_, name := filepath.Split(file)
|
_, 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 {
|
if err != nil {
|
||||||
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
||||||
continue
|
continue
|
||||||
@ -238,24 +225,27 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract disc number from filename
|
if !isLiteral {
|
||||||
num, hasNum := extractDiscNumber(pattern, name)
|
if num, hasNum := extractDiscNumber(pattern, name); hasNum {
|
||||||
if hasNum {
|
if num != d.discNumber {
|
||||||
// File has a disc number — must match target disc
|
continue
|
||||||
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)
|
f, err := os.Open(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
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)
|
// Case insensitive (filename already lowered by caller)
|
||||||
Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
|
Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
|
||||||
|
|
||||||
// Pattern doesn't match
|
// HasPrefix guard: filename doesn't share the pattern's literal prefix
|
||||||
Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false),
|
Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false),
|
||||||
|
|
||||||
// Pattern with no wildcard before dot
|
// Pattern with no wildcard before dot
|
||||||
Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
|
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))
|
Expect(path).To(Equal(f1))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("skips file without number in single-folder album", func() {
|
It("matches file without number in single-folder album (shared disc art)", func() {
|
||||||
f1 := createFile("album/disc.jpg")
|
f1 := createFile("album/cover.png")
|
||||||
reader := &discArtworkReader{
|
reader := &discArtworkReader{
|
||||||
discNumber: 1,
|
discNumber: 1,
|
||||||
imgFiles: []string{f1},
|
imgFiles: []string{f1},
|
||||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||||
}
|
}
|
||||||
|
|
||||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
sf := reader.fromExternalFile(ctx, "cover.*")
|
||||||
r, _, _ := sf()
|
r, path, err := sf()
|
||||||
Expect(r).To(BeNil())
|
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() {
|
It("matches file without number in multi-folder album by folder", func() {
|
||||||
f1 := createFile("album/cd1/disc.jpg")
|
f1 := createFile("album/cd1/disc.jpg")
|
||||||
f2 := createFile("album/cd2/disc.jpg")
|
f2 := createFile("album/cd2/disc.jpg")
|
||||||
|
|||||||
@ -19,6 +19,16 @@ import (
|
|||||||
xdraw "golang.org/x/image/draw"
|
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{
|
var bufPool = sync.Pool{
|
||||||
New: func() any {
|
New: func() any {
|
||||||
return new(bytes.Buffer)
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
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 := bufPool.Get().(*bytes.Buffer)
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
if conf.Server.DevJpegCoverArt {
|
if conf.Server.EnableWebPEncoding {
|
||||||
if square {
|
|
||||||
err = png.Encode(buf, dst)
|
|
||||||
} else {
|
|
||||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
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 {
|
if err != nil {
|
||||||
bufPool.Put(buf)
|
bufPool.Put(buf)
|
||||||
|
|||||||
@ -49,6 +49,7 @@ type FFmpeg interface {
|
|||||||
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
||||||
CmdPath() (string, error)
|
CmdPath() (string, error)
|
||||||
IsAvailable() bool
|
IsAvailable() bool
|
||||||
|
IsProbeAvailable() bool
|
||||||
Version() string
|
Version() string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,6 +225,19 @@ func (e *ffmpeg) IsAvailable() bool {
|
|||||||
return err == nil
|
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.
|
// Version executes ffmpeg -version and extracts the version from the output.
|
||||||
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
||||||
func (e *ffmpeg) Version() string {
|
func (e *ffmpeg) Version() string {
|
||||||
@ -373,18 +387,7 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
|||||||
if opts.BitRate > 0 {
|
if opts.BitRate > 0 {
|
||||||
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
|
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
|
||||||
}
|
}
|
||||||
if opts.SampleRate > 0 {
|
args = injectDynamicAudioFlags(args, opts)
|
||||||
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 = append(args, "-v", "0")
|
args = append(args, "-v", "0")
|
||||||
|
|
||||||
@ -398,12 +401,19 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
|||||||
|
|
||||||
// buildTemplateArgs handles user-customized command templates, with dynamic injection
|
// buildTemplateArgs handles user-customized command templates, with dynamic injection
|
||||||
// of sample rate, channels, and bit depth when requested by the transcode decision.
|
// 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
|
// Values in opts have already been clamped to codec limits upstream (see
|
||||||
// already includes them. FFmpeg uses the last occurrence of duplicate flags.
|
// 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 {
|
func buildTemplateArgs(opts TranscodeOptions) []string {
|
||||||
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
|
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 {
|
if opts.SampleRate > 0 {
|
||||||
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||||
}
|
}
|
||||||
@ -533,4 +543,6 @@ var (
|
|||||||
ffOnce sync.Once
|
ffOnce sync.Once
|
||||||
ffmpegPath string
|
ffmpegPath string
|
||||||
ffmpegErr error
|
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.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
||||||
data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload
|
data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload
|
||||||
data.Config.CoverArtQuality = conf.Server.CoverArtQuality
|
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.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||||
|
|||||||
@ -65,6 +65,8 @@ type Data struct {
|
|||||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||||
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
||||||
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
||||||
|
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||||
|
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||||
SessionTimeout uint64 `json:"sessionTimeout,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.Scheme = shareUrl.Scheme
|
||||||
buildUrl.Host = shareUrl.Host
|
buildUrl.Host = shareUrl.Host
|
||||||
|
if basePath := strings.TrimRight(shareUrl.Path, "/"); basePath != "" {
|
||||||
|
buildUrl.Path = path.Join(basePath, buildUrl.Path)
|
||||||
|
}
|
||||||
if len(params) > 0 {
|
if len(params) > 0 {
|
||||||
buildUrl.RawQuery = params.Encode()
|
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() {
|
When("ShareURL is not set", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.ShareURL = ""
|
conf.Server.ShareURL = ""
|
||||||
|
|||||||
@ -75,3 +75,16 @@ func codecMaxSampleRate(codec string) int {
|
|||||||
}
|
}
|
||||||
return 0
|
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"))
|
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
|
var probe *ffmpeg.AudioProbeResult
|
||||||
if !opts.SkipProbe {
|
if !opts.SkipProbe {
|
||||||
var err error
|
if !s.ff.IsProbeAvailable() {
|
||||||
probe, err = s.ensureProbed(ctx, mf)
|
log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID)
|
||||||
if err != nil {
|
} else {
|
||||||
return nil, err
|
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
|
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),
|
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
||||||
// or a typed reason string if it doesn't match.
|
// or a typed reason string if it doesn't match.
|
||||||
func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
|
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
|
// Check container
|
||||||
if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
|
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
|
// Check codec
|
||||||
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) {
|
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) {
|
||||||
return "audio codec not supported"
|
return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check channels
|
// Check channels
|
||||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
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
|
// 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 {
|
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
|
||||||
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)
|
// Determine target bitrate (all in kbps)
|
||||||
if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply MaxAudioChannels from the transcoding profile
|
// Apply MaxAudioChannels from the transcoding profile. Compare against the
|
||||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
// 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
|
ts.Channels = profile.MaxAudioChannels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,10 @@ var _ = Describe("Decider", func() {
|
|||||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
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() {
|
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{})
|
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
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() {
|
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{})
|
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
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() {
|
It("handles container aliases (aac -> m4a)", func() {
|
||||||
@ -216,7 +259,10 @@ var _ = Describe("Decider", func() {
|
|||||||
Expect(decision.CanTranscode).To(BeTrue())
|
Expect(decision.CanTranscode).To(BeTrue())
|
||||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||||
Expect(decision.TargetBitrate).To(Equal(256)) // kbps
|
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() {
|
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() {
|
Context("Probe-based lossless detection", func() {
|
||||||
It("uses probe codec name for lossless detection", func() {
|
It("uses probe codec name for lossless detection", func() {
|
||||||
// WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"
|
// WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"
|
||||||
@ -901,9 +1014,12 @@ var _ = Describe("Decider", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||||
Expect(decision.TranscodeReasons).To(HaveLen(3))
|
Expect(decision.TranscodeReasons).To(HaveLen(3))
|
||||||
Expect(decision.TranscodeReasons[0]).To(Equal("container not supported"))
|
Expect(decision.TranscodeReasons[0]).To(ContainSubstring("container 'ogg' not supported"))
|
||||||
Expect(decision.TranscodeReasons[1]).To(Equal("container not supported"))
|
Expect(decision.TranscodeReasons[0]).To(ContainSubstring("[flac]"))
|
||||||
Expect(decision.TranscodeReasons[2]).To(Equal("container not supported"))
|
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))
|
Expect(bitrate).To(Equal(fallbackBitrate))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ensureProbed", func() {
|
Describe("ensureProbed", func() {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package stream
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -47,6 +48,18 @@ type DirectPlayProfile struct {
|
|||||||
MaxAudioChannels int
|
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
|
// Profile describes a transcoding target the client supports
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Container string
|
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
|
// 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
|
// This will lock out other writes that could happen at the same time
|
||||||
done, err := backupOp.Step(-1)
|
done, err := backupOp.Step(-1)
|
||||||
if !done {
|
|
||||||
return fmt.Errorf("backup not done with step -1")
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error during backup step: %w", err)
|
return fmt.Errorf("error during backup step: %w", err)
|
||||||
}
|
}
|
||||||
|
if !done {
|
||||||
|
return fmt.Errorf("backup not done with step -1")
|
||||||
|
}
|
||||||
|
|
||||||
err = backupOp.Finish()
|
err = backupOp.Finish()
|
||||||
if err != nil {
|
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
|
go 1.25.0
|
||||||
|
|
||||||
// Fork to implement raw tags support
|
// 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 (
|
require (
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
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/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 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
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-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw=
|
||||||
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/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 h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
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=
|
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"},
|
"releasedate": {field: "media_file.release_date"},
|
||||||
"size": {field: "media_file.size"},
|
"size": {field: "media_file.size"},
|
||||||
"compilation": {field: "media_file.compilation"},
|
"compilation": {field: "media_file.compilation"},
|
||||||
|
"missing": {field: "media_file.missing"},
|
||||||
"explicitstatus": {field: "media_file.explicit_status"},
|
"explicitstatus": {field: "media_file.explicit_status"},
|
||||||
"dateadded": {field: "media_file.created_at"},
|
"dateadded": {field: "media_file.created_at"},
|
||||||
"datemodified": {field: "media_file.updated_at"},
|
"datemodified": {field: "media_file.updated_at"},
|
||||||
@ -49,9 +50,11 @@ var fieldMap = map[string]*mappedField{
|
|||||||
"catalognumber": {field: "media_file.catalog_num"},
|
"catalognumber": {field: "media_file.catalog_num"},
|
||||||
"filepath": {field: "media_file.path"},
|
"filepath": {field: "media_file.path"},
|
||||||
"filetype": {field: "media_file.suffix"},
|
"filetype": {field: "media_file.suffix"},
|
||||||
|
"codec": {field: "media_file.codec"},
|
||||||
"duration": {field: "media_file.duration"},
|
"duration": {field: "media_file.duration"},
|
||||||
"bitrate": {field: "media_file.bit_rate"},
|
"bitrate": {field: "media_file.bit_rate"},
|
||||||
"bitdepth": {field: "media_file.bit_depth"},
|
"bitdepth": {field: "media_file.bit_depth"},
|
||||||
|
"samplerate": {field: "media_file.sample_rate"},
|
||||||
"bpm": {field: "media_file.bpm"},
|
"bpm": {field: "media_file.bpm"},
|
||||||
"channels": {field: "media_file.channels"},
|
"channels": {field: "media_file.channels"},
|
||||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||||
|
|||||||
@ -361,6 +361,9 @@ func older(t1, t2 time.Time) time.Time {
|
|||||||
if t1.IsZero() {
|
if t1.IsZero() {
|
||||||
return t2
|
return t2
|
||||||
}
|
}
|
||||||
|
if t2.IsZero() {
|
||||||
|
return t1
|
||||||
|
}
|
||||||
if t1.After(t2) {
|
if t1.After(t2) {
|
||||||
return t2
|
return t2
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,6 +119,20 @@ var _ = Describe("MediaFiles", func() {
|
|||||||
Expect(a.MinYear).To(Equal(1999))
|
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() {
|
When("we have multiple songs with same dates", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|||||||
@ -12,88 +12,85 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/id"
|
"github.com/navidrome/navidrome/model/id"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
type hashFunc = func(...string) string
|
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
|
// computePID calculates the persistent ID for a given spec. The spec is a
|
||||||
// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes
|
// pipe-separated list of fields, where each field is a comma-separated list of
|
||||||
// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
|
// attributes. Attributes can be either tags or processed values like folder,
|
||||||
// For each field, it gets all its attributes values and concatenates them, then hashes the result.
|
// albumid, albumartistid, etc. For each field, it gets all its attribute values
|
||||||
// If a field is empty, it is skipped and the function looks for the next field.
|
// and concatenates them, then hashes the result. If a field is empty, it is
|
||||||
type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string
|
// skipped and the function looks for the next field.
|
||||||
|
//
|
||||||
func createGetPID(hash hashFunc) getPIDFunc {
|
// Taking hash as a parameter (instead of closing over it in a factory) keeps
|
||||||
var getPID getPIDFunc
|
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
|
||||||
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string {
|
// heap on every call.
|
||||||
attr = strings.TrimSpace(strings.ToLower(attr))
|
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
|
||||||
switch attr {
|
switch spec {
|
||||||
case "albumid":
|
case "track_legacy":
|
||||||
if spec == conf.Server.PID.Album {
|
return legacyTrackID(mf, prependLibId)
|
||||||
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
|
case "album_legacy":
|
||||||
return ""
|
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)
|
values[i] = v
|
||||||
case "folder":
|
}
|
||||||
return filepath.Dir(mf.Path)
|
if hasValue {
|
||||||
case "albumartistid":
|
pid += strings.Join(values, "\\")
|
||||||
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
|
break
|
||||||
case "title":
|
|
||||||
return mf.Title
|
|
||||||
case "album":
|
|
||||||
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
|
|
||||||
}
|
}
|
||||||
return md.String(model.TagName(attr))
|
|
||||||
}
|
}
|
||||||
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
if prependLibId {
|
||||||
pid := ""
|
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, 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)
|
|
||||||
}
|
}
|
||||||
|
return hash(pid)
|
||||||
|
}
|
||||||
|
|
||||||
return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
|
||||||
switch spec {
|
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||||
case "track_legacy":
|
switch attr {
|
||||||
return legacyTrackID(mf, prependLibId)
|
case "albumid":
|
||||||
case "album_legacy":
|
if spec == conf.Server.PID.Album {
|
||||||
return legacyAlbumID(mf, md, prependLibId)
|
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 {
|
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 {
|
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?
|
// BFR Must be configurable?
|
||||||
func (md Metadata) artistID(name string) string {
|
func (md Metadata) artistID(name string) string {
|
||||||
mf := model.MediaFile{AlbumArtist: name}
|
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 {
|
func (md Metadata) mapTrackTitle() string {
|
||||||
|
|||||||
@ -12,15 +12,16 @@ import (
|
|||||||
|
|
||||||
var _ = Describe("getPID", func() {
|
var _ = Describe("getPID", func() {
|
||||||
var (
|
var (
|
||||||
md Metadata
|
md Metadata
|
||||||
mf model.MediaFile
|
mf model.MediaFile
|
||||||
sum hashFunc
|
sum hashFunc
|
||||||
getPID getPIDFunc
|
|
||||||
)
|
)
|
||||||
|
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||||
|
return computePID(mf, md, spec, prependLibId, sum)
|
||||||
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
|
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
|
||||||
getPID = createGetPID(sum)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("attributes are tags", func() {
|
Context("attributes are tags", func() {
|
||||||
|
|||||||
@ -192,6 +192,7 @@ const (
|
|||||||
TagISRC TagName = "isrc"
|
TagISRC TagName = "isrc"
|
||||||
TagBPM TagName = "bpm"
|
TagBPM TagName = "bpm"
|
||||||
TagExplicitStatus TagName = "explicitstatus"
|
TagExplicitStatus TagName = "explicitstatus"
|
||||||
|
TagMetadataTag TagName = "tags"
|
||||||
|
|
||||||
// Dates and years
|
// Dates and years
|
||||||
|
|
||||||
|
|||||||
@ -252,7 +252,17 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
|
|||||||
}
|
}
|
||||||
to := make(map[string]any)
|
to := make(map[string]any)
|
||||||
for _, col := range columns {
|
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}))
|
_, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID}))
|
||||||
return err
|
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() {
|
Describe("GetAll", func() {
|
||||||
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
|
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
|
||||||
albums, err := albumRepo.GetAll(opts...)
|
albums, err := albumRepo.GetAll(opts...)
|
||||||
|
|||||||
@ -102,6 +102,11 @@ components:
|
|||||||
mbzReleaseTrackId:
|
mbzReleaseTrackId:
|
||||||
type: string
|
type: string
|
||||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
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:
|
required:
|
||||||
- id
|
- id
|
||||||
- title
|
- title
|
||||||
|
|||||||
@ -68,6 +68,9 @@ type TrackInfo struct {
|
|||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
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.
|
// NowPlayingRequest is the request for now playing notification.
|
||||||
|
|||||||
@ -128,6 +128,11 @@ components:
|
|||||||
mbzReleaseTrackId:
|
mbzReleaseTrackId:
|
||||||
type: string
|
type: string
|
||||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
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:
|
required:
|
||||||
- id
|
- id
|
||||||
- title
|
- title
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
@ -35,6 +36,8 @@ type kvstoreServiceImpl struct {
|
|||||||
pluginName string
|
pluginName string
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
maxSize int64
|
maxSize int64
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database.
|
// 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)))
|
log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)))
|
||||||
|
|
||||||
|
cleanupCtx, cancel := context.WithCancel(ctx)
|
||||||
svc := &kvstoreServiceImpl{
|
svc := &kvstoreServiceImpl{
|
||||||
pluginName: pluginName,
|
pluginName: pluginName,
|
||||||
db: db,
|
db: db,
|
||||||
maxSize: maxSize,
|
maxSize: maxSize,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
go svc.cleanupLoop(ctx)
|
svc.wg.Add(1)
|
||||||
|
go svc.cleanupLoop(cleanupCtx)
|
||||||
return svc, nil
|
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.
|
// cleanupLoop periodically removes expired keys from the database.
|
||||||
// It stops when the provided context is cancelled.
|
// It stops when the provided context is cancelled.
|
||||||
func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) {
|
func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) {
|
||||||
|
defer s.wg.Done()
|
||||||
ticker := time.NewTicker(cleanupInterval)
|
ticker := time.NewTicker(cleanupInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
@ -359,17 +366,12 @@ func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close runs a final cleanup and closes the SQLite database connection.
|
// Close stops the cleanup goroutine and closes the SQLite database connection.
|
||||||
// The cleanup goroutine is stopped by the context passed to newKVStoreService.
|
|
||||||
func (s *kvstoreServiceImpl) Close() error {
|
func (s *kvstoreServiceImpl) Close() error {
|
||||||
if s.db != nil {
|
log.Debug("Closing plugin kvstore", "plugin", s.pluginName)
|
||||||
log.Debug("Closing plugin kvstore", "plugin", s.pluginName)
|
s.cancel()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
s.wg.Wait()
|
||||||
defer cancel()
|
return s.db.Close()
|
||||||
s.cleanupExpired(ctx)
|
|
||||||
return s.db.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile-time verification
|
// 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() {
|
Describe("SetWithTTL", func() {
|
||||||
It("stores value that is retrievable before expiry", func() {
|
It("stores value that is retrievable before expiry", func() {
|
||||||
err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600)
|
err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600)
|
||||||
|
|||||||
@ -31,7 +31,7 @@ type LyricsPlugin struct {
|
|||||||
// using model.ToLyrics.
|
// using model.ToLyrics.
|
||||||
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||||
req := capabilities.GetLyricsRequest{
|
req := capabilities.GetLyricsRequest{
|
||||||
Track: mediaFileToTrackInfo(mf),
|
Track: mediaFileToTrackInfo(l.plugin, mf),
|
||||||
}
|
}
|
||||||
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
|
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
|
||||||
ctx, l.plugin, FuncLyricsGetLyrics, req,
|
ctx, l.plugin, FuncLyricsGetLyrics, req,
|
||||||
|
|||||||
@ -301,7 +301,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configure filesystem access for library permission
|
// 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)
|
adminCtx := adminContext(ctx)
|
||||||
libraries, err := m.ds.Library(adminCtx).GetAll()
|
libraries, err := m.ds.Library(adminCtx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -384,6 +384,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
|||||||
metrics: m.metrics,
|
metrics: m.metrics,
|
||||||
allowedUserIDs: allowedUsers,
|
allowedUserIDs: allowedUsers,
|
||||||
allUsers: p.AllUsers,
|
allUsers: p.AllUsers,
|
||||||
|
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type plugin struct {
|
|||||||
metrics PluginMetricsRecorder
|
metrics PluginMetricsRecorder
|
||||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||||
allUsers bool // If true, plugin can access all users
|
allUsers bool // If true, plugin can access all users
|
||||||
|
libraries libraryAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
// instance creates a new plugin instance for the given context.
|
// instance creates a new plugin instance for the given context.
|
||||||
@ -47,3 +48,30 @@ func (p *plugin) Close() error {
|
|||||||
}
|
}
|
||||||
return errors.Join(errs...)
|
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 {
|
func (m *Manifest) HasExperimentalThreads() bool {
|
||||||
return m.Experimental != nil && m.Experimental.Threads != nil
|
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"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
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.
|
// Lyrics requires all methods to be implemented.
|
||||||
|
|||||||
@ -65,6 +65,9 @@ type TrackInfo struct {
|
|||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
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.
|
// Lyrics requires all methods to be implemented.
|
||||||
|
|||||||
@ -92,6 +92,9 @@ type TrackInfo struct {
|
|||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
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.
|
// Scrobbler requires all methods to be implemented.
|
||||||
|
|||||||
@ -89,6 +89,9 @@ type TrackInfo struct {
|
|||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
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.
|
// Scrobbler requires all methods to be implemented.
|
||||||
|
|||||||
@ -102,6 +102,10 @@ pub struct TrackInfo {
|
|||||||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub mbz_release_track_id: String,
|
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.
|
/// Error represents an error from a capability method.
|
||||||
|
|||||||
@ -122,6 +122,10 @@ pub struct TrackInfo {
|
|||||||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub mbz_release_track_id: String,
|
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.
|
/// 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)
|
username := getUsernameFromContext(ctx)
|
||||||
input := capabilities.NowPlayingRequest{
|
input := capabilities.NowPlayingRequest{
|
||||||
Username: username,
|
Username: username,
|
||||||
Track: mediaFileToTrackInfo(track),
|
Track: mediaFileToTrackInfo(s.plugin, track),
|
||||||
Position: int32(position),
|
Position: int32(position),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +93,7 @@ func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobb
|
|||||||
username := getUsernameFromContext(ctx)
|
username := getUsernameFromContext(ctx)
|
||||||
input := capabilities.ScrobbleRequest{
|
input := capabilities.ScrobbleRequest{
|
||||||
Username: username,
|
Username: username,
|
||||||
Track: mediaFileToTrackInfo(&sc.MediaFile),
|
Track: mediaFileToTrackInfo(s.plugin, &sc.MediaFile),
|
||||||
Timestamp: sc.TimeStamp.Unix(),
|
Timestamp: sc.TimeStamp.Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,9 +109,11 @@ func getUsernameFromContext(ctx context.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo
|
// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo.
|
||||||
func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
|
// Path is populated only when the plugin is allowed filesystem access to the
|
||||||
return capabilities.TrackInfo{
|
// track's library.
|
||||||
|
func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo {
|
||||||
|
ti := capabilities.TrackInfo{
|
||||||
ID: mf.ID,
|
ID: mf.ID,
|
||||||
Title: mf.Title,
|
Title: mf.Title,
|
||||||
Album: mf.Album,
|
Album: mf.Album,
|
||||||
@ -127,6 +129,10 @@ func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
|
|||||||
MBZReleaseGroupID: mf.MbzReleaseGroupID,
|
MBZReleaseGroupID: mf.MbzReleaseGroupID,
|
||||||
MBZReleaseTrackID: mf.MbzReleaseTrackID,
|
MBZReleaseTrackID: mf.MbzReleaseTrackID,
|
||||||
}
|
}
|
||||||
|
if p.hasLibraryFilesystemAccess(mf.LibraryID) {
|
||||||
|
ti.Path = mf.Path
|
||||||
|
}
|
||||||
|
return ti
|
||||||
}
|
}
|
||||||
|
|
||||||
// participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef
|
// 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"))
|
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() {
|
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" \
|
wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \
|
||||||
"https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip"
|
"https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip"
|
||||||
rm -rf "${DOWNLOAD_FOLDER}/extracted_ffmpeg"
|
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/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 "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
||||||
cp "$BINARY" "$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' />
|
<File Id='ffmpeg.exe' Name='ffmpeg.exe' DiskId='1' Source='ffmpeg.exe' KeyPath='yes' />
|
||||||
</Component>
|
</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>
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
@ -87,6 +91,7 @@
|
|||||||
<ComponentRef Id='Configuration'/>
|
<ComponentRef Id='Configuration'/>
|
||||||
<ComponentRef Id='MainExecutable' />
|
<ComponentRef Id='MainExecutable' />
|
||||||
<ComponentRef Id='FFMpegExecutable' />
|
<ComponentRef Id='FFMpegExecutable' />
|
||||||
|
<ComponentRef Id='FFProbeExecutable' />
|
||||||
<ComponentRef Id='PackageFile' />
|
<ComponentRef Id='PackageFile' />
|
||||||
</Feature>
|
</Feature>
|
||||||
</Product>
|
</Product>
|
||||||
|
|||||||
@ -36,7 +36,9 @@
|
|||||||
"bitDepth": "Bitprofundo",
|
"bitDepth": "Bitprofundo",
|
||||||
"sampleRate": "Elprena rapido",
|
"sampleRate": "Elprena rapido",
|
||||||
"missing": "Mankaj",
|
"missing": "Mankaj",
|
||||||
"libraryName": "Biblioteko"
|
"libraryName": "Biblioteko",
|
||||||
|
"composer": "",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Ludi Poste",
|
"addToQueue": "Ludi Poste",
|
||||||
@ -46,7 +48,8 @@
|
|||||||
"download": "Elŝuti",
|
"download": "Elŝuti",
|
||||||
"playNext": "Ludu Poste",
|
"playNext": "Ludu Poste",
|
||||||
"info": "Akiri Informon",
|
"info": "Akiri Informon",
|
||||||
"showInPlaylist": "Montri en Ludlisto"
|
"showInPlaylist": "Montri en Ludlisto",
|
||||||
|
"instantMix": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
@ -328,6 +331,82 @@
|
|||||||
"scanInProgress": "Skano progresas...",
|
"scanInProgress": "Skano progresas...",
|
||||||
"noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
|
"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": {
|
"ra": {
|
||||||
@ -511,7 +590,14 @@
|
|||||||
"remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn",
|
"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.",
|
"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",
|
"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": {
|
"menu": {
|
||||||
"library": "Biblioteko",
|
"library": "Biblioteko",
|
||||||
@ -597,7 +683,8 @@
|
|||||||
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
|
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
|
||||||
"exportFailed": "Malsukcesis kopii agordojn",
|
"exportFailed": "Malsukcesis kopii agordojn",
|
||||||
"devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
|
"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": {
|
"activity": {
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"bitDepth": "Bit-sakonera",
|
"bitDepth": "Bit-sakonera",
|
||||||
"sampleRate": "Lagin-tasa",
|
"sampleRate": "Lagin-tasa",
|
||||||
"channels": "Kanalak",
|
"channels": "Kanalak",
|
||||||
|
"disc": "%{discNumber}. diskoa",
|
||||||
"discSubtitle": "Diskoaren azpititulua",
|
"discSubtitle": "Diskoaren azpititulua",
|
||||||
"starred": "Gogokoa",
|
"starred": "Gogokoa",
|
||||||
"comment": "Iruzkina",
|
"comment": "Iruzkina",
|
||||||
@ -355,7 +356,8 @@
|
|||||||
"allUsers": "Baimendu erabiltzaile guztiak",
|
"allUsers": "Baimendu erabiltzaile guztiak",
|
||||||
"selectedUsers": "Hautatutako erabiltzaileak",
|
"selectedUsers": "Hautatutako erabiltzaileak",
|
||||||
"allLibraries": "Baimendu liburutegi guztiak",
|
"allLibraries": "Baimendu liburutegi guztiak",
|
||||||
"selectedLibraries": "Hautatutako liburutegiak"
|
"selectedLibraries": "Hautatutako liburutegiak",
|
||||||
|
"allowWriteAccess": "Eman idazteko baimena"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Egoera",
|
"status": "Egoera",
|
||||||
@ -400,6 +402,7 @@
|
|||||||
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
||||||
"noLibraries": "Ez da liburutegirik hautatu",
|
"noLibraries": "Ez da liburutegirik hautatu",
|
||||||
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
|
"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"
|
"requiredHosts": "Beharrezko ostatatzaileak"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@ -554,6 +557,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"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",
|
"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.",
|
"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.",
|
"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",
|
"currentValue": "Uneko balioa",
|
||||||
"configurationFile": "Konfigurazio-fitxategia",
|
"configurationFile": "Konfigurazio-fitxategia",
|
||||||
"exportToml": "Esportatu konfigurazioa (TOML)",
|
"exportToml": "Esportatu konfigurazioa (TOML)",
|
||||||
|
"downloadToml": "Deskargatu konfigurazioa (TOML)",
|
||||||
"exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
|
"exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
|
||||||
"exportFailed": "Konfigurazioa kopiatzeak huts egin du",
|
"exportFailed": "Konfigurazioa kopiatzeak huts egin du",
|
||||||
"devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",
|
"devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Sample waarde",
|
"sampleRate": "Sample waarde",
|
||||||
"missing": "Ontbrekend",
|
"missing": "Ontbrekend",
|
||||||
"libraryName": "Bibliotheek",
|
"libraryName": "Bibliotheek",
|
||||||
"composer": ""
|
"composer": "Componist",
|
||||||
|
"disc": "Schijf %{discNumber}"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Voeg toe aan wachtrij",
|
"addToQueue": "Voeg toe aan wachtrij",
|
||||||
@ -48,7 +49,7 @@
|
|||||||
"playNext": "Volgende",
|
"playNext": "Volgende",
|
||||||
"info": "Meer info",
|
"info": "Meer info",
|
||||||
"showInPlaylist": "Toon in afspeellijst",
|
"showInPlaylist": "Toon in afspeellijst",
|
||||||
"instantMix": ""
|
"instantMix": "Instant mix"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
@ -350,10 +351,11 @@
|
|||||||
"createdAt": "Geinstalleerd",
|
"createdAt": "Geinstalleerd",
|
||||||
"configKey": "Sleutel",
|
"configKey": "Sleutel",
|
||||||
"configValue": "Waarde",
|
"configValue": "Waarde",
|
||||||
"allUsers": "Alle gebruikers toelaten",
|
"allUsers": "Sta toe voor alle gebruikers",
|
||||||
"selectedUsers": "Geselecteerde gebruikers",
|
"selectedUsers": "Geselecteerde gebruikers",
|
||||||
"allLibraries": "Alle bibliotheken toestaan",
|
"allLibraries": "Sta toe voor alle bibliotheken",
|
||||||
"selectedLibraries": "Geselecteerde bibliotheken"
|
"selectedLibraries": "Geselecteerde bibliotheken",
|
||||||
|
"allowWriteAccess": "Sta schrijftoegang toe"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@ -379,26 +381,27 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"enabled": "Plugin actief",
|
"enabled": "Plugin actief",
|
||||||
"disabled": "Plugin niet actief",
|
"disabled": "Plugin niet actief",
|
||||||
"updated": "Plugin geupdate",
|
"updated": "Plugin bijgewerkt",
|
||||||
"error": "Fout bij updaten plugin"
|
"error": "Fout bij updaten plugin"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalidJson": "Configuratie moet geldige JSON zijn"
|
"invalidJson": "Configuratie moet geldige JSON zijn"
|
||||||
},
|
},
|
||||||
"messages": {
|
"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",
|
"clickPermissions": "Klik op permissie voor details",
|
||||||
"noConfig": "Geen configuratie ingesteld",
|
"noConfig": "Geen configuratie ingesteld",
|
||||||
"allUsersHelp": "",
|
"allUsersHelp": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.",
|
||||||
"noUsers": "Geen gebruikers geselecteerd",
|
"noUsers": "Geen gebruikers geselecteerd",
|
||||||
"permissionReason": "Reden",
|
"permissionReason": "Reden",
|
||||||
"usersRequired": "",
|
"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": "",
|
"allLibrariesHelp": "Als dit aanstaat, heeft de plug-in toegang tot alle bibliotheken, inclusief toekomstige.",
|
||||||
"noLibraries": "Geen bibliotheken geselecteerd",
|
"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",
|
"requiredHosts": "Benodigde hosts",
|
||||||
"configValidationError": "",
|
"configValidationError": "Configuratiecheck mislukt",
|
||||||
"schemaRenderError": ""
|
"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": {
|
"placeholders": {
|
||||||
"configKey": "Sleutel",
|
"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.",
|
"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",
|
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
|
||||||
"noTopSongsFound": "Geen beste 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": {
|
"menu": {
|
||||||
"library": "Bibliotheek",
|
"library": "Bibliotheek",
|
||||||
@ -674,7 +683,8 @@
|
|||||||
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
|
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
|
||||||
"exportFailed": "Kopiëren van configuratie mislukt",
|
"exportFailed": "Kopiëren van configuratie mislukt",
|
||||||
"devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
|
"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": {
|
"activity": {
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"bitDepth": "位深度",
|
"bitDepth": "位深度",
|
||||||
"sampleRate": "采样率",
|
"sampleRate": "采样率",
|
||||||
"channels": "声道",
|
"channels": "声道",
|
||||||
|
"disc": "碟片 %{discNumber}",
|
||||||
"discSubtitle": "碟片副标题",
|
"discSubtitle": "碟片副标题",
|
||||||
"starred": "收藏",
|
"starred": "收藏",
|
||||||
"comment": "注释",
|
"comment": "注释",
|
||||||
@ -355,7 +356,8 @@
|
|||||||
"allUsers": "允许所有用户",
|
"allUsers": "允许所有用户",
|
||||||
"selectedUsers": "指定用户",
|
"selectedUsers": "指定用户",
|
||||||
"allLibraries": "允许所有媒体库",
|
"allLibraries": "允许所有媒体库",
|
||||||
"selectedLibraries": "指定媒体库"
|
"selectedLibraries": "指定媒体库",
|
||||||
|
"allowWriteAccess": "允许写入权限"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
@ -400,6 +402,7 @@
|
|||||||
"allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。",
|
"allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。",
|
||||||
"noLibraries": "未选择媒体库",
|
"noLibraries": "未选择媒体库",
|
||||||
"librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。",
|
"librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。",
|
||||||
|
"allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。",
|
||||||
"requiredHosts": "必需的主机"
|
"requiredHosts": "必需的主机"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@ -554,6 +557,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
|
"uploadCover": "上传封面",
|
||||||
|
"removeCover": "移除封面",
|
||||||
|
"coverUploaded": "封面已上传",
|
||||||
|
"coverRemoved": "封面已移除",
|
||||||
|
"coverUploadError": "上传封面时出错",
|
||||||
|
"coverRemoveError": "移除封面时出错",
|
||||||
"note": "注意",
|
"note": "注意",
|
||||||
"transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。",
|
"transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。",
|
||||||
"transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。",
|
"transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。",
|
||||||
@ -673,6 +682,7 @@
|
|||||||
"currentValue": "当前值",
|
"currentValue": "当前值",
|
||||||
"configurationFile": "配置文件",
|
"configurationFile": "配置文件",
|
||||||
"exportToml": "导出配置(TOML)",
|
"exportToml": "导出配置(TOML)",
|
||||||
|
"downloadToml": "下载配置(TOML)",
|
||||||
"exportSuccess": "配置以 TOML 格式导出到剪贴板完成",
|
"exportSuccess": "配置以 TOML 格式导出到剪贴板完成",
|
||||||
"exportFailed": "复制配置失败",
|
"exportFailed": "复制配置失败",
|
||||||
"devFlagsHeader": "开发标志(可能会更改/删除)",
|
"devFlagsHeader": "开发标志(可能会更改/删除)",
|
||||||
|
|||||||
@ -116,7 +116,7 @@ main:
|
|||||||
aliases: [ comm:description, comment, ©cmt, description, icmt ]
|
aliases: [ comm:description, comment, ©cmt, description, icmt ]
|
||||||
maxLength: 4096
|
maxLength: 4096
|
||||||
originaldate:
|
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
|
type: date
|
||||||
recordingdate:
|
recordingdate:
|
||||||
aliases: [ tdrc, date, recordingdate, icrd, record date ]
|
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
|
# Additional tags. You can add new tags without the need to modify the code. They will be available as fields
|
||||||
# for smart playlists
|
# for smart playlists
|
||||||
additional:
|
additional:
|
||||||
|
# Internal tag type, represents metadata tag(s) found in the file
|
||||||
|
tags:
|
||||||
|
aliases: [ __tags ]
|
||||||
asin:
|
asin:
|
||||||
aliases: [ txxx:asin, asin, ----:com.apple.itunes:asin ]
|
aliases: [ txxx:asin, asin, ----:com.apple.itunes:asin ]
|
||||||
barcode:
|
barcode:
|
||||||
|
|||||||
@ -172,6 +172,10 @@ func buildTestFS() storagetest.FakeFS {
|
|||||||
"title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus",
|
"title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus",
|
||||||
"bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220),
|
"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 folder (directory with no audio)
|
||||||
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
|
"_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) CmdPath() (string, error) { return "", nil }
|
||||||
func (n noopFFmpeg) IsAvailable() bool { return false }
|
func (n noopFFmpeg) IsAvailable() bool { return false }
|
||||||
|
func (n noopFFmpeg) IsProbeAvailable() bool { return true }
|
||||||
func (n noopFFmpeg) Version() string { return "noop" }
|
func (n noopFFmpeg) Version() string { return "noop" }
|
||||||
|
|
||||||
// noopArchiver implements core.Archiver
|
// noopArchiver implements core.Archiver
|
||||||
|
|||||||
@ -117,7 +117,7 @@ var _ = Describe("Search Endpoints", func() {
|
|||||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||||
Expect(resp.SearchResult3.Artist).To(HaveLen(6))
|
Expect(resp.SearchResult3.Artist).To(HaveLen(6))
|
||||||
Expect(resp.SearchResult3.Album).To(HaveLen(7))
|
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() {
|
It("finds across all entity types simultaneously", func() {
|
||||||
|
|||||||
@ -13,8 +13,9 @@ import (
|
|||||||
|
|
||||||
var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
||||||
var (
|
var (
|
||||||
mp3TrackID string // Come Together (mp3, 320kbps)
|
mp3TrackID string // Come Together (mp3, 320kbps)
|
||||||
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
||||||
|
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeAll(func() {
|
BeforeAll(func() {
|
||||||
@ -30,6 +31,8 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
|||||||
Expect(mp3TrackID).ToNot(BeEmpty())
|
Expect(mp3TrackID).ToNot(BeEmpty())
|
||||||
flacTrackID = byTitle["TC FLAC Standard"]
|
flacTrackID = byTitle["TC FLAC Standard"]
|
||||||
Expect(flacTrackID).ToNot(BeEmpty())
|
Expect(flacTrackID).ToNot(BeEmpty())
|
||||||
|
flacMultichTrackID = byTitle["TC FLAC Multichannel"]
|
||||||
|
Expect(flacMultichTrackID).ToNot(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("raw / direct play", func() {
|
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.Format).To(Equal("mp3"))
|
||||||
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
|
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() {
|
Describe("downsampling with maxBitRate only", func() {
|
||||||
|
|||||||
@ -114,13 +114,14 @@ const (
|
|||||||
var _ = Describe("Transcode Endpoints", Ordered, func() {
|
var _ = Describe("Transcode Endpoints", Ordered, func() {
|
||||||
// Track IDs resolved in BeforeAll
|
// Track IDs resolved in BeforeAll
|
||||||
var (
|
var (
|
||||||
mp3TrackID string // Come Together (mp3, 320kbps)
|
mp3TrackID string // Come Together (mp3, 320kbps)
|
||||||
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
||||||
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
|
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
|
||||||
alacTrackID string // TC ALAC Track (m4a, alac)
|
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
|
||||||
dsdTrackID string // TC DSD Track (dsf, dsd)
|
alacTrackID string // TC ALAC Track (m4a, alac)
|
||||||
opusTrackID string // TC Opus Track (opus, 128kbps)
|
dsdTrackID string // TC DSD Track (dsf, dsd)
|
||||||
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
|
opusTrackID string // TC Opus Track (opus, 128kbps)
|
||||||
|
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeAll(func() {
|
BeforeAll(func() {
|
||||||
@ -140,6 +141,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
|
|||||||
mp3TrackID = ensureGetTrackID("Come Together")
|
mp3TrackID = ensureGetTrackID("Come Together")
|
||||||
flacTrackID = ensureGetTrackID("TC FLAC Standard")
|
flacTrackID = ensureGetTrackID("TC FLAC Standard")
|
||||||
flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes")
|
flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes")
|
||||||
|
flacMultichTrackID = ensureGetTrackID("TC FLAC Multichannel")
|
||||||
alacTrackID = ensureGetTrackID("TC ALAC Track")
|
alacTrackID = ensureGetTrackID("TC ALAC Track")
|
||||||
dsdTrackID = ensureGetTrackID("TC DSD Track")
|
dsdTrackID = ensureGetTrackID("TC DSD Track")
|
||||||
opusTrackID = ensureGetTrackID("TC Opus 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
|
// maxTranscodingAudioBitrate is 192000 bps = 192 kbps → response in bps
|
||||||
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
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() {
|
Describe("response structure", func() {
|
||||||
|
|||||||
@ -68,13 +68,16 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error {
|
|||||||
func checkFFmpegInstallation() {
|
func checkFFmpegInstallation() {
|
||||||
f := ffmpeg.New()
|
f := ffmpeg.New()
|
||||||
_, err := f.CmdPath()
|
_, 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
|
return
|
||||||
}
|
}
|
||||||
log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err)
|
if !f.IsProbeAvailable() {
|
||||||
if conf.Server.Scanner.Extractor == "ffmpeg" {
|
log.Warn("Unable to find ffprobe. Transcoding decisions will be limited")
|
||||||
log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib")
|
|
||||||
conf.Server.Scanner.Extractor = "taglib"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/core/publicurl"
|
"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 {
|
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
|
||||||
s.URL = ShareURL(r, s.ID)
|
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 {
|
for i := range s.Tracks {
|
||||||
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
|
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,
|
"defaultLanguage": conf.Server.DefaultLanguage,
|
||||||
"defaultUIVolume": conf.Server.DefaultUIVolume,
|
"defaultUIVolume": conf.Server.DefaultUIVolume,
|
||||||
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
||||||
|
"uiCoverArtSize": conf.Server.UICoverArtSize,
|
||||||
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
||||||
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
||||||
"gaTrackingId": conf.Server.GATrackingID,
|
"gaTrackingId": conf.Server.GATrackingID,
|
||||||
|
|||||||
@ -86,6 +86,7 @@ var _ = Describe("serveIndex", func() {
|
|||||||
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
||||||
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
||||||
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
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("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
||||||
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
||||||
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
|
. "github.com/navidrome/navidrome/utils/gg"
|
||||||
"github.com/navidrome/navidrome/utils/number"
|
"github.com/navidrome/navidrome/utils/number"
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"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.Path = fakePath(mf)
|
||||||
}
|
}
|
||||||
child.DiscNumber = int32(mf.DiscNumber)
|
child.DiscNumber = int32(mf.DiscNumber)
|
||||||
child.Created = &mf.BirthTime
|
child.Created = P(mf.BirthTime)
|
||||||
child.AlbumId = mf.AlbumID
|
child.AlbumId = mf.AlbumID
|
||||||
child.ArtistId = mf.ArtistID
|
child.ArtistId = mf.ArtistID
|
||||||
child.Type = "music"
|
child.Type = "music"
|
||||||
@ -317,6 +319,20 @@ func sanitizeSlashes(target string) string {
|
|||||||
return strings.ReplaceAll(target, "/", "_")
|
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 {
|
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
||||||
child := responses.Child{}
|
child := responses.Child{}
|
||||||
child.Id = al.ID
|
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.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
|
||||||
child.Genre = al.Genre
|
child.Genre = al.Genre
|
||||||
child.CoverArt = al.CoverArtID().String()
|
child.CoverArt = al.CoverArtID().String()
|
||||||
child.Created = &al.CreatedAt
|
child.Created = P(albumCreatedAt(al))
|
||||||
child.Parent = al.AlbumArtistID
|
child.Parent = al.AlbumArtistID
|
||||||
child.ArtistId = al.AlbumArtistID
|
child.ArtistId = al.AlbumArtistID
|
||||||
child.Duration = int32(al.Duration)
|
child.Duration = int32(al.Duration)
|
||||||
@ -391,9 +407,12 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var discTitles []responses.DiscTitle
|
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 {
|
for num, title := range a.Discs {
|
||||||
artID := model.NewArtworkID(model.KindDiscArtwork,
|
artID := model.NewArtworkID(model.KindDiscArtwork,
|
||||||
model.DiscArtworkID(a.ID, num), &a.UpdatedAt)
|
model.DiscArtworkID(a.ID, num), &updatedAt)
|
||||||
discTitles = append(discTitles, responses.DiscTitle{
|
discTitles = append(discTitles, responses.DiscTitle{
|
||||||
Disc: int32(num),
|
Disc: int32(num),
|
||||||
Title: title,
|
Title: title,
|
||||||
@ -421,9 +440,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
|||||||
dir.PlayCount = album.PlayCount
|
dir.PlayCount = album.PlayCount
|
||||||
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
|
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
|
||||||
dir.Genre = album.Genre
|
dir.Genre = album.Genre
|
||||||
if !album.CreatedAt.IsZero() {
|
dir.Created = P(albumCreatedAt(album))
|
||||||
dir.Created = &album.CreatedAt
|
|
||||||
}
|
|
||||||
if album.Starred {
|
if album.Starred {
|
||||||
dir.Starred = album.StarredAt
|
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() {
|
Describe("EnableAverageRating config", func() {
|
||||||
It("excludes averageRating when disabled", func() {
|
It("excludes averageRating when disabled", func() {
|
||||||
conf.Server.Subsonic.EnableAverageRating = false
|
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 {
|
func NewMockFFmpeg(data string) *MockFFmpeg {
|
||||||
return &MockFFmpeg{Reader: strings.NewReader(data)}
|
return &MockFFmpeg{Reader: strings.NewReader(data), ProbeAvailable: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockFFmpeg struct {
|
type MockFFmpeg struct {
|
||||||
@ -21,12 +21,17 @@ type MockFFmpeg struct {
|
|||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
Error error
|
Error error
|
||||||
ProbeAudioResult *ffmpeg.AudioProbeResult
|
ProbeAudioResult *ffmpeg.AudioProbeResult
|
||||||
|
ProbeAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ff *MockFFmpeg) IsAvailable() bool {
|
func (ff *MockFFmpeg) IsAvailable() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ff *MockFFmpeg) IsProbeAvailable() bool {
|
||||||
|
return ff.ProbeAvailable
|
||||||
|
}
|
||||||
|
|
||||||
func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) {
|
func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) {
|
||||||
if ff.Error != nil {
|
if ff.Error != nil {
|
||||||
return nil, ff.Error
|
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",
|
"prettier": "^3.6.2",
|
||||||
"ra-test": "^3.19.12",
|
"ra-test": "^3.19.12",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.1.12",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-pwa": "^1.1.0",
|
"vite-plugin-pwa": "^1.1.0",
|
||||||
"vitest": "^4.0.3"
|
"vitest": "^4.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
useTranslate,
|
useTranslate,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import { COVER_ART_SIZE } from '../consts'
|
import config from '../config'
|
||||||
import 'react-image-lightbox/style.css'
|
import 'react-image-lightbox/style.css'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import {
|
import {
|
||||||
@ -32,7 +32,6 @@ import {
|
|||||||
useAlbumsPerPage,
|
useAlbumsPerPage,
|
||||||
useImageLoadingState,
|
useImageLoadingState,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import config from '../config'
|
|
||||||
import { formatFullDate, intersperse } from '../utils'
|
import { formatFullDate, intersperse } from '../utils'
|
||||||
import AlbumExternalLinks from './AlbumExternalLinks'
|
import AlbumExternalLinks from './AlbumExternalLinks'
|
||||||
import { SafeHTML } from '../common/SafeHTML'
|
import { SafeHTML } from '../common/SafeHTML'
|
||||||
@ -255,7 +254,7 @@ const AlbumDetails = (props) => {
|
|||||||
})
|
})
|
||||||
}, [record])
|
}, [record])
|
||||||
|
|
||||||
const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE)
|
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize)
|
||||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -20,7 +20,8 @@ import {
|
|||||||
OverflowTooltip,
|
OverflowTooltip,
|
||||||
useImageUrl,
|
useImageUrl,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import { COVER_ART_SIZE, DraggableTypes } from '../consts'
|
import config from '../config'
|
||||||
|
import { DraggableTypes } from '../consts'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { AlbumDatesField } from './AlbumDatesField.jsx'
|
import { AlbumDatesField } from './AlbumDatesField.jsx'
|
||||||
|
|
||||||
@ -135,7 +136,7 @@ const Cover = withContentRect('bounds')(({
|
|||||||
[record],
|
[record],
|
||||||
)
|
)
|
||||||
|
|
||||||
const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)
|
const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
|
||||||
const { imgUrl, loading: imageLoading } = useImageUrl(url)
|
const { imgUrl, loading: imageLoading } = useImageUrl(url)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import {
|
|||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
import AlbumInfo from '../album/AlbumInfo'
|
import AlbumInfo from '../album/AlbumInfo'
|
||||||
import { COVER_ART_SIZE } from '../consts'
|
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { SafeHTML } from '../common/SafeHTML'
|
import { SafeHTML } from '../common/SafeHTML'
|
||||||
|
|
||||||
@ -110,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
|||||||
<CardMedia
|
<CardMedia
|
||||||
key={record.id}
|
key={record.id}
|
||||||
component="img"
|
component="img"
|
||||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
|
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
|
||||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||||
onClick={handleOpenLightbox}
|
onClick={handleOpenLightbox}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
useImageLoadingState,
|
useImageLoadingState,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import { COVER_ART_SIZE } from '../consts'
|
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { SafeHTML } from '../common/SafeHTML'
|
import { SafeHTML } from '../common/SafeHTML'
|
||||||
|
|
||||||
@ -113,7 +112,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
|||||||
<CardMedia
|
<CardMedia
|
||||||
key={record.id}
|
key={record.id}
|
||||||
component="img"
|
component="img"
|
||||||
src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
|
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
|
||||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||||
onClick={handleOpenLightbox}
|
onClick={handleOpenLightbox}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useRecordContext } from 'react-admin'
|
|||||||
import { Avatar } from '@material-ui/core'
|
import { Avatar } from '@material-ui/core'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { COVER_ART_SIZE } from '../consts'
|
import config from '../config'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { useImageUrl } from './useImageUrl'
|
import { useImageUrl } from './useImageUrl'
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ export const CoverArtAvatar = ({
|
|||||||
const record = recordProp || recordContext
|
const record = recordProp || recordContext
|
||||||
const square = variant !== 'circular'
|
const square = variant !== 'circular'
|
||||||
const url = record
|
const url = record
|
||||||
? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)
|
? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square)
|
||||||
: null
|
: null
|
||||||
const { imgUrl } = useImageUrl(url)
|
const { imgUrl } = useImageUrl(url)
|
||||||
if (!record) return null
|
if (!record) return null
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const defaultConfig = {
|
|||||||
defaultLanguage: '',
|
defaultLanguage: '',
|
||||||
defaultUIVolume: 100,
|
defaultUIVolume: 100,
|
||||||
uiSearchDebounceMs: 200,
|
uiSearchDebounceMs: 200,
|
||||||
|
uiCoverArtSize: 600,
|
||||||
enableUserEditing: true,
|
enableUserEditing: true,
|
||||||
enableArtworkUpload: true,
|
enableArtworkUpload: true,
|
||||||
enableSharing: true,
|
enableSharing: true,
|
||||||
|
|||||||
@ -26,8 +26,6 @@ DraggableTypes.ALL.push(
|
|||||||
|
|
||||||
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
|
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
|
||||||
|
|
||||||
export const COVER_ART_SIZE = 600
|
|
||||||
|
|
||||||
export const DEFAULT_SHARE_BITRATE = 128
|
export const DEFAULT_SHARE_BITRATE = 128
|
||||||
|
|
||||||
export const BITRATE_CHOICES = [
|
export const BITRATE_CHOICES = [
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
OverflowTooltip,
|
OverflowTooltip,
|
||||||
useImageLoadingState,
|
useImageLoadingState,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import { COVER_ART_SIZE } from '../consts'
|
import config from '../config'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
@ -107,7 +107,7 @@ const PlaylistDetails = (props) => {
|
|||||||
handleCloseLightbox,
|
handleCloseLightbox,
|
||||||
} = useImageLoadingState(record.id)
|
} = 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)
|
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||||
|
|
||||||
return (
|
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