diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ffa536a..05962b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,10 @@ - Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()` - Tags correctly embedded during FFmpeg conversion +- **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC + - Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()` + - Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT` + ### Extensions - **spotify-web Extension**: Updated to v1.7.0 @@ -92,11 +96,24 @@ - File stat uses a single syscall and only triggers state updates on change - Static regex/month table avoids repeated allocations - Cover precached before opening metadata from history/queue/recents +- **Flutter Provider Optimizations**: + - Cache `SharedPreferences` instance in `DownloadHistoryNotifier` and `DownloadQueueNotifier` to avoid repeated `getInstance()` calls + - Precompile regex for folder name sanitization and year extraction (top-level `final`) + - Use `indexWhere` instead of `firstWhere` with placeholder object to reduce allocations in queue processing +- **Flutter UI Optimizations**: + - Selective `ref.watch()` for `downloadQueueProvider` (watch only `queuedCount` or `items` instead of entire state) + - Pass `Track` directly to `_buildTrackTile()` instead of index lookup inside builder + - Pass `historyItems` as parameter to `_buildRecentAccess()` to avoid `ref.read()` inside method +- **M4A Metadata Embedding**: Streaming implementation reduces memory usage for large files + - Uses `os.Open()` + `ReadAt` instead of `os.ReadFile()` (no full file load into memory) + - Atomic file replacement via temp file + rename for safer writes + - New helper functions: `findAtomInRange()`, `readAtomHeaderAt()`, `copyRange()` ### Backend - **Deezer ISRC Fetching**: Uses ISRCs already present in payloads and caches them, cutting extra API calls - **SearchAll Allocation**: Preallocated slices to reduce allocations during Deezer search +- **HTTP Client Helper**: Refactored HTTP client creation to use `NewHTTPClientWithTimeout()` helper function across `lyrics.go`, `qobuz.go`, `tidal.go` ### Technical diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 895da25a..b22b200a 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -123,9 +123,7 @@ type LyricsClient struct { func NewLyricsClient() *LyricsClient { return &LyricsClient{ - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + httpClient: NewHTTPClientWithTimeout(15 * time.Second), } } diff --git a/go_backend/metadata.go b/go_backend/metadata.go index a30fc684..f2dac03d 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -1,7 +1,10 @@ package gobackend import ( + "bytes" + "encoding/binary" "fmt" + "io" "os" "strconv" "strings" @@ -515,73 +518,166 @@ func GetAudioQuality(filePath string) (AudioQuality, error) { // EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error { - data, err := os.ReadFile(filePath) + input, err := os.Open(filePath) if err != nil { - return fmt.Errorf("failed to read M4A file: %w", err) + return fmt.Errorf("failed to open M4A file: %w", err) } + defer input.Close() - moovPos := findAtom(data, "moov", 0) - if moovPos < 0 { + info, err := input.Stat() + if err != nil { + return fmt.Errorf("failed to stat M4A file: %w", err) + } + fileSize := info.Size() + + moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize) + if err != nil { + return fmt.Errorf("failed to find moov atom: %w", err) + } + if !moovFound { return fmt.Errorf("moov atom not found in M4A file") } - moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3])) - udtaPos := findAtom(data, "udta", moovPos+8) + moovContentStart := moovHeader.offset + moovHeader.headerSize + moovContentSize := moovHeader.size - moovHeader.headerSize + + udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize) + if err != nil { + return fmt.Errorf("failed to locate udta atom: %w", err) + } + + var metaHeader atomHeader + metaFound := false + if udtaFound { + udtaContentStart := udtaHeader.offset + udtaHeader.headerSize + udtaContentSize := udtaHeader.size - udtaHeader.headerSize + metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize) + if err != nil { + return fmt.Errorf("failed to locate meta atom: %w", err) + } + } metaAtom := buildMetaAtom(metadata, coverData) + metaSize := int64(len(metaAtom)) - var newData []byte - if udtaPos >= 0 && udtaPos < moovPos+moovSize { - udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3])) - metaPos := findAtom(data, "meta", udtaPos+8) + var delta int64 + var newUdtaSize int64 + switch { + case udtaFound && metaFound: + delta = metaSize - metaHeader.size + newUdtaSize = udtaHeader.size + delta + case udtaFound && !metaFound: + delta = metaSize + newUdtaSize = udtaHeader.size + delta + case !udtaFound: + newUdtaSize = int64(8 + len(metaAtom)) + delta = newUdtaSize + } - if metaPos >= 0 && metaPos < udtaPos+udtaSize { - metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3])) - newData = append(newData, data[:metaPos]...) - newData = append(newData, metaAtom...) - newData = append(newData, data[metaPos+metaSize:]...) - } else { - newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...) - newUdtaSize := 8 + len(newUdtaContent) - newUdta := make([]byte, 4) - newUdta[0] = byte(newUdtaSize >> 24) - newUdta[1] = byte(newUdtaSize >> 16) - newUdta[2] = byte(newUdtaSize >> 8) - newUdta[3] = byte(newUdtaSize) - newUdta = append(newUdta, []byte("udta")...) - newUdta = append(newUdta, newUdtaContent...) + newMoovSize := moovHeader.size + delta + if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) { + return fmt.Errorf("moov atom exceeds 32-bit size after update") + } + if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) { + return fmt.Errorf("udta atom exceeds 32-bit size after update") + } + if !udtaFound && newUdtaSize > int64(^uint32(0)) { + return fmt.Errorf("udta atom exceeds 32-bit size after update") + } - newData = append(newData, data[:udtaPos]...) - newData = append(newData, newUdta...) - newData = append(newData, data[udtaPos+udtaSize:]...) + tempPath := filePath + ".tmp" + output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + cleanupTemp := true + defer func() { + _ = output.Close() + if cleanupTemp { + _ = os.Remove(tempPath) } - } else { - udtaContent := metaAtom - udtaSize := 8 + len(udtaContent) - newUdta := make([]byte, 4) - newUdta[0] = byte(udtaSize >> 24) - newUdta[1] = byte(udtaSize >> 16) - newUdta[2] = byte(udtaSize >> 8) - newUdta[3] = byte(udtaSize) - newUdta = append(newUdta, []byte("udta")...) - newUdta = append(newUdta, udtaContent...) + }() - insertPos := moovPos + moovSize - newData = append(newData, data[:insertPos]...) - newData = append(newData, newUdta...) - newData = append(newData, data[insertPos:]...) + switch { + case udtaFound && metaFound: + if err := copyRange(output, input, 0, moovHeader.offset); err != nil { + return err + } + if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { + return err + } + if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil { + return err + } + if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil { + return err + } + if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil { + return err + } + if _, err := output.Write(metaAtom); err != nil { + return fmt.Errorf("failed to write meta atom: %w", err) + } + metaEnd := metaHeader.offset + metaHeader.size + if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil { + return err + } + case udtaFound && !metaFound: + if err := copyRange(output, input, 0, moovHeader.offset); err != nil { + return err + } + if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { + return err + } + if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil { + return err + } + if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil { + return err + } + insertPos := udtaHeader.offset + udtaHeader.size + if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil { + return err + } + if _, err := output.Write(metaAtom); err != nil { + return fmt.Errorf("failed to write meta atom: %w", err) + } + if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil { + return err + } + case !udtaFound: + newUdtaAtom := buildUdtaAtom(metaAtom) + if err := copyRange(output, input, 0, moovHeader.offset); err != nil { + return err + } + if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { + return err + } + moovEnd := moovHeader.offset + moovHeader.size + if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil { + return err + } + if _, err := output.Write(newUdtaAtom); err != nil { + return fmt.Errorf("failed to write udta atom: %w", err) + } + if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil { + return err + } } - newMoovSize := moovSize + len(newData) - len(data) - newData[moovPos] = byte(newMoovSize >> 24) - newData[moovPos+1] = byte(newMoovSize >> 16) - newData[moovPos+2] = byte(newMoovSize >> 8) - newData[moovPos+3] = byte(newMoovSize) - - if err := os.WriteFile(filePath, newData, 0644); err != nil { - return fmt.Errorf("failed to write M4A file: %w", err) + if err := output.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) } + _ = input.Close() + if err := os.Remove(filePath); err != nil { + return fmt.Errorf("failed to replace original file: %w", err) + } + if err := os.Rename(tempPath, filePath); err != nil { + return fmt.Errorf("failed to move temp file: %w", err) + } + cleanupTemp = false + fmt.Printf("[M4A] Metadata embedded successfully\n") return nil } @@ -782,28 +878,225 @@ func buildCoverAtom(coverData []byte) []byte { } func GetM4AQuality(filePath string) (AudioQuality, error) { - data, err := os.ReadFile(filePath) + f, err := os.Open(filePath) if err != nil { - return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err) + return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err) } + defer f.Close() - moovPos := findAtom(data, "moov", 0) - if moovPos < 0 { + info, err := f.Stat() + if err != nil { + return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err) + } + fileSize := info.Size() + + moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil { + return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err) + } + if !moovFound { return AudioQuality{}, fmt.Errorf("moov atom not found") } - for i := moovPos; i < len(data)-20; i++ { - if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" { - if i+24 < len(data) { - sampleRate := int(data[i+22])<<8 | int(data[i+23]) - bitDepth := 16 - if string(data[i:i+4]) == "alac" { - bitDepth = 24 - } - return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil - } - } + moovStart := moovHeader.offset + moovEnd := moovHeader.offset + moovHeader.size + + sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize) + if err != nil { + return AudioQuality{}, err } - return AudioQuality{}, fmt.Errorf("audio info not found in M4A file") + buf := make([]byte, 24) + if _, err := f.ReadAt(buf, sampleOffset); err != nil { + return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err) + } + + sampleRate := int(buf[22])<<8 | int(buf[23]) + bitDepth := 16 + if atomType == "alac" { + bitDepth = 24 + } + + return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil +} + +type atomHeader struct { + offset int64 + size int64 + headerSize int64 + typ string +} + +func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) { + if offset+8 > fileSize { + return atomHeader{}, io.ErrUnexpectedEOF + } + + headerBuf := make([]byte, 8) + if _, err := f.ReadAt(headerBuf, offset); err != nil { + return atomHeader{}, err + } + + size32 := binary.BigEndian.Uint32(headerBuf[0:4]) + typ := string(headerBuf[4:8]) + + if size32 == 1 { + if offset+16 > fileSize { + return atomHeader{}, io.ErrUnexpectedEOF + } + extBuf := make([]byte, 8) + if _, err := f.ReadAt(extBuf, offset+8); err != nil { + return atomHeader{}, err + } + size64 := binary.BigEndian.Uint64(extBuf) + return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil + } + + return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil +} + +func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) { + if size <= 0 { + return atomHeader{}, false, nil + } + + end := start + size + pos := start + + for pos+8 <= end { + header, err := readAtomHeaderAt(f, pos, fileSize) + if err != nil { + return atomHeader{}, false, err + } + + atomSize := header.size + if atomSize == 0 { + atomSize = end - pos + } + + if atomSize < header.headerSize { + return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ) + } + + header.size = atomSize + if header.typ == target { + return header, true, nil + } + + pos += atomSize + } + + return atomHeader{}, false, nil +} + +func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error { + if len(typ) != 4 { + return fmt.Errorf("invalid atom type: %s", typ) + } + + if headerSize == 16 { + header := make([]byte, 16) + binary.BigEndian.PutUint32(header[0:4], 1) + copy(header[4:8], []byte(typ)) + binary.BigEndian.PutUint64(header[8:16], uint64(size)) + _, err := w.Write(header) + return err + } + + if size > int64(^uint32(0)) { + return fmt.Errorf("atom size exceeds 32-bit for %s", typ) + } + + header := make([]byte, 8) + binary.BigEndian.PutUint32(header[0:4], uint32(size)) + copy(header[4:8], []byte(typ)) + _, err := w.Write(header) + return err +} + +func copyRange(dst io.Writer, src *os.File, offset, length int64) error { + if length <= 0 { + return nil + } + if _, err := src.Seek(offset, io.SeekStart); err != nil { + return fmt.Errorf("failed to seek source: %w", err) + } + if _, err := io.CopyN(dst, src, length); err != nil { + return fmt.Errorf("failed to copy data: %w", err) + } + return nil +} + +func buildUdtaAtom(metaAtom []byte) []byte { + size := 8 + len(metaAtom) + header := make([]byte, 8) + binary.BigEndian.PutUint32(header[0:4], uint32(size)) + copy(header[4:8], []byte("udta")) + return append(header, metaAtom...) +} + +func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) { + const chunkSize = 64 * 1024 + patternMP4A := []byte("mp4a") + patternALAC := []byte("alac") + + var tail []byte + readPos := start + + for readPos < end { + toRead := end - readPos + if toRead > chunkSize { + toRead = chunkSize + } + + buf := make([]byte, toRead) + n, err := f.ReadAt(buf, readPos) + if err != nil && err != io.EOF { + return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err) + } + if n == 0 { + break + } + + data := append(tail, buf[:n]...) + mp4aIdx := bytes.Index(data, patternMP4A) + alacIdx := bytes.Index(data, patternALAC) + + bestIdx := -1 + bestType := "" + switch { + case mp4aIdx >= 0 && alacIdx >= 0: + if mp4aIdx <= alacIdx { + bestIdx = mp4aIdx + bestType = "mp4a" + } else { + bestIdx = alacIdx + bestType = "alac" + } + case mp4aIdx >= 0: + bestIdx = mp4aIdx + bestType = "mp4a" + case alacIdx >= 0: + bestIdx = alacIdx + bestType = "alac" + } + + if bestIdx >= 0 { + absolute := readPos - int64(len(tail)) + int64(bestIdx) + if absolute+24 > fileSize { + return 0, "", fmt.Errorf("audio info not found in M4A file") + } + return absolute, bestType, nil + } + + if len(data) >= 3 { + tail = append([]byte{}, data[len(data)-3:]...) + } else { + tail = append([]byte{}, data...) + } + + readPos += int64(n) + } + + return 0, "", fmt.Errorf("audio info not found in M4A file") } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index d3a777ac..40c24d86 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -716,9 +716,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( go func(api string) { reqStart := time.Now() - client := &http.Client{ - Timeout: 15 * time.Second, - } + client := NewHTTPClientWithTimeout(15 * time.Second) reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 8c245b64..f489741a 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -600,9 +600,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin go func(api string) { reqStart := time.Now() - client := &http.Client{ - Timeout: 15 * time.Second, - } + client := NewHTTPClientWithTimeout(15 * time.Second) reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) @@ -901,9 +899,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", directURL != "", initURL != "", len(mediaURLs)) - client := &http.Client{ - Timeout: 120 * time.Second, - } + client := NewHTTPClientWithTimeout(120 * time.Second) if directURL != "" { GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])