package main import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "time" ) // NoLyricsCache tracks songs that previously had no results. type NoLyricsCache struct { Tracks map[string]time.Time mutex sync.RWMutex } func loadCache(cacheFile string) (*NoLyricsCache, error) { cache := &NoLyricsCache{Tracks: make(map[string]time.Time)} if _, err := os.Stat(cacheFile); os.IsNotExist(err) { return cache, nil } data, err := os.ReadFile(cacheFile) if err != nil { return cache, err } if len(data) == 0 { return cache, nil } err = json.Unmarshal(data, &cache.Tracks) if cache.Tracks == nil { cache.Tracks = make(map[string]time.Time) } return cache, err } func (c *NoLyricsCache) SaveCache(cacheFile string, ttl time.Duration) error { cacheSnapshot := c.prunedCopy(ttl) data, err := json.MarshalIndent(cacheSnapshot, "", " ") if err != nil { return err } return writeFileAtomically(cacheFile, data, 0644) } func (c *NoLyricsCache) IsTrackedAsNoLyrics(trackID string, ttl time.Duration) bool { c.mutex.RLock() timestamp, exists := c.Tracks[trackID] c.mutex.RUnlock() if !exists { return false } if isCacheEntryExpired(timestamp, ttl) { c.mutex.Lock() if ts, ok := c.Tracks[trackID]; ok && isCacheEntryExpired(ts, ttl) { delete(c.Tracks, trackID) } c.mutex.Unlock() return false } return true } func (c *NoLyricsCache) AddNoLyrics(trackID string) { c.mutex.Lock() defer c.mutex.Unlock() c.Tracks[trackID] = time.Now() } func (c *NoLyricsCache) prunedCopy(ttl time.Duration) map[string]time.Time { c.mutex.Lock() defer c.mutex.Unlock() for trackID, timestamp := range c.Tracks { if isCacheEntryExpired(timestamp, ttl) { delete(c.Tracks, trackID) } } cacheSnapshot := make(map[string]time.Time, len(c.Tracks)) for trackID, timestamp := range c.Tracks { cacheSnapshot[trackID] = timestamp } return cacheSnapshot } func createTrackIdentifier(metadata Metadata) string { return fmt.Sprintf("%s-%s-%s", strings.ToLower(strings.TrimSpace(metadata.ArtistName)), strings.ToLower(strings.TrimSpace(metadata.AlbumName)), strings.ToLower(strings.TrimSpace(metadata.TrackName))) } func isCacheEntryExpired(timestamp time.Time, ttl time.Duration) bool { if ttl <= 0 { return false } return time.Since(timestamp) > ttl } func writeFileAtomically(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) base := filepath.Base(path) tempFile, err := os.CreateTemp(dir, base+".tmp-*") if err != nil { return err } tempPath := tempFile.Name() cleanup := func() { tempFile.Close() os.Remove(tempPath) } if _, err := tempFile.Write(data); err != nil { cleanup() return err } if err := tempFile.Chmod(perm); err != nil { cleanup() return err } if err := tempFile.Close(); err != nil { os.Remove(tempPath) return err } if err := os.Rename(tempPath, path); err != nil { os.Remove(tempPath) return err } return nil }