Add native MP3 metadata writer and prefer it over ffmpeg for .mp3 files

This commit is contained in:
vcadoux
2026-01-27 15:37:27 +01:00
parent 69b54f1ae8
commit a58f930d58
14 changed files with 681 additions and 74 deletions

View File

@@ -35,22 +35,24 @@ var (
// FileInfo describes a file.
type FileInfo struct {
*Listing
Fs afero.Fs `json:"-"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
Extension string `json:"extension"`
ModTime time.Time `json:"modified"`
Mode os.FileMode `json:"mode"`
IsDir bool `json:"isDir"`
IsSymlink bool `json:"isSymlink"`
Type string `json:"type"`
Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"`
currentDir []os.FileInfo `json:"-"`
Resolution *ImageResolution `json:"resolution,omitempty"`
Fs afero.Fs `json:"-"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
Extension string `json:"extension"`
ModTime time.Time `json:"modified"`
Mode os.FileMode `json:"mode"`
IsDir bool `json:"isDir"`
IsSymlink bool `json:"isSymlink"`
Type string `json:"type"`
Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
// Tags holds audio metadata (ID3, VorbisComment, MP4 tags, ...)
Tags map[string]interface{} `json:"tags,omitempty"`
Token string `json:"token,omitempty"`
currentDir []os.FileInfo `json:"-"`
Resolution *ImageResolution `json:"resolution,omitempty"`
}
// FileOptions are the options when getting a file info.

100
files/tags.go Normal file
View File

@@ -0,0 +1,100 @@
package files
import (
"bytes"
"encoding/base64"
"io"
"log"
"strings"
"github.com/dhowden/tag"
)
// ReadAudioTags extracts common audio metadata from the file and stores it in
// the FileInfo.Tags map. It attempts to extract common fields and includes
// embedded picture(s) as base64 where present.
func (i *FileInfo) ReadAudioTags() error {
if i.IsDir {
return nil
}
f, err := i.Fs.Open(i.Path)
if err != nil {
return err
}
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil {
// don't fail hard; log and return nil so callers still get file info
log.Printf("ReadAudioTags: failed to read tags for %s: %v", i.Path, err)
return nil
}
tags := map[string]interface{}{}
if v := m.Title(); v != "" {
tags["title"] = v
}
if v := m.Album(); v != "" {
tags["album"] = v
}
if v := m.Artist(); v != "" {
tags["artist"] = v
}
if v := m.AlbumArtist(); v != "" {
tags["albumartist"] = v
}
if v := m.Composer(); v != "" {
tags["composer"] = v
}
if v := m.Year(); v != 0 {
tags["year"] = v
}
if t, _ := m.Track(); t > 0 {
tags["track"] = t
}
if dnum, _ := m.Disc(); dnum > 0 {
tags["disc"] = dnum
}
if v := m.Genre(); v != "" {
tags["genre"] = v
}
// Pictures: encode as data URI (base64) so frontend can preview if needed.
if pic := m.Picture(); pic != nil {
// safe encode image bytes as base64
var buf bytes.Buffer
if _, err := io.Copy(&buf, bytes.NewReader(pic.Data)); err == nil {
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
dataURI := "data:" + pic.MIMEType + ";base64," + b64
tags["picture"] = map[string]interface{}{
"mime": pic.MIMEType,
"data": dataURI,
}
}
}
// Keep raw metadata map if available (some formats expose additional fields)
if raw := m.Raw(); raw != nil {
for k, v := range raw {
// Skip raw APIC entries (attached picture frame) to avoid
// exposing large binary blobs. We already expose a friendly
// `picture` key above when a picture exists.
if strings.EqualFold(k, "APIC") {
continue
}
// avoid overwriting already set common fields
if _, ok := tags[k]; !ok {
tags[k] = v
}
}
}
// Attach tags map
if len(tags) > 0 {
i.Tags = tags
}
return nil
}

66
files/write_mp3.go Normal file
View File

@@ -0,0 +1,66 @@
package files
import (
"fmt"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
)
// applyMP3Tags writes only the provided non-empty tags to the MP3 file at
// realPath. It preserves existing frames when not overwritten.
func applyMP3Tags(realPath string, tags map[string]string) error {
// Open tag for read/write
tag, err := id3v2.Open(realPath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("open id3v2: %w", err)
}
defer tag.Close()
// Helper to set a text frame (replace existing)
setFrame := func(frameID, value string) {
if strings.TrimSpace(value) == "" {
return
}
tag.DeleteAllFrames(frameID)
tag.AddTextFrame(frameID, tag.DefaultEncoding(), value)
}
for k, v := range tags {
if strings.TrimSpace(v) == "" {
continue
}
switch strings.ToLower(k) {
case "title":
setFrame("TIT2", v)
case "artist":
setFrame("TPE1", v)
case "album":
setFrame("TALB", v)
case "albumartist", "album artist":
setFrame("TPE2", v)
case "composer":
setFrame("TCOM", v)
case "year", "date":
setFrame("TDRC", v)
case "track":
setFrame("TRCK", v)
case "disc":
setFrame("TPOS", v)
case "genre":
setFrame("TCON", v)
default:
// write custom text frame under TXXX with description = key
// Use description to store the original key so we don't collide
// with standard frames.
// Note: bogem/id3v2 uses AddTextFrame for TXXX as well.
tag.AddTextFrame("TXXX", tag.DefaultEncoding(), v)
}
}
if err := tag.Save(); err != nil {
return fmt.Errorf("save id3v2: %w", err)
}
return nil
}