Add native MP3 metadata writer and prefer it over ffmpeg for .mp3 files
This commit is contained in:
@@ -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
100
files/tags.go
Normal 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
66
files/write_mp3.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user