perf(library): scan flac and m4a in one pass

This commit is contained in:
zarzet
2026-07-16 10:47:29 +07:00
parent 5846dc2416
commit c13fdcc6e1
4 changed files with 305 additions and 47 deletions
+39 -22
View File
@@ -1575,39 +1575,56 @@ func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
return cacheKey
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) {
hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
return jpgPath, pngPath
}
func existingLibraryCoverCachePath(cacheDir, cacheKey string) string {
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
if _, err := os.Stat(jpgPath); err == nil {
return jpgPath, nil
return jpgPath
}
if _, err := os.Stat(pngPath); err == nil {
return pngPath, nil
return pngPath
}
return ""
}
func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) {
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
if len(imageData) == 0 {
return "", fmt.Errorf("cover data is empty")
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
cachePath := jpgPath
if strings.Contains(mimeType, "png") {
cachePath = pngPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
var cachePath string
if strings.Contains(mimeType, "png") {
cachePath = pngPath
} else {
cachePath = jpgPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType)
}
+98 -10
View File
@@ -86,6 +86,7 @@ var supportedAudioFormats = map[string]bool{
type libraryAudioFileInfo struct {
path string
modTime int64
size int64
}
type scannedCueFileInfo struct {
@@ -152,6 +153,7 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
files = append(files, libraryAudioFileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
size: info.Size(),
})
return nil
})
@@ -163,6 +165,10 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
return files, nil
}
func libraryAudioCoverCacheKey(info libraryAudioFileInfo) string {
return fmt.Sprintf("%s|%d|%d", info.path, info.size, info.modTime)
}
func libraryScanWorkerCount(taskCount int) int {
if taskCount < 16 {
return 1
@@ -205,7 +211,13 @@ func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, can
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
task.info.path,
"",
libraryAudioCoverCacheKey(task.info),
scanTime,
task.info.modTime,
)
*completed++
updateLibraryScanProgress(*completed, totalFiles, task.info.path)
if err != nil {
@@ -232,7 +244,13 @@ func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, can
return
default:
}
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
task.info.path,
"",
libraryAudioCoverCacheKey(task.info),
scanTime,
task.info.modTime,
)
taskResult := libraryScanTaskResult{
index: task.index,
path: task.info.path,
@@ -472,6 +490,12 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if ext == ".flac" {
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
}
if ext == ".m4a" || ext == ".mp4" || ext == ".aac" {
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
}
if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHintAndKey(
filePath,
@@ -485,10 +509,6 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
}
switch ext {
case ".flac":
return scanFLACFile(filePath, result, displayNameHint)
case ".m4a", ".mp4", ".aac":
return scanM4AFile(filePath, result, displayNameHint)
case ".mp3":
return scanMP3File(filePath, result, displayNameHint)
case ".opus", ".ogg":
@@ -504,6 +524,29 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
}
}
func embeddedCoverMIME(data []byte) string {
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4e &&
data[3] == 0x47 {
return "image/png"
}
return "image/jpeg"
}
func cacheScannedCover(filePath, cacheDir, coverCacheKey string, coverData []byte) string {
if cacheDir == "" || len(coverData) == 0 {
return ""
}
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
path, err := saveLibraryCoverDataToCache(cacheDir, cacheKey, coverData, embeddedCoverMIME(coverData))
if err != nil {
return ""
}
return path
}
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
ext := strings.ToLower(filepath.Ext(filePath))
if ext != "" {
@@ -533,10 +576,16 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
}
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, "", "")
}
func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
f, err := parseFlacFile(filePath)
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
defer f.Close()
metadata := metadataFromParsedFlac(f)
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
@@ -553,7 +602,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetAudioQuality(filePath)
quality, err := audioQualityFromParsedFlac(f)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
@@ -561,6 +610,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
if coverCacheDir != "" {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
result.CoverPath = existing
} else if coverData, coverErr := coverArtFromParsedFlac(f); coverErr == nil {
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
@@ -568,7 +625,28 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
}
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath)
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, "", "")
}
func scanM4AFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
f, err := os.Open(filePath)
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return scanFromFilename(filePath, displayNameHint, result)
}
fileSize := info.Size()
var metadata *AudioMetadata
ilst, ilstErr := findM4AIlstAtom(f, fileSize)
if ilstErr == nil {
metadata, err = readM4ATagsFromIlst(f, fileSize, ilst)
} else {
err = ilstErr
}
if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
}
@@ -577,7 +655,7 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
applyAudioMetadataToScan(metadata, result)
}
quality, err := GetM4AQuality(filePath)
quality, err := m4aQualityFromFile(f, fileSize)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
@@ -592,6 +670,16 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
}
}
}
if coverCacheDir != "" {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
result.CoverPath = existing
} else if ilstErr == nil {
if coverData, coverErr := extractCoverFromM4AIlst(f, fileSize, ilst); coverErr == nil {
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
}
}
}
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
+110
View File
@@ -0,0 +1,110 @@
package gobackend
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis/v2"
flac "github.com/go-flac/go-flac/v2"
)
func writeSinglePassTestFlac(t *testing.T, path string, cover []byte) {
t.Helper()
streamInfo := make([]byte, 34)
const sampleRate = 44100
const bitDepth = 16
const totalSamples = int64(441000)
streamInfo[10] = byte((sampleRate >> 12) & 0xff)
streamInfo[11] = byte((sampleRate >> 4) & 0xff)
streamInfo[12] = byte((sampleRate&0x0f)<<4 | ((bitDepth - 1) >> 4))
streamInfo[13] = byte(((bitDepth-1)&0x0f)<<4) | byte(totalSamples>>32)
streamInfo[14] = byte((totalSamples >> 24) & 0xff)
streamInfo[15] = byte((totalSamples >> 16) & 0xff)
streamInfo[16] = byte((totalSamples >> 8) & 0xff)
streamInfo[17] = byte(totalSamples & 0xff)
streamBlock := flac.MetaDataBlock{Type: flac.StreamInfo, Data: streamInfo}
comments := flacvorbis.New()
setComment(comments, "TITLE", "Single Pass")
setComment(comments, "ARTIST", "Artist")
commentBlock := comments.Marshal()
pictureBlock := (&flacpicture.MetadataBlockPicture{
PictureType: flacpicture.PictureTypeFrontCover,
MIME: "image/png",
Width: 1,
Height: 1,
ColorDepth: 24,
ImageData: cover,
}).Marshal()
data := append([]byte("fLaC"), streamBlock.Marshal(false)...)
data = append(data, commentBlock.Marshal(false)...)
data = append(data, pictureBlock.Marshal(true)...)
data = append(data, 0xff, 0xf8)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func TestScanFLACSinglePassReadsMetadataQualityAndCover(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "track.flac")
cover := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3}
writeSinglePassTestFlac(t, path, cover)
result := &LibraryScanResult{FilePath: path, Format: "flac"}
result, err := scanFLACFileWithCoverCache(path, result, "", filepath.Join(dir, "covers"), "stable-key")
if err != nil {
t.Fatal(err)
}
if result.TrackName != "Single Pass" || result.ArtistName != "Artist" || result.SampleRate != 44100 || result.BitDepth != 16 || result.Duration != 10 {
t.Fatalf("scan result = %#v", result)
}
if result.CoverPath == "" {
t.Fatal("cover was not cached")
}
gotCover, err := os.ReadFile(result.CoverPath)
if err != nil || !bytes.Equal(gotCover, cover) {
t.Fatalf("cached cover = %x, %v", gotCover, err)
}
}
func TestScanM4ASingleOpenReadsMetadataAndCover(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "track.m4a")
cover := []byte{0xff, 0xd8, 0xff, 1, 2, 3}
ilst := append(buildM4ATextAtom("\xa9nam", "Single Open"), buildM4ACoverAtom(cover)...)
data, _ := buildTestM4A(t, ilst, []byte("audio"))
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
result := &LibraryScanResult{FilePath: path, Format: "m4a"}
result, err := scanM4AFileWithCoverCache(path, result, "", filepath.Join(dir, "covers"), "stable-key")
if err != nil {
t.Fatal(err)
}
if result.TrackName != "Single Open" || result.CoverPath == "" {
t.Fatalf("scan result = %#v", result)
}
gotCover, err := os.ReadFile(result.CoverPath)
if err != nil || !bytes.Equal(gotCover, cover) {
t.Fatalf("cached cover = %x, %v", gotCover, err)
}
}
func TestAudioQualityFromParsedFlacRejectsMissingStreamInfo(t *testing.T) {
if _, err := audioQualityFromParsedFlac(&flac.File{}); err == nil {
t.Fatal("missing STREAMINFO was accepted")
}
}
func TestLibraryAudioCoverCacheKeyIncludesFileIdentity(t *testing.T) {
info := libraryAudioFileInfo{path: "track.flac", size: 123, modTime: 456}
if got := libraryAudioCoverCacheKey(info); got != "track.flac|123|456" {
t.Fatalf("cache key = %q", got)
}
}
+58 -15
View File
@@ -323,7 +323,10 @@ func ReadMetadata(filePath string) (*Metadata, error) {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
defer f.Close()
return metadataFromParsedFlac(f), nil
}
func metadataFromParsedFlac(f *flac.File) *Metadata {
metadata := &Metadata{}
for _, meta := range f.Meta {
@@ -399,7 +402,7 @@ func ReadMetadata(filePath string) (*Metadata, error) {
}
}
return metadata, nil
return metadata
}
// EditFlacFields opens a FLAC file and updates only the Vorbis Comment keys
@@ -767,7 +770,10 @@ func ExtractCoverArt(filePath string) ([]byte, error) {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
defer f.Close()
return coverArtFromParsedFlac(f)
}
func coverArtFromParsedFlac(f *flac.File) ([]byte, error) {
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
@@ -893,12 +899,15 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
if err != nil {
return nil, err
}
return readM4ATagsFromIlst(f, fi.Size(), ilst)
}
func readM4ATagsFromIlst(f *os.File, fileSize int64, ilst atomHeader) (*AudioMetadata, error) {
metadata := &AudioMetadata{}
start := ilst.offset + ilst.headerSize
end := ilst.offset + ilst.size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fi.Size())
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return nil, err
}
@@ -911,32 +920,32 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
switch header.typ {
case "\xa9nam":
metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
metadata.Title, _ = readM4ATextValue(f, header, fileSize)
case "\xa9ART":
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
metadata.Artist, _ = readM4ATextValue(f, header, fileSize)
case "\xa9alb":
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
metadata.Album, _ = readM4ATextValue(f, header, fileSize)
case "aART":
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fileSize)
case "\xa9day":
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
metadata.Date, _ = readM4ATextValue(f, header, fileSize)
metadata.Year = metadata.Date
case "\xa9gen":
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
metadata.Genre, _ = readM4ATextValue(f, header, fileSize)
case "\xa9wrt":
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
metadata.Composer, _ = readM4ATextValue(f, header, fileSize)
case "\xa9cmt":
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
metadata.Comment, _ = readM4ATextValue(f, header, fileSize)
case "cprt":
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
metadata.Copyright, _ = readM4ATextValue(f, header, fileSize)
case "\xa9lyr":
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
metadata.Lyrics, _ = readM4ATextValue(f, header, fileSize)
case "trkn":
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size())
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fileSize)
case "disk":
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size())
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fileSize)
case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
name, value, freeformErr := readM4AFreeformValue(f, header, fileSize)
if freeformErr == nil {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "ISRC":
@@ -1015,7 +1024,10 @@ func extractCoverFromM4A(filePath string) ([]byte, error) {
if err != nil {
return nil, err
}
return extractCoverFromM4AIlst(f, fileSize, ilst)
}
func extractCoverFromM4AIlst(f *os.File, fileSize int64, ilst atomHeader) ([]byte, error) {
bodyStart := ilst.offset + ilst.headerSize
bodySize := ilst.size - ilst.headerSize
@@ -1649,6 +1661,34 @@ type AudioQuality struct {
Codec string `json:"codec,omitempty"`
}
func audioQualityFromParsedFlac(f *flac.File) (AudioQuality, error) {
for _, meta := range f.Meta {
if meta.Type != flac.StreamInfo || len(meta.Data) < 18 {
continue
}
streamInfo := meta.Data
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
int64(streamInfo[16])<<8 |
int64(streamInfo[17])
duration := 0
if sampleRate > 0 && totalSamples > 0 {
duration = int(totalSamples / int64(sampleRate))
}
return AudioQuality{
BitDepth: bitsPerSample,
SampleRate: sampleRate,
TotalSamples: totalSamples,
Duration: duration,
Codec: "flac",
}, nil
}
return AudioQuality{}, fmt.Errorf("FLAC STREAMINFO block not found")
}
func GetAudioQuality(filePath string) (AudioQuality, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -1727,7 +1767,10 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
return m4aQualityFromFile(f, fileSize)
}
func m4aQualityFromFile(f *os.File, fileSize int64) (AudioQuality, error) {
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)