summaryrefslogtreecommitdiff
path: root/internal/tag/tag.go
blob: 653fac7ef2ec25d734c8688de937d0821fa2db0f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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
}