Foundation: config, schema/migrations, auth, stable IDs, metadata probing
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type ffprobeOutput struct {
|
||||
Format ffprobeFormat `json:"format"`
|
||||
Streams []ffprobeStream `json:"streams"`
|
||||
}
|
||||
|
||||
type ffprobeFormat struct {
|
||||
FormatName string `json:"format_name"`
|
||||
Duration string `json:"duration"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type ffprobeStream struct {
|
||||
Index int `json:"index"`
|
||||
CodecType string `json:"codec_type"` // "audio", "video", "subtitle", ...
|
||||
CodecName string `json:"codec_name"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
Channels int `json:"channels"`
|
||||
Duration string `json:"duration"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Disposition map[string]int `json:"disposition"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
// Prober runs ffprobe to extract tags and technical properties from a
|
||||
// media file.
|
||||
type Prober struct {
|
||||
FFprobePath string
|
||||
}
|
||||
|
||||
// Probe reads metadata from the file at path. path must already be a
|
||||
// validated, absolute filesystem path within a configured music
|
||||
// folder — Probe does not perform any authorization or traversal checks
|
||||
// itself.
|
||||
func (p *Prober) Probe(ctx context.Context, path string) (*Tags, error) {
|
||||
ffprobePath := p.FFprobePath
|
||||
if ffprobePath == "" {
|
||||
ffprobePath = "ffprobe"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
"--", path,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return nil, fmt.Errorf("ffprobe: %w: %s", err, string(ee.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("ffprobe: %w", err)
|
||||
}
|
||||
|
||||
var raw ffprobeOutput
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing ffprobe output: %w", err)
|
||||
}
|
||||
return parseFFprobeOutput(raw), nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProbeRealFile(t *testing.T) {
|
||||
ffmpegPath, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
t.Skip("ffmpeg not installed, skipping integration test")
|
||||
}
|
||||
if _, err := exec.LookPath("ffprobe"); err != nil {
|
||||
t.Skip("ffprobe not installed, skipping integration test")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "test.mp3")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
||||
"-metadata", "title=Integration Title",
|
||||
"-metadata", "artist=Integration Artist",
|
||||
"-metadata", "album=Integration Album",
|
||||
"-metadata", "track=5/10",
|
||||
"-codec:a", "libmp3lame", "-b:a", "64k",
|
||||
out,
|
||||
)
|
||||
if combined, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("ffmpeg failed: %v\n%s", err, combined)
|
||||
}
|
||||
|
||||
p := &Prober{}
|
||||
tags, err := p.Probe(ctx, out)
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
if tags.Title != "Integration Title" {
|
||||
t.Errorf("Title = %q, want %q", tags.Title, "Integration Title")
|
||||
}
|
||||
if len(tags.Artists) != 1 || tags.Artists[0] != "Integration Artist" {
|
||||
t.Errorf("Artists = %v", tags.Artists)
|
||||
}
|
||||
if tags.Album != "Integration Album" {
|
||||
t.Errorf("Album = %q", tags.Album)
|
||||
}
|
||||
if tags.TrackNumber != 5 || tags.TrackTotal != 10 {
|
||||
t.Errorf("Track = %d/%d, want 5/10", tags.TrackNumber, tags.TrackTotal)
|
||||
}
|
||||
if tags.DurationSeconds < 0.9 || tags.DurationSeconds > 1.5 {
|
||||
t.Errorf("DurationSeconds = %v, want ~1", tags.DurationSeconds)
|
||||
}
|
||||
if tags.SampleRateHz == 0 {
|
||||
t.Error("SampleRateHz = 0, want > 0")
|
||||
}
|
||||
if tags.Kind != "song" {
|
||||
t.Errorf("Kind = %q, want song", tags.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeRejectsMissingFile(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffprobe"); err != nil {
|
||||
t.Skip("ffprobe not installed, skipping integration test")
|
||||
}
|
||||
p := &Prober{}
|
||||
if _, err := p.Probe(context.Background(), "/nonexistent/path/does-not-exist.mp3"); err == nil {
|
||||
t.Fatal("expected error probing a nonexistent file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package metadata reads audio/video tags and technical properties via
|
||||
// ffprobe. It shells out with an explicit argument list (never a shell),
|
||||
// so filenames and tag content can never be interpreted as commands.
|
||||
package metadata
|
||||
|
||||
// Tags holds the metadata VibeSonic understands, extracted from a single
|
||||
// media file. Multi-value fields (Artists, Genres) are kept as slices
|
||||
// rather than flattened into a single string, since a song can have
|
||||
// several performers or genres.
|
||||
type Tags struct {
|
||||
Title string
|
||||
SortTitle string
|
||||
|
||||
Album string
|
||||
SortAlbum string
|
||||
AlbumArtist string
|
||||
SortAlbumArtist string
|
||||
|
||||
Artists []string // primary + featured performers, in credit order
|
||||
Composer string
|
||||
Conductor string
|
||||
Contributors []Contributor // producer/engineer/remixer/etc.
|
||||
Genres []string
|
||||
|
||||
Year int
|
||||
OriginalYear int
|
||||
ReleaseDate string
|
||||
OriginalReleaseDate string
|
||||
RecordLabel string
|
||||
|
||||
TrackNumber int
|
||||
TrackTotal int
|
||||
DiscNumber int
|
||||
DiscTotal int
|
||||
DiscSubtitle string
|
||||
|
||||
Comment string
|
||||
ISRC string
|
||||
BPM int
|
||||
|
||||
MusicBrainzTrackID string
|
||||
MusicBrainzRecordingID string
|
||||
MusicBrainzAlbumID string
|
||||
MusicBrainzArtistID string
|
||||
|
||||
ReplayGainTrackGainDB *float64
|
||||
ReplayGainTrackPeak *float64
|
||||
ReplayGainAlbumGainDB *float64
|
||||
ReplayGainAlbumPeak *float64
|
||||
|
||||
HasEmbeddedArt bool
|
||||
EmbeddedArtStream int
|
||||
EmbeddedLyrics string
|
||||
|
||||
// Technical properties, read from the container/stream info rather
|
||||
// than tags.
|
||||
Format string
|
||||
Kind string // "song" or "video"
|
||||
DurationSeconds float64
|
||||
BitrateKbps int
|
||||
SampleRateHz int
|
||||
Channels int
|
||||
}
|
||||
|
||||
// Contributor is a non-performer credit, e.g. {Name: "Rick Rubin", Role: "producer"}.
|
||||
type Contributor struct {
|
||||
Name string
|
||||
Role string
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package metadata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFFprobeOutputBasicTags(t *testing.T) {
|
||||
raw := ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
FormatName: "mp3",
|
||||
Duration: "215.5",
|
||||
BitRate: "320000",
|
||||
Tags: map[string]string{
|
||||
"title": "Test Song",
|
||||
"album": "Test Album",
|
||||
"album_artist": "Album Artist",
|
||||
"artist": "Artist One; Artist Two",
|
||||
"genre": "Rock; Alternative",
|
||||
"date": "2004-05-11",
|
||||
"track": "3/12",
|
||||
"disc": "1/2",
|
||||
"composer": "Some Composer",
|
||||
"isrc": "USRC17607839",
|
||||
"producer": "Rick Rubin",
|
||||
},
|
||||
},
|
||||
Streams: []ffprobeStream{
|
||||
{Index: 0, CodecType: "audio", SampleRate: "44100", Channels: 2, BitRate: "320000"},
|
||||
},
|
||||
}
|
||||
|
||||
tags := parseFFprobeOutput(raw)
|
||||
|
||||
if tags.Title != "Test Song" {
|
||||
t.Errorf("Title = %q", tags.Title)
|
||||
}
|
||||
if tags.Album != "Test Album" {
|
||||
t.Errorf("Album = %q", tags.Album)
|
||||
}
|
||||
if tags.AlbumArtist != "Album Artist" {
|
||||
t.Errorf("AlbumArtist = %q", tags.AlbumArtist)
|
||||
}
|
||||
if len(tags.Artists) != 2 || tags.Artists[0] != "Artist One" || tags.Artists[1] != "Artist Two" {
|
||||
t.Errorf("Artists = %v", tags.Artists)
|
||||
}
|
||||
if len(tags.Genres) != 2 || tags.Genres[0] != "Rock" || tags.Genres[1] != "Alternative" {
|
||||
t.Errorf("Genres = %v", tags.Genres)
|
||||
}
|
||||
if tags.Year != 2004 {
|
||||
t.Errorf("Year = %d, want 2004", tags.Year)
|
||||
}
|
||||
if tags.TrackNumber != 3 || tags.TrackTotal != 12 {
|
||||
t.Errorf("Track = %d/%d, want 3/12", tags.TrackNumber, tags.TrackTotal)
|
||||
}
|
||||
if tags.DiscNumber != 1 || tags.DiscTotal != 2 {
|
||||
t.Errorf("Disc = %d/%d, want 1/2", tags.DiscNumber, tags.DiscTotal)
|
||||
}
|
||||
if tags.ISRC != "USRC17607839" {
|
||||
t.Errorf("ISRC = %q", tags.ISRC)
|
||||
}
|
||||
if len(tags.Contributors) != 1 || tags.Contributors[0].Name != "Rick Rubin" || tags.Contributors[0].Role != "producer" {
|
||||
t.Errorf("Contributors = %v", tags.Contributors)
|
||||
}
|
||||
if tags.DurationSeconds != 215.5 {
|
||||
t.Errorf("DurationSeconds = %v", tags.DurationSeconds)
|
||||
}
|
||||
if tags.Format != "mp3" {
|
||||
t.Errorf("Format = %q", tags.Format)
|
||||
}
|
||||
if tags.SampleRateHz != 44100 || tags.Channels != 2 {
|
||||
t.Errorf("SampleRateHz/Channels = %d/%d", tags.SampleRateHz, tags.Channels)
|
||||
}
|
||||
if tags.BitrateKbps != 320 {
|
||||
t.Errorf("BitrateKbps = %d, want 320", tags.BitrateKbps)
|
||||
}
|
||||
if tags.Kind != "song" {
|
||||
t.Errorf("Kind = %q, want song", tags.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutputArtistWithSlashNotSplit(t *testing.T) {
|
||||
raw := ffprobeOutput{Format: ffprobeFormat{Tags: map[string]string{"artist": "AC/DC"}}}
|
||||
tags := parseFFprobeOutput(raw)
|
||||
if len(tags.Artists) != 1 || tags.Artists[0] != "AC/DC" {
|
||||
t.Errorf("Artists = %v, want [\"AC/DC\"] (must not split band names on '/')", tags.Artists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutputEmbeddedArt(t *testing.T) {
|
||||
raw := ffprobeOutput{
|
||||
Format: ffprobeFormat{Tags: map[string]string{"title": "Song"}},
|
||||
Streams: []ffprobeStream{
|
||||
{Index: 0, CodecType: "audio"},
|
||||
{Index: 1, CodecType: "video", CodecName: "mjpeg", Disposition: map[string]int{"attached_pic": 1}},
|
||||
},
|
||||
}
|
||||
tags := parseFFprobeOutput(raw)
|
||||
if !tags.HasEmbeddedArt {
|
||||
t.Error("HasEmbeddedArt = false, want true")
|
||||
}
|
||||
if tags.EmbeddedArtStream != 1 {
|
||||
t.Errorf("EmbeddedArtStream = %d, want 1", tags.EmbeddedArtStream)
|
||||
}
|
||||
if tags.Kind != "song" {
|
||||
t.Errorf("Kind = %q, want song (attached_pic stream should not make this a video)", tags.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutputRealVideoStream(t *testing.T) {
|
||||
raw := ffprobeOutput{
|
||||
Format: ffprobeFormat{Tags: map[string]string{"title": "Music Video"}},
|
||||
Streams: []ffprobeStream{
|
||||
{Index: 0, CodecType: "video", CodecName: "h264"},
|
||||
{Index: 1, CodecType: "audio"},
|
||||
},
|
||||
}
|
||||
tags := parseFFprobeOutput(raw)
|
||||
if tags.Kind != "video" {
|
||||
t.Errorf("Kind = %q, want video", tags.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutputReplayGain(t *testing.T) {
|
||||
raw := ffprobeOutput{Format: ffprobeFormat{Tags: map[string]string{
|
||||
"replaygain_track_gain": "-6.30 dB",
|
||||
"replaygain_track_peak": "0.987654",
|
||||
}}}
|
||||
tags := parseFFprobeOutput(raw)
|
||||
if tags.ReplayGainTrackGainDB == nil || *tags.ReplayGainTrackGainDB != -6.30 {
|
||||
t.Errorf("ReplayGainTrackGainDB = %v, want -6.30", tags.ReplayGainTrackGainDB)
|
||||
}
|
||||
if tags.ReplayGainTrackPeak == nil || *tags.ReplayGainTrackPeak != 0.987654 {
|
||||
t.Errorf("ReplayGainTrackPeak = %v, want 0.987654", tags.ReplayGainTrackPeak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutputCaseInsensitiveTags(t *testing.T) {
|
||||
raw := ffprobeOutput{Format: ffprobeFormat{Tags: map[string]string{"ALBUMARTIST": "Vorbis Style Artist"}}}
|
||||
tags := parseFFprobeOutput(raw)
|
||||
if tags.AlbumArtist != "Vorbis Style Artist" {
|
||||
t.Errorf("AlbumArtist = %q, want case-insensitive match", tags.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutputNoTags(t *testing.T) {
|
||||
raw := ffprobeOutput{Format: ffprobeFormat{Duration: "10"}}
|
||||
tags := parseFFprobeOutput(raw)
|
||||
if tags.Title != "" || tags.Artists != nil {
|
||||
t.Errorf("expected empty tags, got %+v", tags)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user