Foundation: config, schema/migrations, auth, stable IDs, metadata probing
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user