Foundation: config, schema/migrations, auth, stable IDs, metadata probing

This commit is contained in:
2026-08-22 23:29:16 -05:00
commit d4ea7a4ca9
40 changed files with 3874 additions and 0 deletions
+69
View File
@@ -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")
}
}
+69
View 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
}
+233
View File
@@ -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]
}
+149
View File
@@ -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)
}
}