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] }