Parent directory

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}