catalog.go
5818 bytes
1package main
2
3import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "hash/fnv"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "github.com/dhowden/tag"
14)
15
16// Song is a catalog entry: the file path plus its metadata.
17type Song struct {
18 Path string
19 Title string
20 Artist string
21 Album string
22}
23
24var musicExts = map[string]bool{
25 ".mp3": true, ".flac": true, ".ogg": true, ".oga": true,
26 ".m4a": true, ".aac": true, ".wav": true, ".opus": true,
27 ".wma": true, ".aiff": true, ".aif": true,
28}
29
30// buildCatalog walks dir recursively, keeps music files not under a
31// blacklisted directory, and produces the song catalog. Tag reading is the
32// slow part, so results are cached on disk keyed by file mtime: a fast walk
33// (stat only) collects the current files, unchanged files reuse their cached
34// tags, and only new or modified files are re-parsed.
35func buildCatalog(dir, blacklistPath string) ([]Song, error) {
36 absDir, err := filepath.Abs(dir)
37 if err != nil {
38 return nil, err
39 }
40 blacklistDirs, err := loadBlacklistDirs(absDir, blacklistPath)
41 if err != nil {
42 return nil, err
43 }
44
45 // Fast walk: collect current music files and their mtimes (no tag parsing).
46 type fileRef struct {
47 path string
48 mtime int64
49 }
50 var current []fileRef
51 if walkErr := filepath.Walk(absDir, func(path string, info os.FileInfo, err error) error {
52 if err != nil || info.IsDir() {
53 return nil
54 }
55 if !musicExts[strings.ToLower(filepath.Ext(path))] {
56 return nil
57 }
58 if isExcluded(path, blacklistDirs) {
59 return nil
60 }
61 current = append(current, fileRef{path, info.ModTime().UnixNano()})
62 return nil
63 }); walkErr != nil {
64 return nil, walkErr
65 }
66
67 cachePath := cacheFilePath(absDir, blacklistPath)
68 cached := loadCache(cachePath)
69
70 songs := make([]Song, 0, len(current))
71 newCache := make(map[string]cacheEntry, len(current))
72 for _, fr := range current {
73 if e, ok := cached[fr.path]; ok && e.Mtime == fr.mtime {
74 songs = append(songs, Song{Path: fr.path, Title: e.Title, Artist: e.Artist, Album: e.Album})
75 newCache[fr.path] = e
76 continue
77 }
78 s := readSong(fr.path)
79 songs = append(songs, s)
80 newCache[fr.path] = cacheEntry{Title: s.Title, Artist: s.Artist, Album: s.Album, Mtime: fr.mtime}
81 }
82
83 saveCache(cachePath, newCache)
84 return songs, nil
85}
86
87// loadBlacklistDirs reads a file with one directory per line. Blank lines and
88// lines starting with '#' are ignored. Relative entries are resolved against
89// dir so they match the paths produced by the walk.
90func loadBlacklistDirs(dir, path string) ([]string, error) {
91 if path == "" {
92 return nil, nil
93 }
94 f, err := os.Open(path)
95 if err != nil {
96 return nil, fmt.Errorf("open blacklist: %w", err)
97 }
98 defer func() { _ = f.Close() }()
99
100 var dirs []string
101 scanner := bufio.NewScanner(f)
102 for scanner.Scan() {
103 line := strings.TrimSpace(scanner.Text())
104 if line == "" || strings.HasPrefix(line, "#") {
105 continue
106 }
107 target := line
108 if !filepath.IsAbs(target) {
109 target = filepath.Join(dir, target)
110 }
111 dirs = append(dirs, filepath.Clean(target))
112 }
113 return dirs, scanner.Err()
114}
115
116// isExcluded reports whether path lies inside one of the blacklisted
117// directories (equal to it, or nested beneath it).
118func isExcluded(path string, dirs []string) bool {
119 for _, d := range dirs {
120 if path == d || strings.HasPrefix(path, d+string(filepath.Separator)) {
121 return true
122 }
123 }
124 return false
125}
126
127// readSong reads the title and artist tags from a music file. If tags are
128// missing or unreadable it falls back to the filename without extension, so
129// every music file stays in the catalog.
130func readSong(path string) Song {
131 fallback := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
132 title, artist, album := fallback, "", ""
133
134 if f, err := os.Open(path); err == nil {
135 defer func() { _ = f.Close() }()
136 if m, err := tag.ReadFrom(f); err == nil {
137 if t := m.Title(); t != "" {
138 title = t
139 }
140 artist = m.Artist()
141 album = m.Album()
142 if aa := m.AlbumArtist(); aa != "" {
143 artist = aa
144 }
145 }
146 }
147 return Song{Path: path, Title: title, Artist: artist, Album: album}
148}
149
150// cacheEntry is the cached tag data for a single file, plus the mtime it was
151// read at so staleness can be detected.
152type cacheEntry struct {
153 Title string `json:"title"`
154 Artist string `json:"artist"`
155 Album string `json:"album"`
156 Mtime int64 `json:"mtime"`
157}
158
159// cacheFile is the on-disk catalog cache.
160type cacheFile struct {
161 Files map[string]cacheEntry `json:"files"`
162}
163
164// cacheKey derives a stable filename from the scan parameters so different
165// directories and blacklists get separate caches.
166func cacheKey(absDir, blacklistPath string) string {
167 h := fnv.New64a()
168 _, _ = io.WriteString(h, absDir)
169 _, _ = io.WriteString(h, "\x00")
170 _, _ = io.WriteString(h, blacklistPath)
171 return fmt.Sprintf("%x", h.Sum64())
172}
173
174// cacheFilePath returns where the catalog cache for these parameters is stored.
175func cacheFilePath(absDir, blacklistPath string) string {
176 base, err := os.UserCacheDir()
177 if err != nil {
178 base = os.TempDir()
179 }
180 return filepath.Join(base, "museguessr", cacheKey(absDir, blacklistPath)+".json")
181}
182
183// loadCache reads and parses the cache file, returning nil if it is missing
184// or cannot be read (a miss, not an error).
185func loadCache(path string) map[string]cacheEntry {
186 data, err := os.ReadFile(path)
187 if err != nil {
188 return nil
189 }
190 var cf cacheFile
191 if err := json.Unmarshal(data, &cf); err != nil {
192 return nil
193 }
194 return cf.Files
195}
196
197// saveCache writes the cache file. Failures are ignored: a cache write error
198// should never break the game.
199func saveCache(path string, files map[string]cacheEntry) {
200 cf := cacheFile{Files: files}
201 data, err := json.MarshalIndent(cf, "", " ")
202 if err != nil {
203 return
204 }
205 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
206 return
207 }
208 _ = os.WriteFile(path, data, 0o644)
209}