mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-25 21:40:57 +02:00
87b46a9694
The index was rebuilt from scratch every 5 minutes even while AddToISRCIndex kept it write-consistent after every download, and each rebuild parsed the FULL metadata of every FLAC in the library — including multi-megabyte embedded cover art — sequentially, with the triggering download blocked behind the build lock. For a few thousand files that is gigabytes of flash I/O per rebuild, repeated throughout a long batch. - Indexing now reads only the Vorbis comment block (block headers are walked and picture/padding payloads seeked past, never loaded) - A per-file (size, mtime) -> ISRC cache carries across rebuilds, so a rebuild is normally a stat walk that re-reads only changed files - Add() refreshes the index timestamp, so the TTL no longer forces a rebuild in the middle of the workload the index exists to serve - Cold builds parse with 4 workers, matching the library scanner
425 lines
9.7 KiB
Go
425 lines
9.7 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// isrcFileEntry caches the parse result for one file so index rebuilds only
|
|
// re-read files whose size or mtime changed.
|
|
type isrcFileEntry struct {
|
|
size int64
|
|
modTime int64 // UnixNano
|
|
isrc string // uppercase; empty when the file carries no ISRC tag
|
|
}
|
|
|
|
type ISRCIndex struct {
|
|
index map[string]string // ISRC (uppercase) -> file path
|
|
files map[string]isrcFileEntry // file path -> cached parse result
|
|
outputDir string
|
|
buildTime atomic.Int64 // UnixNano of the last build or write
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
var (
|
|
isrcIndexCache = make(map[string]*ISRCIndex)
|
|
isrcIndexCacheMu sync.RWMutex
|
|
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
|
isrcIndexTTL = 5 * time.Minute
|
|
|
|
isrcIndexBuildWorkers = 4
|
|
)
|
|
|
|
func (idx *ISRCIndex) isFresh() bool {
|
|
return time.Since(time.Unix(0, idx.buildTime.Load())) < isrcIndexTTL
|
|
}
|
|
|
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
|
isrcIndexCacheMu.RLock()
|
|
idx, exists := isrcIndexCache[outputDir]
|
|
isrcIndexCacheMu.RUnlock()
|
|
|
|
if exists && idx.isFresh() {
|
|
return idx
|
|
}
|
|
|
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
|
mu := buildLock.(*sync.Mutex)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
isrcIndexCacheMu.RLock()
|
|
idx, exists = isrcIndexCache[outputDir]
|
|
isrcIndexCacheMu.RUnlock()
|
|
|
|
if exists && idx.isFresh() {
|
|
return idx
|
|
}
|
|
|
|
return buildISRCIndex(outputDir)
|
|
}
|
|
|
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
|
idx := &ISRCIndex{
|
|
index: make(map[string]string),
|
|
files: make(map[string]isrcFileEntry),
|
|
outputDir: outputDir,
|
|
}
|
|
idx.buildTime.Store(time.Now().UnixNano())
|
|
|
|
if outputDir == "" {
|
|
return idx
|
|
}
|
|
|
|
// Reuse the previous build's per-file cache: files whose size and mtime
|
|
// are unchanged are adopted without touching their content, so a rebuild
|
|
// is normally just a stat walk.
|
|
prevFiles := map[string]isrcFileEntry{}
|
|
isrcIndexCacheMu.RLock()
|
|
if prev, ok := isrcIndexCache[outputDir]; ok {
|
|
prev.mu.RLock()
|
|
for path, entry := range prev.files {
|
|
prevFiles[path] = entry
|
|
}
|
|
prev.mu.RUnlock()
|
|
}
|
|
isrcIndexCacheMu.RUnlock()
|
|
|
|
startTime := time.Now()
|
|
type parseTask struct {
|
|
path string
|
|
size int64
|
|
modTime int64
|
|
}
|
|
var toParse []parseTask
|
|
reused := 0
|
|
|
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if ext != ".flac" {
|
|
return nil
|
|
}
|
|
|
|
size := info.Size()
|
|
modTime := info.ModTime().UnixNano()
|
|
if entry, ok := prevFiles[path]; ok && entry.size == size && entry.modTime == modTime {
|
|
idx.files[path] = entry
|
|
if entry.isrc != "" {
|
|
idx.index[entry.isrc] = path
|
|
}
|
|
reused++
|
|
return nil
|
|
}
|
|
toParse = append(toParse, parseTask{path: path, size: size, modTime: modTime})
|
|
return nil
|
|
})
|
|
|
|
if len(toParse) > 0 {
|
|
// New/changed files: read only their Vorbis comment block, in
|
|
// parallel. Embedded cover art (megabytes per file) is never loaded.
|
|
isrcs := make([]string, len(toParse))
|
|
workerCount := isrcIndexBuildWorkers
|
|
if len(toParse) < workerCount {
|
|
workerCount = len(toParse)
|
|
}
|
|
tasks := make(chan int)
|
|
var wg sync.WaitGroup
|
|
for w := 0; w < workerCount; w++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := range tasks {
|
|
isrcs[i] = strings.ToUpper(readFlacISRC(toParse[i].path))
|
|
}
|
|
}()
|
|
}
|
|
for i := range toParse {
|
|
tasks <- i
|
|
}
|
|
close(tasks)
|
|
wg.Wait()
|
|
|
|
for i, task := range toParse {
|
|
entry := isrcFileEntry{size: task.size, modTime: task.modTime, isrc: isrcs[i]}
|
|
idx.files[task.path] = entry
|
|
if entry.isrc != "" {
|
|
idx.index[entry.isrc] = task.path
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files (%d parsed, %d cached) in %v\n",
|
|
outputDir, len(idx.files), len(toParse), reused, time.Since(startTime).Round(time.Millisecond))
|
|
|
|
isrcIndexCacheMu.Lock()
|
|
isrcIndexCache[outputDir] = idx
|
|
isrcIndexCacheMu.Unlock()
|
|
|
|
return idx
|
|
}
|
|
|
|
// readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking
|
|
// the metadata block headers and reading only the VORBIS_COMMENT payload;
|
|
// picture and padding blocks are seeked past, never loaded. Returns "" when
|
|
// the file is not FLAC or carries no ISRC tag.
|
|
func readFlacISRC(path string) string {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer f.Close()
|
|
|
|
magic := make([]byte, 4)
|
|
if _, err := io.ReadFull(f, magic); err != nil || string(magic) != "fLaC" {
|
|
return ""
|
|
}
|
|
|
|
header := make([]byte, 4)
|
|
for {
|
|
if _, err := io.ReadFull(f, header); err != nil {
|
|
return ""
|
|
}
|
|
last := header[0]&0x80 != 0
|
|
blockType := header[0] & 0x7F
|
|
length := int64(header[1])<<16 | int64(header[2])<<8 | int64(header[3])
|
|
if blockType == 4 { // VORBIS_COMMENT
|
|
if length > 16<<20 {
|
|
return ""
|
|
}
|
|
payload := make([]byte, length)
|
|
if _, err := io.ReadFull(f, payload); err != nil {
|
|
return ""
|
|
}
|
|
return vorbisCommentISRC(payload)
|
|
}
|
|
if last {
|
|
return ""
|
|
}
|
|
if _, err := f.Seek(length, io.SeekCurrent); err != nil {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
func vorbisCommentISRC(payload []byte) string {
|
|
if len(payload) < 8 {
|
|
return ""
|
|
}
|
|
offset := int(binary.LittleEndian.Uint32(payload[0:4])) + 4
|
|
if offset < 4 || offset+4 > len(payload) {
|
|
return ""
|
|
}
|
|
count := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
|
|
offset += 4
|
|
for i := 0; i < count; i++ {
|
|
if offset+4 > len(payload) {
|
|
return ""
|
|
}
|
|
commentLen := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
|
|
offset += 4
|
|
if commentLen < 0 || offset+commentLen > len(payload) {
|
|
return ""
|
|
}
|
|
comment := payload[offset : offset+commentLen]
|
|
offset += commentLen
|
|
eq := strings.IndexByte(string(comment), '=')
|
|
if eq > 0 && strings.EqualFold(string(comment[:eq]), "ISRC") {
|
|
return strings.TrimSpace(string(comment[eq+1:]))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|
if isrc == "" {
|
|
return "", false
|
|
}
|
|
|
|
idx.mu.RLock()
|
|
defer idx.mu.RUnlock()
|
|
|
|
path, exists := idx.index[strings.ToUpper(isrc)]
|
|
return path, exists
|
|
}
|
|
|
|
func (idx *ISRCIndex) remove(isrc string) {
|
|
if isrc == "" {
|
|
return
|
|
}
|
|
|
|
idx.mu.Lock()
|
|
defer idx.mu.Unlock()
|
|
|
|
delete(idx.index, strings.ToUpper(isrc))
|
|
}
|
|
|
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
|
path, _ := idx.lookup(isrc)
|
|
return path, nil
|
|
}
|
|
|
|
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
|
if isrc == "" || filePath == "" {
|
|
return
|
|
}
|
|
|
|
upper := strings.ToUpper(isrc)
|
|
var entry *isrcFileEntry
|
|
if info, err := os.Stat(filePath); err == nil {
|
|
entry = &isrcFileEntry{
|
|
size: info.Size(),
|
|
modTime: info.ModTime().UnixNano(),
|
|
isrc: upper,
|
|
}
|
|
}
|
|
|
|
idx.mu.Lock()
|
|
idx.index[upper] = filePath
|
|
if entry != nil {
|
|
if idx.files == nil {
|
|
idx.files = make(map[string]isrcFileEntry)
|
|
}
|
|
idx.files[filePath] = *entry
|
|
}
|
|
idx.mu.Unlock()
|
|
|
|
// The index is write-maintained after every successful download;
|
|
// refreshing the timestamp keeps the TTL from forcing a full rebuild in
|
|
// the middle of the exact workload the index exists to serve.
|
|
idx.buildTime.Store(time.Now().UnixNano())
|
|
}
|
|
|
|
func InvalidateISRCCache(outputDir string) {
|
|
isrcIndexCacheMu.Lock()
|
|
delete(isrcIndexCache, outputDir)
|
|
isrcIndexCacheMu.Unlock()
|
|
}
|
|
|
|
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|
if isrc == "" || outputDir == "" {
|
|
return "", false
|
|
}
|
|
|
|
idx := GetISRCIndex(outputDir)
|
|
filePath, exists := idx.lookup(isrc)
|
|
if !exists {
|
|
return "", false
|
|
}
|
|
|
|
if !CheckFileExists(filePath) {
|
|
// Stale index entry; remove it and return not found.
|
|
idx.remove(isrc)
|
|
return "", false
|
|
}
|
|
|
|
return filePath, true
|
|
}
|
|
|
|
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
|
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
|
return filepath, nil
|
|
}
|
|
|
|
func CheckFileExists(filePath string) bool {
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return !info.IsDir() && info.Size() > 0
|
|
}
|
|
|
|
type FileExistenceResult struct {
|
|
ISRC string `json:"isrc"`
|
|
Exists bool `json:"exists"`
|
|
FilePath string `json:"file_path,omitempty"`
|
|
TrackName string `json:"track_name,omitempty"`
|
|
ArtistName string `json:"artist_name,omitempty"`
|
|
}
|
|
|
|
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
|
var tracks []struct {
|
|
ISRC string `json:"isrc"`
|
|
TrackName string `json:"track_name"`
|
|
ArtistName string `json:"artist_name"`
|
|
}
|
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
|
return "", fmt.Errorf("failed to parse tracks JSON: %w", err)
|
|
}
|
|
|
|
results := make([]FileExistenceResult, len(tracks))
|
|
|
|
isrcIdx := GetISRCIndex(outputDir)
|
|
|
|
var wg sync.WaitGroup
|
|
for i, track := range tracks {
|
|
wg.Add(1)
|
|
go func(resultIdx int, t struct {
|
|
ISRC string `json:"isrc"`
|
|
TrackName string `json:"track_name"`
|
|
ArtistName string `json:"artist_name"`
|
|
}) {
|
|
defer wg.Done()
|
|
|
|
result := FileExistenceResult{
|
|
ISRC: t.ISRC,
|
|
TrackName: t.TrackName,
|
|
ArtistName: t.ArtistName,
|
|
Exists: false,
|
|
}
|
|
|
|
if t.ISRC != "" {
|
|
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
|
|
result.Exists = true
|
|
result.FilePath = filePath
|
|
}
|
|
}
|
|
|
|
results[resultIdx] = result
|
|
}(i, track)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
resultJSON, err := json.Marshal(results)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal results: %w", err)
|
|
}
|
|
|
|
return string(resultJSON), nil
|
|
}
|
|
|
|
func PreBuildISRCIndex(outputDir string) error {
|
|
if outputDir == "" {
|
|
return fmt.Errorf("output directory is required")
|
|
}
|
|
|
|
buildISRCIndex(outputDir)
|
|
return nil
|
|
}
|
|
|
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
|
if outputDir == "" || isrc == "" || filePath == "" {
|
|
return
|
|
}
|
|
|
|
isrcIndexCacheMu.RLock()
|
|
idx, exists := isrcIndexCache[outputDir]
|
|
isrcIndexCacheMu.RUnlock()
|
|
|
|
if exists {
|
|
idx.Add(isrc, filePath)
|
|
}
|
|
}
|