summaryrefslogtreecommitdiff
path: root/internal/tag/tag.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tag/tag.go')
-rw-r--r--internal/tag/tag.go132
1 files changed, 132 insertions, 0 deletions
diff --git a/internal/tag/tag.go b/internal/tag/tag.go
new file mode 100644
index 0000000..653fac7
--- /dev/null
+++ b/internal/tag/tag.go
@@ -0,0 +1,132 @@
+// 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
+}