mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge branch 'master' into master
This commit is contained in:
commit
70f43437b5
@ -43,23 +43,21 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
|||||||
|
|
||||||
// Parse audio properties
|
// Parse audio properties
|
||||||
ap := metadata.AudioProperties{}
|
ap := metadata.AudioProperties{}
|
||||||
if length, ok := tags["_lengthinmilliseconds"]; ok && len(length) > 0 {
|
ap.BitRate = parseProp(tags, "__bitrate")
|
||||||
millis, _ := strconv.Atoi(length[0])
|
ap.Channels = parseProp(tags, "__channels")
|
||||||
if millis > 0 {
|
ap.SampleRate = parseProp(tags, "__samplerate")
|
||||||
ap.Duration = (time.Millisecond * time.Duration(millis)).Round(time.Millisecond * 10)
|
ap.BitDepth = parseProp(tags, "__bitspersample")
|
||||||
}
|
length := parseProp(tags, "__lengthinmilliseconds")
|
||||||
delete(tags, "_lengthinmilliseconds")
|
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
|
||||||
}
|
|
||||||
parseProp := func(prop string, target *int) {
|
// Extract basic tags
|
||||||
if value, ok := tags[prop]; ok && len(value) > 0 {
|
parseBasicTag(tags, "__title", "title")
|
||||||
*target, _ = strconv.Atoi(value[0])
|
parseBasicTag(tags, "__artist", "artist")
|
||||||
delete(tags, prop)
|
parseBasicTag(tags, "__album", "album")
|
||||||
}
|
parseBasicTag(tags, "__comment", "comment")
|
||||||
}
|
parseBasicTag(tags, "__genre", "genre")
|
||||||
parseProp("_bitrate", &ap.BitRate)
|
parseBasicTag(tags, "__year", "year")
|
||||||
parseProp("_channels", &ap.Channels)
|
parseBasicTag(tags, "__track", "tracknumber")
|
||||||
parseProp("_samplerate", &ap.SampleRate)
|
|
||||||
parseProp("_bitspersample", &ap.BitDepth)
|
|
||||||
|
|
||||||
// Parse track/disc totals
|
// Parse track/disc totals
|
||||||
parseTuple := func(prop string) {
|
parseTuple := func(prop string) {
|
||||||
@ -107,6 +105,31 @@ var tiplMapping = map[string]string{
|
|||||||
"DJ-mix": "djmixer",
|
"DJ-mix": "djmixer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseProp parses a property from the tags map and sets it to the target integer.
|
||||||
|
// It also deletes the property from the tags map after parsing.
|
||||||
|
func parseProp(tags map[string][]string, prop string) int {
|
||||||
|
if value, ok := tags[prop]; ok && len(value) > 0 {
|
||||||
|
v, _ := strconv.Atoi(value[0])
|
||||||
|
delete(tags, prop)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
|
||||||
|
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
|
||||||
|
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
|
||||||
|
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
|
||||||
|
basicValue := tags[basicName]
|
||||||
|
if len(basicValue) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(tags, basicName)
|
||||||
|
if len(tags[tagName]) == 0 {
|
||||||
|
tags[tagName] = basicValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
||||||
//
|
//
|
||||||
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
||||||
|
|||||||
@ -45,31 +45,63 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
|||||||
|
|
||||||
// Add audio properties to the tags
|
// Add audio properties to the tags
|
||||||
const TagLib::AudioProperties *props(f.audioProperties());
|
const TagLib::AudioProperties *props(f.audioProperties());
|
||||||
goPutInt(id, (char *)"_lengthinmilliseconds", props->lengthInMilliseconds());
|
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
|
||||||
goPutInt(id, (char *)"_bitrate", props->bitrate());
|
goPutInt(id, (char *)"__bitrate", props->bitrate());
|
||||||
goPutInt(id, (char *)"_channels", props->channels());
|
goPutInt(id, (char *)"__channels", props->channels());
|
||||||
goPutInt(id, (char *)"_samplerate", props->sampleRate());
|
goPutInt(id, (char *)"__samplerate", props->sampleRate());
|
||||||
|
|
||||||
|
// Extract bits per sample for supported formats
|
||||||
|
int bitsPerSample = 0;
|
||||||
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
|
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
|
||||||
goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample());
|
bitsPerSample = apeProperties->bitsPerSample();
|
||||||
if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
||||||
goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample());
|
bitsPerSample = asfProperties->bitsPerSample();
|
||||||
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
|
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
|
||||||
goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample());
|
bitsPerSample = flacProperties->bitsPerSample();
|
||||||
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
|
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
|
||||||
goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample());
|
bitsPerSample = mp4Properties->bitsPerSample();
|
||||||
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
|
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
|
||||||
goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample());
|
bitsPerSample = wavePackProperties->bitsPerSample();
|
||||||
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
|
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
|
||||||
goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample());
|
bitsPerSample = aiffProperties->bitsPerSample();
|
||||||
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
|
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
|
||||||
goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample());
|
bitsPerSample = wavProperties->bitsPerSample();
|
||||||
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
|
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
|
||||||
goPutInt(id, (char *)"_bitspersample", dsfProperties->bitsPerSample());
|
bitsPerSample = dsfProperties->bitsPerSample();
|
||||||
|
|
||||||
|
if (bitsPerSample > 0) {
|
||||||
|
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
|
||||||
|
}
|
||||||
|
|
||||||
// Send all properties to the Go map
|
// Send all properties to the Go map
|
||||||
TagLib::PropertyMap tags = f.file()->properties();
|
TagLib::PropertyMap tags = f.file()->properties();
|
||||||
|
|
||||||
|
// Make sure at least the basic properties are extracted
|
||||||
|
TagLib::Tag *basic = f.file()->tag();
|
||||||
|
if (!basic->isEmpty()) {
|
||||||
|
if (!basic->title().isEmpty()) {
|
||||||
|
tags.insert("__title", basic->title());
|
||||||
|
}
|
||||||
|
if (!basic->artist().isEmpty()) {
|
||||||
|
tags.insert("__artist", basic->artist());
|
||||||
|
}
|
||||||
|
if (!basic->album().isEmpty()) {
|
||||||
|
tags.insert("__album", basic->album());
|
||||||
|
}
|
||||||
|
if (!basic->comment().isEmpty()) {
|
||||||
|
tags.insert("__comment", basic->comment());
|
||||||
|
}
|
||||||
|
if (!basic->genre().isEmpty()) {
|
||||||
|
tags.insert("__genre", basic->genre());
|
||||||
|
}
|
||||||
|
if (basic->year() > 0) {
|
||||||
|
tags.insert("__year", TagLib::String::number(basic->year()));
|
||||||
|
}
|
||||||
|
if (basic->track() > 0) {
|
||||||
|
tags.insert("__track", TagLib::String::number(basic->track()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TagLib::ID3v2::Tag *id3Tags = NULL;
|
TagLib::ID3v2::Tag *id3Tags = NULL;
|
||||||
|
|
||||||
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
||||||
|
|||||||
@ -61,7 +61,12 @@ func (c Criteria) OrderBy() string {
|
|||||||
if f.order != "" {
|
if f.order != "" {
|
||||||
mapped = f.order
|
mapped = f.order
|
||||||
} else if f.isTag {
|
} else if f.isTag {
|
||||||
mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')"
|
// Use the actual field name (handles aliases like albumtype -> releasetype)
|
||||||
|
tagName := sortField
|
||||||
|
if f.field != "" {
|
||||||
|
tagName = f.field
|
||||||
|
}
|
||||||
|
mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
|
||||||
} else if f.isRole {
|
} else if f.isRole {
|
||||||
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
|
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -118,6 +118,16 @@ var _ = Describe("Criteria", func() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("sorts by albumtype alias (resolves to releasetype)", func() {
|
||||||
|
AddTagNames([]string{"releasetype"})
|
||||||
|
goObj.Sort = "albumtype"
|
||||||
|
gomega.Expect(goObj.OrderBy()).To(
|
||||||
|
gomega.Equal(
|
||||||
|
"COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
It("sorts by random", func() {
|
It("sorts by random", func() {
|
||||||
newObj := goObj
|
newObj := goObj
|
||||||
newObj.Sort = "random"
|
newObj.Sort = "random"
|
||||||
|
|||||||
@ -32,7 +32,6 @@ var fieldMap = map[string]*mappedField{
|
|||||||
"sortalbum": {field: "media_file.sort_album_name"},
|
"sortalbum": {field: "media_file.sort_album_name"},
|
||||||
"sortartist": {field: "media_file.sort_artist_name"},
|
"sortartist": {field: "media_file.sort_artist_name"},
|
||||||
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
||||||
"albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"},
|
|
||||||
"albumcomment": {field: "media_file.mbz_album_comment"},
|
"albumcomment": {field: "media_file.mbz_album_comment"},
|
||||||
"catalognumber": {field: "media_file.catalog_num"},
|
"catalognumber": {field: "media_file.catalog_num"},
|
||||||
"filepath": {field: "media_file.path"},
|
"filepath": {field: "media_file.path"},
|
||||||
@ -55,6 +54,9 @@ var fieldMap = map[string]*mappedField{
|
|||||||
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
|
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
|
||||||
"library_id": {field: "media_file.library_id", numeric: true},
|
"library_id": {field: "media_file.library_id", numeric: true},
|
||||||
|
|
||||||
|
// Backward compatibility: albumtype is an alias for releasetype tag
|
||||||
|
"albumtype": {field: "releasetype", isTag: true},
|
||||||
|
|
||||||
// special fields
|
// special fields
|
||||||
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
|
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
|
||||||
"value": {field: "value"}, // pseudo-field for tag and roles values
|
"value": {field: "value"}, // pseudo-field for tag and roles values
|
||||||
@ -154,13 +156,19 @@ type tagCond struct {
|
|||||||
func (e tagCond) ToSql() (string, []any, error) {
|
func (e tagCond) ToSql() (string, []any, error) {
|
||||||
cond, args, err := e.cond.ToSql()
|
cond, args, err := e.cond.ToSql()
|
||||||
|
|
||||||
// Check if this tag is marked as numeric in the fieldMap
|
// Resolve the actual tag name (handles aliases like albumtype -> releasetype)
|
||||||
if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
|
tagName := e.tag
|
||||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
if fm, ok := fieldMap[e.tag]; ok {
|
||||||
|
if fm.field != "" {
|
||||||
|
tagName = fm.field
|
||||||
|
}
|
||||||
|
if fm.numeric {
|
||||||
|
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
||||||
e.tag, cond)
|
tagName, cond)
|
||||||
if e.not {
|
if e.not {
|
||||||
cond = "not " + cond
|
cond = "not " + cond
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,6 +105,40 @@ var _ = Describe("Operators", func() {
|
|||||||
gomega.Expect(sql).To(gomega.BeEmpty())
|
gomega.Expect(sql).To(gomega.BeEmpty())
|
||||||
gomega.Expect(args).To(gomega.BeEmpty())
|
gomega.Expect(args).To(gomega.BeEmpty())
|
||||||
})
|
})
|
||||||
|
It("supports releasetype as multi-valued tag", func() {
|
||||||
|
AddTagNames([]string{"releasetype"})
|
||||||
|
op := Contains{"releasetype": "soundtrack"}
|
||||||
|
sql, args, err := op.ToSql()
|
||||||
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||||
|
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
|
||||||
|
})
|
||||||
|
It("supports albumtype as alias for releasetype", func() {
|
||||||
|
AddTagNames([]string{"releasetype"})
|
||||||
|
op := Contains{"albumtype": "live"}
|
||||||
|
sql, args, err := op.ToSql()
|
||||||
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||||
|
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
|
||||||
|
})
|
||||||
|
It("supports albumtype alias with Is operator", func() {
|
||||||
|
AddTagNames([]string{"releasetype"})
|
||||||
|
op := Is{"albumtype": "album"}
|
||||||
|
sql, args, err := op.ToSql()
|
||||||
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
|
// Should query $.releasetype, not $.albumtype
|
||||||
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
|
||||||
|
gomega.Expect(args).To(gomega.HaveExactElements("album"))
|
||||||
|
})
|
||||||
|
It("supports albumtype alias with IsNot operator", func() {
|
||||||
|
AddTagNames([]string{"releasetype"})
|
||||||
|
op := IsNot{"albumtype": "compilation"}
|
||||||
|
sql, args, err := op.ToSql()
|
||||||
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
|
// Should query $.releasetype, not $.albumtype
|
||||||
|
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
|
||||||
|
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Custom Roles", func() {
|
Describe("Custom Roles", func() {
|
||||||
|
|||||||
@ -42,6 +42,9 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const formatReleaseType = (record) =>
|
||||||
|
record?.tagValue ? humanize(record?.tagValue) : '-- None --'
|
||||||
|
|
||||||
const AlbumFilter = (props) => {
|
const AlbumFilter = (props) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
@ -142,9 +145,7 @@ const AlbumFilter = (props) => {
|
|||||||
>
|
>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
emptyText="-- None --"
|
emptyText="-- None --"
|
||||||
optionText={(record) =>
|
optionText={formatReleaseType}
|
||||||
record?.tagValue ? humanize(record?.tagValue) : '-- None --'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ReferenceInput>
|
</ReferenceInput>
|
||||||
<NullableBooleanInput source="compilation" />
|
<NullableBooleanInput source="compilation" />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user