diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 935e28c3..06c4c889 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2680,6 +2680,33 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "ensureAC4Config" -> { + val filePath = call.argument("file_path") ?: "" + val sourcePath = call.argument("source_path") ?: "" + val response = withContext(Dispatchers.IO) { + try { + Gobackend.ensureAC4Config(filePath, sourcePath) + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "ensureAC4Config failed: ${e.message}", e) + """{"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } + "writeAC4Metadata" -> { + val filePath = call.argument("file_path") ?: "" + val metadataJson = call.argument("metadata_json") ?: "{}" + val coverPath = call.argument("cover_path") ?: "" + val response = withContext(Dispatchers.IO) { + try { + Gobackend.writeAC4Metadata(filePath, metadataJson, coverPath) + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "writeAC4Metadata failed: ${e.message}", e) + """{"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } "writeTempToSaf" -> { val tempPath = call.argument("temp_path") ?: "" val safUri = call.argument("saf_uri") ?: "" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 122c7e0d..c4d6c968 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -422,16 +422,19 @@ object NativeDownloadFinalizer { try { for (candidate in decryptionKeyCandidates(key)) { checkCancelled(shouldCancel) - val attempts = mutableListOf>() - attempts.add(outputPath to (preferredExt == ".flac")) + val attempts = mutableListOf>() + attempts.add(Triple(outputPath, preferredExt == ".flac", false)) if (preferredExt == ".flac") { - attempts.add(buildOutputPath(localInput, ".m4a") to false) + attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false)) } if (preferredExt == ".flac" || preferredExt == ".m4a") { - attempts.add(buildOutputPath(localInput, ".mp4") to false) + attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, false)) } + // MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4): + // keeps the .mp4 filename but stores the codec params. + attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, true)) - for ((candidateOutput, mapAudioOnly) in attempts) { + for ((candidateOutput, mapAudioOnly, forceMov) in attempts) { try { val audioMap = if (mapAudioOnly) "-map 0:a " else "" // Force the flac muxer when the target extension is @@ -439,7 +442,11 @@ object NativeDownloadFinalizer { // stream layout, producing FLAC-in-MP4 under a .flac // filename which downstream native FLAC tag writers // cannot read. - val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else "" + val muxerOverride = when { + forceMov -> "-f mov " + candidateOutput.lowercase(Locale.ROOT).endsWith(".flac") -> "-f flac " + else -> "" + } val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y" val result = runFFmpeg(command, shouldCancel) lastOutput = result.second @@ -1159,18 +1166,28 @@ object NativeDownloadFinalizer { val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else "" var adoptedTemp = false var originalDeleted = false - try { - val command = if (isM4a && coverFile != null) { + + fun buildEmbedCommand(forceMov: Boolean): String { + return if (isM4a && coverFile != null) { "-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " + "-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " + "-disposition:v:0 attached_pic " + "-metadata:s:v ${q("title=Album cover")} " + "-metadata:s:v ${q("comment=Cover (front)")} " + - "$metadataArgs -f mp4 ${q(temp.absolutePath)} -y" + "$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y" } else { - "-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y" + val movFlag = if (forceMov) "-f mov " else "" + "-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y" + } + } + + try { + var result = runFFmpeg(buildEmbedCommand(false)) + // MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4). + if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) { + temp.delete() + result = runFFmpeg(buildEmbedCommand(true)) } - val result = runFFmpeg(command) if (result.first && temp.exists()) { if (inputFile.delete()) { originalDeleted = true diff --git a/go_backend/ac4_config.go b/go_backend/ac4_config.go new file mode 100644 index 00000000..efeeea0e --- /dev/null +++ b/go_backend/ac4_config.go @@ -0,0 +1,312 @@ +package gobackend + +import ( + "encoding/binary" + "fmt" + "os" +) + +// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer. +type mp4Box struct { + offset int64 + size int64 + hdr int64 + typ string +} + +func (b mp4Box) body() int64 { return b.offset + b.hdr } +func (b mp4Box) end() int64 { return b.offset + b.size } + +func readMP4Box(data []byte, pos int64) (mp4Box, bool) { + n := int64(len(data)) + if pos < 0 || pos+8 > n { + return mp4Box{}, false + } + size := int64(binary.BigEndian.Uint32(data[pos : pos+4])) + typ := string(data[pos+4 : pos+8]) + hdr := int64(8) + if size == 1 { + if pos+16 > n { + return mp4Box{}, false + } + size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16])) + hdr = 16 + } else if size == 0 { + size = n - pos + } + if size < hdr || pos+size > n { + return mp4Box{}, false + } + return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true +} + +func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) { + pos := start + for pos+8 <= end { + b, ok := readMP4Box(data, pos) + if !ok { + return mp4Box{}, false + } + if b.typ == typ { + return b, true + } + pos = b.end() + } + return mp4Box{}, false +} + +func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) { + pos := start + for pos+8 <= end { + b, ok := readMP4Box(data, pos) + if !ok { + return + } + if b.typ == typ && !fn(b) { + return + } + pos = b.end() + } +} + +// findBoxBySignature scans [start,end) for a box of the given type, matching the +// 4-byte type tag and validating the preceding size field. Used to locate dac4 +// which may be nested inside an encrypted (enca) sample entry. +func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) { + if len(typ) != 4 { + return mp4Box{}, false + } + for i := start; i+8 <= end; i++ { + if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] { + if b, ok := readMP4Box(data, i); ok && b.typ == typ { + return b, true + } + } + } + return mp4Box{}, false +} + +// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample +// entry header (from the box body start) before child boxes begin. +func audioSampleEntryHeaderLen(data []byte, entry mp4Box) int64 { + // 6 bytes reserved + 2 bytes data_reference_index, then the audio fields. + base := entry.body() + if base+10 > entry.end() { + return 8 + 20 + } + version := binary.BigEndian.Uint16(data[base+8 : base+10]) + switch version { + case 1: + return 8 + 20 + 16 + case 2: + return 8 + 20 + 36 + default: + return 8 + 20 + } +} + +type ac4Location struct { + chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow) + entry mp4Box // the ac-4 sample entry +} + +func locateAC4Entry(data []byte) (ac4Location, bool) { + moov, ok := findChildMP4(data, 0, int64(len(data)), "moov") + if !ok { + return ac4Location{}, false + } + var found ac4Location + var ok2 bool + eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool { + mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia") + if !ok { + return true + } + minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf") + if !ok { + return true + } + stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl") + if !ok { + return true + } + stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd") + if !ok { + return true + } + entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4") + if !ok { + return true + } + found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry} + ok2 = true + return false + }) + return found, ok2 +} + +func growBoxSize(data []byte, b mp4Box, delta int64) { + if b.hdr == 16 { + binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta)) + } else { + binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta)) + } +} + +// shiftChunkOffsets adds delta to every stco/co64 entry that references a file +// offset at or beyond insertPos, keeping sample pointers valid after bytes are +// inserted into moov. +func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) { + eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool { + mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia") + if !ok { + return true + } + minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf") + if !ok { + return true + } + stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl") + if !ok { + return true + } + if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok { + base := stco.body() + 4 + if base+4 <= stco.end() { + count := int64(binary.BigEndian.Uint32(data[base : base+4])) + p := base + 4 + for i := int64(0); i < count && p+4 <= stco.end(); i++ { + v := int64(binary.BigEndian.Uint32(data[p : p+4])) + if v >= insertPos { + binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta)) + } + p += 4 + } + } + } + if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok { + base := co64.body() + 4 + if base+4 <= co64.end() { + count := int64(binary.BigEndian.Uint32(data[base : base+4])) + p := base + 4 + for i := int64(0); i < count && p+8 <= co64.end(); i++ { + v := int64(binary.BigEndian.Uint64(data[p : p+8])) + if v >= insertPos { + binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta)) + } + p += 8 + } + } + } + return true + }) +} + +// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov +// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a +// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry. +// Windows Media Foundation (and other strict parsers) reject the QuickTime +// flavor for AC-4 even when dac4 is present. +func normalizeQuickTimeAudioToMP4(data []byte) []byte { + if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok { + if ftyp.body()+4 <= int64(len(data)) { + copy(data[ftyp.body():ftyp.body()+4], []byte("mp42")) + } + for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 { + if string(data[p:p+4]) == "qt " { + copy(data[p:p+4], []byte("isom")) + } + } + } + + loc, ok := locateAC4Entry(data) + if !ok { + return data + } + entry := loc.entry + verPos := entry.body() + 8 + if verPos+2 > entry.end() { + return data + } + if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 { + return data // already v0 (or v2, left untouched) + } + + binary.BigEndian.PutUint16(data[verPos:verPos+2], 0) + // The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0 + // audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample). + extStart := entry.body() + 8 + 20 + extEnd := extStart + 16 + delta := int64(-16) + + shiftChunkOffsets(data, loc.chain[0], extStart, delta) + for _, b := range loc.chain { + growBoxSize(data, b, delta) + } + growBoxSize(data, entry, delta) + + out := make([]byte, 0, len(data)-16) + out = append(out, data[:extStart]...) + out = append(out, data[extEnd:]...) + return out +} + +// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and +// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4 +// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The +// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext +// moov still carries it). No-op when the file has no AC-4 track. +func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error { + dst, err := os.ReadFile(decryptedPath) + if err != nil { + return err + } + + if _, ok := locateAC4Entry(dst); !ok { + return nil // not an AC-4 file; nothing to do + } + + dst = normalizeQuickTimeAudioToMP4(dst) + + loc, ok := locateAC4Entry(dst) + if !ok { + return nil + } + + hdrLen := audioSampleEntryHeaderLen(dst, loc.entry) + childStart := loc.entry.body() + hdrLen + if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has { + // Already has dac4; still persist any normalization changes. + return os.WriteFile(decryptedPath, dst, 0o644) + } + + src, err := os.ReadFile(sourcePath) + if err != nil { + return err + } + srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov") + if !ok { + return fmt.Errorf("source has no moov") + } + dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4") + if !ok { + return fmt.Errorf("dac4 not found in source") + } + dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...) + + insertPos := childStart + delta := int64(len(dac4)) + + shiftChunkOffsets(dst, loc.chain[0], insertPos, delta) + for _, b := range loc.chain { + growBoxSize(dst, b, delta) + } + growBoxSize(dst, loc.entry, delta) + + out := make([]byte, 0, len(dst)+len(dac4)) + out = append(out, dst[:insertPos]...) + out = append(out, dac4...) + out = append(out, dst[insertPos:]...) + + return os.WriteFile(decryptedPath, out, 0o644) +} diff --git a/go_backend/ac4_metadata.go b/go_backend/ac4_metadata.go new file mode 100644 index 00000000..cf6b0862 --- /dev/null +++ b/go_backend/ac4_metadata.go @@ -0,0 +1,182 @@ +package gobackend + +import ( + "encoding/binary" + "encoding/json" + "os" + "strconv" + "strings" +) + +// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric +// fields are strings because they arrive as a JSON-encoded map of strings. +type ac4Metadata struct { + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + AlbumArtist string `json:"albumArtist"` + Date string `json:"date"` + Genre string `json:"genre"` + Composer string `json:"composer"` + TrackNumber string `json:"trackNumber"` + TotalTracks string `json:"totalTracks"` + DiscNumber string `json:"discNumber"` + TotalDiscs string `json:"totalDiscs"` + ISRC string `json:"isrc"` + Label string `json:"label"` + Copyright string `json:"copyright"` + Lyrics string `json:"lyrics"` +} + +func atoiSafe(s string) int { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return 0 + } + return n +} + +func itunesTextTag(atomType, value string) []byte { + data := make([]byte, 8+len(value)) + binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8 + copy(data[8:], []byte(value)) + return buildM4AAtom(atomType, buildM4AAtom("data", data)) +} + +func itunesNumberPairTag(atomType string, number, total int) []byte { + payload := make([]byte, 8) + binary.BigEndian.PutUint16(payload[2:4], uint16(number)) + binary.BigEndian.PutUint16(payload[4:6], uint16(total)) + data := make([]byte, 8+len(payload)) + binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary + copy(data[8:], payload) + return buildM4AAtom(atomType, buildM4AAtom("data", data)) +} + +func itunesCoverTag(image []byte) []byte { + typeCode := uint32(13) // JPEG + if len(image) >= 8 && + image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 { + typeCode = 14 // PNG + } + data := make([]byte, 8+len(image)) + binary.BigEndian.PutUint32(data[0:4], typeCode) + copy(data[8:], image) + return buildM4AAtom("covr", buildM4AAtom("data", data)) +} + +func itunesMetadataHandler() []byte { + payload := make([]byte, 0, 25) + payload = append(payload, 0, 0, 0, 0) // version + flags + payload = append(payload, 0, 0, 0, 0) // pre_defined + payload = append(payload, []byte("mdir")...) // handler type + payload = append(payload, []byte("appl")...) // reserved[0] + payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2] + payload = append(payload, 0) // empty name + return buildM4AAtom("hdlr", payload) +} + +// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata. +func buildITunesUdta(md ac4Metadata, cover []byte) []byte { + ilst := make([]byte, 0, 256) + add := func(atomType, value string) { + if strings.TrimSpace(value) != "" { + ilst = append(ilst, itunesTextTag(atomType, value)...) + } + } + add("\xa9nam", md.Title) + add("\xa9ART", md.Artist) + add("\xa9alb", md.Album) + add("aART", md.AlbumArtist) + add("\xa9day", md.Date) + add("\xa9gen", md.Genre) + add("\xa9wrt", md.Composer) + if tn := atoiSafe(md.TrackNumber); tn > 0 { + ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...) + } + if dn := atoiSafe(md.DiscNumber); dn > 0 { + ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...) + } + if strings.TrimSpace(md.ISRC) != "" { + ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...) + } + if strings.TrimSpace(md.Label) != "" { + ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...) + } + if strings.TrimSpace(md.Copyright) != "" { + add("cprt", md.Copyright) + } + if strings.TrimSpace(md.Lyrics) != "" { + add("\xa9lyr", md.Lyrics) + } + if len(cover) > 0 { + ilst = append(ilst, itunesCoverTag(cover)...) + } + + ilstBox := buildM4AAtom("ilst", ilst) + metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...) + metaPayload = append(metaPayload, ilstBox...) + meta := buildM4AAtom("meta", metaPayload) + return buildM4AAtom("udta", meta) +} + +// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in +// the moov of an MP4 buffer and returns the rewritten bytes. +func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte { + moov, ok := findChildMP4(data, 0, int64(len(data)), "moov") + if !ok { + return data + } + newUdta := buildITunesUdta(md, cover) + + if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok { + delta := int64(len(newUdta)) - udta.size + shiftChunkOffsets(data, moov, udta.offset, delta) + growBoxSize(data, moov, delta) + out := make([]byte, 0, len(data)+len(newUdta)) + out = append(out, data[:udta.offset]...) + out = append(out, newUdta...) + out = append(out, data[udta.end():]...) + return out + } + + delta := int64(len(newUdta)) + insertPos := moov.end() + shiftChunkOffsets(data, moov, insertPos, delta) + growBoxSize(data, moov, delta) + out := make([]byte, 0, len(data)+len(newUdta)) + out = append(out, data[:insertPos]...) + out = append(out, newUdta...) + out = append(out, data[insertPos:]...) + return out +} + +// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns +// true when the file was an AC-4 track and metadata was written; false when the +// file is not AC-4 (the caller should fall back to its normal metadata path). +func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) { + data, err := os.ReadFile(decryptedPath) + if err != nil { + return false, err + } + if _, ok := locateAC4Entry(data); !ok { + return false, nil + } + + var md ac4Metadata + if strings.TrimSpace(metadataJSON) != "" { + _ = json.Unmarshal([]byte(metadataJSON), &md) + } + var cover []byte + if strings.TrimSpace(coverPath) != "" { + if b, err := os.ReadFile(coverPath); err == nil { + cover = b + } + } + + out := writeMP4iTunesMetadata(data, md, cover) + if err := os.WriteFile(decryptedPath, out, 0o644); err != nil { + return false, err + } + return true, nil +} diff --git a/go_backend/exports.go b/go_backend/exports.go index 29232886..f1d62f1b 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1529,6 +1529,29 @@ func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } +// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO +// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when +// the file is not AC-4. +func EnsureAC4Config(filePath, sourcePath string) (string, error) { + if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil { + return "", fmt.Errorf("failed to finalize AC-4 container: %w", err) + } + return `{"success":true}`, nil +} + +// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON +// "handled" field reports whether the file was AC-4 (true) so the caller can +// skip the FFmpeg metadata pass that would re-wrap it as QuickTime. +func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) { + handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath) + if err != nil { + return "", fmt.Errorf("failed to write AC-4 metadata: %w", err) + } + resp := map[string]any{"success": true, "handled": handled} + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + // EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg. func EditFileMetadata(filePath, metadataJSON string) (string, error) { var fields map[string]string diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 83f1d8e0..39602995 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4878,6 +4878,47 @@ class DownloadQueueNotifier extends Notifier { ? coverPath : null; + // AC-4 is passthrough-only: the FFmpeg mov muxer would re-wrap it as + // QuickTime and break the ISO MP4 from decryption. writeAC4Metadata is a + // no-op for non-AC-4 files, so other m4a downloads fall through to FFmpeg. + if (isM4a) { + try { + final ac4Meta = { + 'title': track.name, + 'artist': track.artistName, + 'album': track.albumName, + 'albumArtist': ?albumArtist, + if (track.releaseDate != null) 'date': track.releaseDate!, + if (genre != null && genre.isNotEmpty) 'genre': genre, + if (track.composer != null && track.composer!.isNotEmpty) + 'composer': track.composer!, + if (track.trackNumber != null && track.trackNumber! > 0) + 'trackNumber': track.trackNumber!.toString(), + if (track.totalTracks != null && track.totalTracks! > 0) + 'totalTracks': track.totalTracks!.toString(), + if (track.discNumber != null && track.discNumber! > 0) + 'discNumber': track.discNumber!.toString(), + if (track.totalDiscs != null && track.totalDiscs! > 0) + 'totalDiscs': track.totalDiscs!.toString(), + if (track.isrc != null) 'isrc': track.isrc!, + if (label != null && label.isNotEmpty) 'label': label, + if (copyright != null && copyright.isNotEmpty) 'copyright': copyright, + if (shouldEmbedLyrics) 'lyrics': ?lrcContent, + }; + final ac4Result = await PlatformBridge.writeAC4Metadata( + filePath, + ac4Meta, + validCover ?? '', + ); + if (ac4Result['handled'] == true) { + _log.d('AC-4 metadata embedded natively for $format'); + return; + } + } catch (e) { + _log.w('AC-4 metadata path failed, falling back to FFmpeg: $e'); + } + } + String? ffmpegResult; if (isFlac) { ffmpegResult = await FFmpegService.embedMetadata( @@ -7565,6 +7606,14 @@ class DownloadQueueNotifier extends Notifier { return; } + // Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted + // source. No-op for other codecs. + try { + await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath); + } catch (e) { + _log.w('AC-4 container repair skipped: $e'); + } + final dotIndex = decryptedTempPath.lastIndexOf('.'); final decryptedExt = dotIndex >= 0 ? decryptedTempPath.substring(dotIndex).toLowerCase() @@ -7617,10 +7666,11 @@ class DownloadQueueNotifier extends Notifier { } } } else { + final encryptedSource = filePath; final decryptedPath = await FFmpegService.decryptWithDescriptor( - inputPath: filePath, + inputPath: encryptedSource, descriptor: decryptionDescriptor, - deleteOriginal: true, + deleteOriginal: false, ); if (decryptedPath == null) { _log.e('FFmpeg decrypt failed for local file'); @@ -7631,10 +7681,20 @@ class DownloadQueueNotifier extends Notifier { errorType: DownloadErrorType.unknown, ); try { - await deleteFile(filePath); + await deleteFile(encryptedSource); } catch (_) {} return; } + // Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted + // source before discarding it. No-op for other codecs. + try { + await PlatformBridge.ensureAC4Config(decryptedPath, encryptedSource); + } catch (e) { + _log.w('AC-4 container repair skipped: $e'); + } + try { + await deleteFile(encryptedSource); + } catch (_) {} filePath = decryptedPath; _log.i('Local decryption completed'); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 172b22b4..be2af662 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -483,6 +483,7 @@ class FFmpegService { String outputPath, { required bool mapAudioOnly, required String key, + bool forceMovMuxer = false, }) { final audioMap = mapAudioOnly ? '-map 0:a ' : ''; // Force MOV demuxer: -decryption_key is only supported by the MOV/MP4 @@ -494,7 +495,12 @@ class FFmpegService { // extension AND keeps the input container's stream layout, which for // FLAC-in-MP4 sources would still emit an ISO-BMFF payload under a // .flac filename. That file fails native FLAC tag writers later on. - final muxerOverride = outputPath.toLowerCase().endsWith('.flac') + // + // forceMovMuxer routes through the MOV muxer for codecs the MP4 muxer + // rejects (e.g. AC-4), keeping the .mp4 filename. + final muxerOverride = forceMovMuxer + ? '-f mov ' + : outputPath.toLowerCase().endsWith('.flac') ? '-f flac ' : ''; return '-v error -decryption_key "$key" -f $demuxerFormat -i "$inputPath" $audioMap-c copy $muxerOverride"$outputPath" -y'; @@ -555,6 +561,24 @@ class FFmpegService { } } + // Final fallback: force the MOV muxer for codecs the MP4 muxer rejects + // (e.g. AC-4). MOV stores the codec params and keeps the .mp4 filename. + if (!result.success) { + final movFallbackOutput = _buildOutputPath(inputPath, '.mp4'); + final movFallbackResult = await _execute( + buildDecryptCommand( + movFallbackOutput, + mapAudioOnly: false, + key: keyCandidate, + forceMovMuxer: true, + ), + ); + if (movFallbackResult.success) { + tempOutput = movFallbackOutput; + result = movFallbackResult; + } + } + if (result.success) { decryptSucceeded = true; lastResult = result; @@ -1974,69 +1998,88 @@ class FFmpegService { }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a'); - final arguments = ['-v', 'error', '-hide_banner', '-i', m4aPath]; final normalizedCoverPath = coverPath?.trim(); final hasCover = normalizedCoverPath != null && normalizedCoverPath.isNotEmpty && await File(normalizedCoverPath).exists(); - if (hasCover) { - arguments - ..add('-i') - ..add(normalizedCoverPath); - } - final preserveExistingStreams = preserveMetadata && !hasCover; - if (preserveExistingStreams) { - // When no replacement cover is provided, preserve all input streams so - // the existing attached artwork is not dropped during the metadata rewrite. - arguments - ..add('-map') - ..add('0') - ..add('-c') - ..add('copy'); - } else { - arguments - ..add('-map') - ..add('0:a') - ..add('-c:a') - ..add('copy'); - } - arguments - ..add('-map_metadata') - ..add(preserveMetadata ? '0' : '-1'); - // For M4A cover replacements, mark the image as an attached picture so the - // mp4 muxer writes a proper covr atom instead of a generic MJPEG video track. - // Force the mp4 muxer because the default ipod muxer (auto-selected for .m4a) - // does not register a codec tag for mjpeg on FFmpeg 8.0+. - if (hasCover) { + List buildArgs(bool forceMov) { + final arguments = ['-v', 'error', '-hide_banner', '-i', m4aPath]; + if (hasCover) { + arguments + ..add('-i') + ..add(normalizedCoverPath); + } + if (preserveExistingStreams) { + // When no replacement cover is provided, preserve all input streams so + // the existing attached artwork is not dropped during the metadata rewrite. + arguments + ..add('-map') + ..add('0') + ..add('-c') + ..add('copy'); + } else { + arguments + ..add('-map') + ..add('0:a') + ..add('-c:a') + ..add('copy'); + } arguments - ..add('-map') - ..add('1:v') - ..add('-c:v') - ..add('copy') - ..add('-disposition:v:0') - ..add('attached_pic') - ..add('-metadata:s:v') - ..add('title=Album cover') - ..add('-metadata:s:v') - ..add('comment=Cover (front)') - ..add('-f') - ..add('mp4'); - } + ..add('-map_metadata') + ..add(preserveMetadata ? '0' : '-1'); - if (metadata != null) { - _appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata)); - } + if (hasCover) { + // Mark the image as an attached picture so the container writes a proper + // covr atom instead of a generic MJPEG video track. + arguments + ..add('-map') + ..add('1:v') + ..add('-c:v') + ..add('copy') + ..add('-disposition:v:0') + ..add('attached_pic') + ..add('-metadata:s:v') + ..add('title=Album cover') + ..add('-metadata:s:v') + ..add('comment=Cover (front)'); + } - arguments - ..add(tempOutput) - ..add('-y'); + if (metadata != null) { + _appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata)); + } + + // MOV muxer accepts codecs the MP4 muxer rejects (e.g. AC-4). The default + // (no -f) keeps the ipod muxer for plain .m4a; cover writes force mp4. + if (forceMov) { + arguments + ..add('-f') + ..add('mov'); + } else if (hasCover) { + arguments + ..add('-f') + ..add('mp4'); + } + + arguments + ..add(tempOutput) + ..add('-y'); + return arguments; + } _log.d('Executing FFmpeg M4A embed command'); - final result = await _executeWithArguments(arguments); + var result = await _executeWithArguments(buildArgs(false)); + if (!result.success) { + _log.w('M4A embed failed with default muxer, retrying with mov muxer'); + try { + final stale = File(tempOutput); + if (await stale.exists()) await stale.delete(); + } catch (_) {} + result = await _executeWithArguments(buildArgs(true)); + } if (result.success) { try { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index e9305162..d96bcfaf 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -809,6 +809,39 @@ class PlatformBridge { return _decodeRequiredMapResult(result, 'writeM4AFreeformTags'); } + /// Normalizes a decrypted AC-4 file to a standards-compliant ISO MP4 and + /// injects the dac4 configuration box from the encrypted [sourcePath]. The + /// FFmpeg mov muxer drops dac4 and writes a QuickTime-flavored container that + /// players reject, so this repair is required for AC-4 to be playable. + static Future> ensureAC4Config( + String filePath, + String sourcePath, + ) async { + final result = await _channel.invokeMethod('ensureAC4Config', { + 'file_path': filePath, + 'source_path': sourcePath, + }); + return _decodeRequiredMapResult(result, 'ensureAC4Config'); + } + + /// Writes iTunes-style metadata (and cover art) into an AC-4 MP4. Returns a + /// map whose `handled` flag is `true` when the file was AC-4 and metadata was + /// written natively, signalling the caller to skip the FFmpeg metadata pass + /// (which would re-wrap the file as QuickTime). + static Future> writeAC4Metadata( + String filePath, + Map metadata, + String coverPath, + ) async { + final metadataJSON = jsonEncode(metadata); + final result = await _channel.invokeMethod('writeAC4Metadata', { + 'file_path': filePath, + 'metadata_json': metadataJSON, + 'cover_path': coverPath, + }); + return _decodeRequiredMapResult(result, 'writeAC4Metadata'); + } + /// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries /// using the native Go FLAC writer, fixing FFmpeg's tag deduplication. static Future> rewriteSplitArtistTags(