// Package tag reads and writes ID3v2 tags on downloaded audio. package tag import ( "fmt" "os" "path/filepath" "strconv" "strings" "github.com/bogem/id3v2/v2" "podkit/internal/feed" ) // Metadata is the subset of ID3 fields worth setting for a lecture series. type Metadata struct { Album string Artist string AlbumArtist string Genre string Year string } // Result reports what happened to one file. type Result struct { File string Track int Title string Err error } // Read returns the title and track number stored in a file's tags. // // Signature matches feed.TagReader so the feed generator can prefer real tags // over a filename guess. A file with no tags is not an error — it is the // normal state of a fresh download — so it reports ok=false and lets the // caller fall back. func Read(path string) (title string, track int, ok bool) { t, err := id3v2.Open(path, id3v2.Options{Parse: true}) if err != nil { return "", 0, false } defer func() { _ = t.Close() }() title = strings.TrimSpace(t.Title()) // A track frame can be "3" or "3/7"; only the part before the slash is the // number of this track. raw := strings.TrimSpace(t.GetTextFrame(t.CommonID("Track number/Position in set")).Text) if i := strings.IndexByte(raw, '/'); i >= 0 { raw = raw[:i] } if n, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { track = n } return title, track, title != "" || track > 0 } // WriteDir tags every audio file in dir, deriving the track number and title // from the filename. // // Only .mp3 is handled: ID3v2 is an MP3 container convention, and writing it // into an .opus or .m4a file produces something no player will read. func WriteDir(dir string, md Metadata) ([]Result, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading %s: %w", dir, err) } var results []Result total := 0 for _, e := range entries { if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".mp3") { total++ } } for _, e := range entries { if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".mp3") { continue } path := filepath.Join(dir, e.Name()) track, title := feed.ParseName(e.Name()) res := Result{File: e.Name(), Track: track, Title: title} if err := writeOne(path, track, total, title, md); err != nil { res.Err = err } results = append(results, res) } return results, nil } func writeOne(path string, track, total int, title string, md Metadata) error { // Parse:true so an existing tag is preserved and updated rather than // replaced wholesale, which would discard artwork yt-dlp may have embedded. t, err := id3v2.Open(path, id3v2.Options{Parse: true}) if err != nil { return fmt.Errorf("opening: %w", err) } defer func() { _ = t.Close() }() t.SetTitle(title) if md.Album != "" { t.SetAlbum(md.Album) } if md.Artist != "" { t.SetArtist(md.Artist) } if md.Genre != "" { t.SetGenre(md.Genre) } if md.Year != "" { t.SetYear(md.Year) } if md.AlbumArtist != "" { t.AddTextFrame(t.CommonID("Band/Orchestra/Accompaniment"), t.DefaultEncoding(), md.AlbumArtist) } if track > 0 { // "n/total" so a player can show "3 of 7" and, more importantly, sort // the series correctly when the filenames are not available to it. value := strconv.Itoa(track) if total > 0 { value = fmt.Sprintf("%d/%d", track, total) } t.AddTextFrame(t.CommonID("Track number/Position in set"), t.DefaultEncoding(), value) } if err := t.Save(); err != nil { return fmt.Errorf("saving tags: %w", err) } return nil }