234 lines
6.3 KiB
Go
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]
|
|
}
|