Files
vibesonic/internal/metadata/parse.go
T

234 lines
6.3 KiB
Go

package metadata
import (
"strconv"
"strings"
)
// parseFFprobeOutput maps ffprobe's raw JSON (format+stream tags, which
// vary in casing and naming between containers/taggers) onto our Tags
// struct. It is a pure function so it can be tested without invoking
// ffprobe.
func parseFFprobeOutput(raw ffprobeOutput) *Tags {
tags := raw.Format.Tags
if tags == nil {
tags = map[string]string{}
}
t := &Tags{
Title: getTag(tags, "title"),
SortTitle: getTag(tags, "title-sort", "titlesort", "sort_name"),
Album: getTag(tags, "album"),
SortAlbum: getTag(tags, "album-sort", "albumsort"),
AlbumArtist: getTag(tags, "album_artist", "albumartist", "band"),
SortAlbumArtist: getTag(tags, "albumartistsort", "album_artist_sort"),
Composer: getTag(tags, "composer"),
Conductor: getTag(tags, "conductor"),
Comment: getTag(tags, "comment"),
ISRC: getTag(tags, "isrc"),
RecordLabel: getTag(tags, "label", "publisher", "organization"),
ReleaseDate: getTag(tags, "date", "release_date"),
OriginalReleaseDate: getTag(tags, "original_release_date", "originaldate"),
DiscSubtitle: getTag(tags, "discsubtitle", "disc_subtitle"),
MusicBrainzTrackID: getTag(tags, "musicbrainz_releasetrackid", "musicbrainz_trackid"),
MusicBrainzRecordingID: getTag(tags, "musicbrainz_trackid", "musicbrainz_recording_id"),
MusicBrainzAlbumID: getTag(tags, "musicbrainz_albumid"),
MusicBrainzArtistID: getTag(tags, "musicbrainz_artistid"),
}
artistField := getTag(tags, "artist")
t.Artists = splitMulti(artistField)
genreField := getTag(tags, "genre")
t.Genres = splitMulti(genreField)
t.Contributors = parseContributors(tags)
t.Year = parseYear(t.ReleaseDate)
if t.Year == 0 {
t.Year = atoiOr(getTag(tags, "year"), 0)
}
t.OriginalYear = parseYear(t.OriginalReleaseDate)
t.TrackNumber, t.TrackTotal = parseNumOfTotal(getTag(tags, "track"))
t.DiscNumber, t.DiscTotal = parseNumOfTotal(getTag(tags, "disc"))
t.BPM = atoiOr(getTag(tags, "bpm", "tbpm"), 0)
t.ReplayGainTrackGainDB = parseGainDB(getTag(tags, "replaygain_track_gain"))
t.ReplayGainTrackPeak = parseFloatPtr(getTag(tags, "replaygain_track_peak"))
t.ReplayGainAlbumGainDB = parseGainDB(getTag(tags, "replaygain_album_gain"))
t.ReplayGainAlbumPeak = parseFloatPtr(getTag(tags, "replaygain_album_peak"))
t.DurationSeconds = atofOr(raw.Format.Duration, 0)
t.Format = normalizeFormatName(raw.Format.FormatName)
t.Kind = "song"
for _, s := range raw.Streams {
switch s.CodecType {
case "audio":
if t.SampleRateHz == 0 {
t.SampleRateHz = int(atofOr(s.SampleRate, 0))
}
if t.Channels == 0 {
t.Channels = s.Channels
}
if lyrics := getTag(s.Tags, "lyrics", "unsyncedlyrics"); lyrics != "" && t.EmbeddedLyrics == "" {
t.EmbeddedLyrics = lyrics
}
case "video":
if s.Disposition["attached_pic"] == 1 {
t.HasEmbeddedArt = true
t.EmbeddedArtStream = s.Index
} else {
// A non-attached-pic video stream means this is an
// actual video file, not audio with cover art.
t.Kind = "video"
}
}
}
bitrate := raw.Format.BitRate
if bitrate == "" && len(raw.Streams) > 0 {
bitrate = raw.Streams[0].BitRate
}
t.BitrateKbps = int(atofOr(bitrate, 0) / 1000)
return t
}
// getTag looks up the first present key from candidates, case-
// insensitively — different containers/taggers spell the same field
// differently (e.g. ID3's TPE2 surfaces as "album_artist", Vorbis
// comments use "ALBUMARTIST").
func getTag(tags map[string]string, candidates ...string) string {
if len(tags) == 0 {
return ""
}
lower := make(map[string]string, len(tags))
for k, v := range tags {
lower[strings.ToLower(k)] = v
}
for _, c := range candidates {
if v, ok := lower[strings.ToLower(c)]; ok && v != "" {
return v
}
}
return ""
}
// splitMulti splits a multi-value tag on the conventional MusicBrainz
// Picard separator "; ". It deliberately does not split on "/" or "&",
// which commonly appear inside single artist/band names (e.g. "AC/DC").
func splitMulti(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ";")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func parseContributors(tags map[string]string) []Contributor {
roles := []string{"producer", "engineer", "mixer", "remixer", "arranger", "lyricist", "writer"}
var out []Contributor
for _, role := range roles {
v := getTag(tags, role)
if v == "" {
continue
}
for _, name := range splitMulti(v) {
out = append(out, Contributor{Name: name, Role: role})
}
}
return out
}
// parseNumOfTotal parses "3", "3/12", or "3 of 12" style track/disc tags.
func parseNumOfTotal(s string) (num, total int) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0
}
sep := "/"
if strings.Contains(s, " of ") {
sep = " of "
}
parts := strings.SplitN(s, sep, 2)
num = atoiOr(strings.TrimSpace(parts[0]), 0)
if len(parts) == 2 {
total = atoiOr(strings.TrimSpace(parts[1]), 0)
}
return num, total
}
// parseYear extracts a 4-digit year from the start of a date string like
// "2004-05-11" or "2004".
func parseYear(s string) int {
s = strings.TrimSpace(s)
if len(s) < 4 {
return 0
}
return atoiOr(s[:4], 0)
}
// parseGainDB parses a ReplayGain string like "-6.30 dB" into a plain dB
// float.
func parseGainDB(s string) *float64 {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, "dB")
s = strings.TrimSuffix(s, "DB")
return parseFloatPtr(strings.TrimSpace(s))
}
func parseFloatPtr(s string) *float64 {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
return &f
}
func atoiOr(s string, def int) int {
s = strings.TrimSpace(s)
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func atofOr(s string, def float64) float64 {
s = strings.TrimSpace(s)
if s == "" {
return def
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return def
}
return f
}
// normalizeFormatName picks a short, stable format label from ffprobe's
// often multi-valued format_name (e.g. "mov,mp4,m4a,3gp,3g2,mj2").
func normalizeFormatName(formatName string) string {
if formatName == "" {
return ""
}
return strings.Split(formatName, ",")[0]
}