From b77def62f4620e71c0d3aa77ec5f3d03cf4c90bb Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Apr 2026 02:03:22 +0700 Subject: [PATCH] feat: expose extension utils, preserve M4A native container, and bump to v4.2.3+124 --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 1 + go_backend/extension_runtime.go | 4 + go_backend/extension_runtime_utils.go | 63 +++++ go_backend/extension_test.go | 52 ++++ go_backend/httputil.go | 17 +- go_backend/httputil_ios.go | 2 +- go_backend/httputil_utls.go | 6 +- go_backend/lyrics.go | 26 ++ go_backend/lyrics_apple.go | 5 +- go_backend/lyrics_musixmatch.go | 2 +- go_backend/lyrics_netease.go | 4 +- go_backend/lyrics_qqmusic.go | 2 +- go_backend/songlink.go | 7 +- ios/Runner/AppDelegate.swift | 3 + lib/constants/app_info.dart | 4 +- lib/providers/download_queue_provider.dart | 239 +++++++++--------- lib/providers/extension_provider.dart | 6 + lib/screens/settings/donate_page.dart | 1 + pubspec.yaml | 2 +- 19 files changed, 305 insertions(+), 141 deletions(-) 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 36d81b93..98eb3af1 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1969,6 +1969,7 @@ class MainActivity: FlutterFragmentActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + Gobackend.setAppVersion(BuildConfig.VERSION_NAME) // Always-enabled back callback to ensure back presses reach Flutter. // Nested tab navigators can incorrectly set frameworkHandlesBack(false), diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index f02e9521..7c77a43b 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -413,6 +413,10 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher) utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("randomUserAgent", r.randomUserAgent) + utilsObj.Set("appVersion", r.appVersion) + utilsObj.Set("appUserAgent", r.appUserAgent) + utilsObj.Set("sleep", r.sleep) + utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled) vm.Set("utils", utilsObj) logObj := vm.NewObject() diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index ede6c4b4..d7c42bf3 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -249,6 +249,69 @@ func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value { return r.vm.ToValue(getRandomUserAgent()) } +func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value { + return r.vm.ToValue(GetAppVersion()) +} + +func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value { + return r.vm.ToValue(appUserAgent()) +} + +func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(true) + } + + sleepMs := 0 + switch value := call.Arguments[0].Export().(type) { + case int64: + sleepMs = int(value) + case int32: + sleepMs = int(value) + case int: + sleepMs = value + case float64: + sleepMs = int(value) + default: + sleepMs = 0 + } + + if sleepMs <= 0 { + return r.vm.ToValue(true) + } + if sleepMs > 5*60*1000 { + sleepMs = 5 * 60 * 1000 + } + + itemID := r.getActiveDownloadItemID() + deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond) + + for { + if itemID != "" && isDownloadCancelled(itemID) { + return r.vm.ToValue(false) + } + + remaining := time.Until(deadline) + if remaining <= 0 { + return r.vm.ToValue(true) + } + + step := 100 * time.Millisecond + if remaining < step { + step = remaining + } + time.Sleep(step) + } +} + +func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value { + itemID := r.getActiveDownloadItemID() + if itemID == "" { + return r.vm.ToValue(false) + } + return r.vm.ToValue(isDownloadCancelled(itemID)) +} + func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value { msg := r.formatLogArgs(call.Arguments) GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) diff --git a/go_backend/extension_test.go b/go_backend/extension_test.go index 75cde774..b0d2b842 100644 --- a/go_backend/extension_test.go +++ b/go_backend/extension_test.go @@ -239,6 +239,58 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) { if result.String() == "" { t.Error("Expected non-empty JSON string") } + + result, err = vm.RunString(`utils.sleep(1)`) + if err != nil { + t.Fatalf("sleep failed: %v", err) + } + if !result.ToBoolean() { + t.Error("Expected sleep to complete successfully") + } + + runtime.setActiveDownloadItemID("test-item") + cancelDownload("test-item") + t.Cleanup(func() { + clearDownloadCancel("test-item") + runtime.clearActiveDownloadItemID() + }) + + result, err = vm.RunString(`utils.isDownloadCancelled()`) + if err != nil { + t.Fatalf("isDownloadCancelled failed: %v", err) + } + if !result.ToBoolean() { + t.Error("Expected active download cancellation to be visible to JS") + } + + SetAppVersion("4.2.2") + t.Cleanup(func() { + SetAppVersion("") + }) + + result, err = vm.RunString(`utils.appVersion()`) + if err != nil { + t.Fatalf("appVersion failed: %v", err) + } + if got := result.String(); got != "4.2.2" { + t.Fatalf("Expected appVersion 4.2.2, got %q", got) + } + + result, err = vm.RunString(`utils.appUserAgent()`) + if err != nil { + t.Fatalf("appUserAgent failed: %v", err) + } + if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" { + t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got) + } + + result, err = vm.RunString(`utils.sleep(50)`) + if err != nil { + t.Fatalf("cancel-aware sleep failed: %v", err) + } + if result.ToBoolean() { + t.Error("Expected sleep to abort when download is cancelled") + } } func TestExtensionRuntime_SSRFProtection(t *testing.T) { diff --git a/go_backend/httputil.go b/go_backend/httputil.go index f4badcf6..e48dc7d7 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -16,6 +16,19 @@ import ( "time" ) +func userAgentForURL(u *url.URL) string { + if u == nil { + return getRandomUserAgent() + } + + host := strings.ToLower(strings.TrimSpace(u.Hostname())) + if host == "api.zarz.moe" { + return appUserAgent() + } + + return getRandomUserAgent() +} + func getRandomUserAgent() string { chromeVersion := rand.Intn(26) + 120 chromeBuild := rand.Intn(1500) + 6000 @@ -225,7 +238,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request } func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", userAgentForURL(req.URL)) resp, err := client.Do(req) if err != nil { CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") @@ -255,7 +268,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf for attempt := 0; attempt <= config.MaxRetries; attempt++ { reqCopy := req.Clone(req.Context()) - reqCopy.Header.Set("User-Agent", getRandomUserAgent()) + reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL)) resp, err := client.Do(reqCopy) if err != nil { diff --git a/go_backend/httputil_ios.go b/go_backend/httputil_ios.go index 5edf55e8..43f41aae 100644 --- a/go_backend/httputil_ios.go +++ b/go_backend/httputil_ios.go @@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client { } func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", userAgentForURL(req.URL)) resp, err := sharedClient.Do(req) if err != nil { CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index 79191d39..8c9c721a 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -101,7 +101,7 @@ func GetCloudflareBypassClient() *http.Client { } func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", userAgentForURL(req.URL)) resp, err := sharedClient.Do(req) if err == nil { @@ -129,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...") reqCopy := req.Clone(req.Context()) - reqCopy.Header.Set("User-Agent", getRandomUserAgent()) + reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL)) return cloudflareBypassClient.Do(reqCopy) } @@ -155,7 +155,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err) reqCopy := req.Clone(req.Context()) - reqCopy.Header.Set("User-Agent", getRandomUserAgent()) + reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL)) return cloudflareBypassClient.Do(reqCopy) } diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index ce065d4c..c16321d6 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -39,8 +39,34 @@ var DefaultLyricsProviders = []string{ var ( lyricsProvidersMu sync.RWMutex lyricsProviders []string // ordered list of enabled providers + appVersionMu sync.RWMutex + appVersion string ) +func SetAppVersion(version string) { + normalized := strings.TrimSpace(version) + + appVersionMu.Lock() + defer appVersionMu.Unlock() + appVersion = normalized +} + +func GetAppVersion() string { + appVersionMu.RLock() + defer appVersionMu.RUnlock() + return appVersion +} + +func appUserAgent() string { + version := GetAppVersion() + + if version == "" { + return "SpotiFLAC-Mobile" + } + + return "SpotiFLAC-Mobile/" + version +} + type LyricsFetchOptions struct { IncludeTranslationNetease bool `json:"include_translation_netease"` IncludeRomanizationNetease bool `json:"include_romanization_netease"` diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 63370255..ff592b96 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", appUserAgent()) req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) @@ -147,7 +147,8 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) { if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", appUserAgent()) + req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index 5cdcf6fe..309f5480 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -72,7 +72,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", appUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index ff09ff9d..827ce057 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) for k, v := range neteaseHeaders { req.Header.Set(k, v) } - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", appUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { @@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ for k, v := range neteaseHeaders { req.Header.Set(k, v) } - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", appUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 9a034619..b68455e7 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -54,7 +54,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("User-Agent", appUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 0ef45375..c9bbd93e 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -147,6 +147,7 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl return nil, fmt.Errorf("failed to create resolve request: %w", err) } req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgentForURL(req.URL)) resp, err := s.client.Do(req) if err != nil { @@ -164,9 +165,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl } var resolveResp struct { - Success bool `json:"success"` - ISRC string `json:"isrc"` - SongUrls map[string]json.RawMessage `json:"songUrls"` + Success bool `json:"success"` + ISRC string `json:"isrc"` + SongUrls map[string]json.RawMessage `json:"songUrls"` } if err := json.Unmarshal(body, &resolveResp); err != nil { return nil, fmt.Errorf("failed to decode resolve response: %w", err) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 1b2bf496..aec06217 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -22,6 +22,9 @@ import Gobackend // Import Go framework _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + GobackendSetAppVersion(version) + } let controller = window?.rootViewController as! FlutterViewController let channel = FlutterMethodChannel( diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 102d712b..b583e0f0 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart'; /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '4.2.2'; - static const String buildNumber = '123'; + static const String version = '4.2.3'; + static const String buildNumber = '124'; static const String fullVersion = '$version+$buildNumber'; /// Shows "Internal" in debug builds, actual version in release. diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 70bcc315..84bb08d5 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2344,7 +2344,36 @@ class DownloadQueueNotifier extends Notifier { return '$prefix/$suffix'; } + String? _extensionPreferredOutputExt(String service) { + final normalizedService = service.trim().toLowerCase(); + if (normalizedService.isEmpty) return null; + + final extensionState = ref.read(extensionProvider); + for (final ext in extensionState.extensions) { + if (!ext.enabled || !ext.hasDownloadProvider) continue; + if (ext.id.toLowerCase() != normalizedService) continue; + + final preferred = ext.preferredDownloadOutputExtension; + if (preferred == null) return null; + + final normalized = preferred.startsWith('.') + ? preferred.toLowerCase() + : '.${preferred.toLowerCase()}'; + const allowed = {'.flac', '.m4a', '.mp3', '.opus'}; + if (allowed.contains(normalized)) { + return normalized; + } + return null; + } + + return null; + } + String _determineOutputExt(String quality, String service) { + final extensionPreferred = _extensionPreferredOutputExt(service); + if (extensionPreferred != null) { + return extensionPreferred; + } if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { return '.m4a'; } @@ -3718,8 +3747,8 @@ class DownloadQueueNotifier extends Notifier { /// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats. /// - /// [format] must be one of `'flac'`, `'mp3'`, or `'opus'`. - /// [writeExternalLrc] only applies to FLAC (non-SAF paths handle LRC separately). + /// [format] must be one of `'flac'`, `'m4a'`, `'mp3'`, or `'opus'`. + /// [writeExternalLrc] only applies to FLAC and M4A (non-SAF paths handle LRC separately). Future _embedMetadataToFile( String filePath, Track track, { @@ -3739,6 +3768,7 @@ class DownloadQueueNotifier extends Notifier { } final isFlac = format == 'flac'; + final isM4a = format == 'm4a'; final isMp3 = format == 'mp3'; // ── Cover download ────────────────────────────────────────────── @@ -3862,9 +3892,11 @@ class DownloadQueueNotifier extends Notifier { if (shouldEmbedLyrics && lrcContent != null) { metadata['LYRICS'] = lrcContent; if (isFlac || isMp3) metadata['UNSYNCEDLYRICS'] = lrcContent; - } else if (isFlac && !shouldEmbedLyrics) { + } else if ((isFlac || isM4a) && !shouldEmbedLyrics) { metadata['LYRICS'] = ''; - metadata['UNSYNCEDLYRICS'] = ''; + if (isFlac) { + metadata['UNSYNCEDLYRICS'] = ''; + } } if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) { @@ -3908,6 +3940,12 @@ class DownloadQueueNotifier extends Notifier { metadata: metadata, artistTagMode: settings.artistTagMode, ); + } else if (isM4a) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: filePath, + coverPath: validCover, + metadata: metadata, + ); } else if (isMp3) { ffmpegResult = await FFmpegService.embedMetadataToMp3( mp3Path: filePath, @@ -4957,7 +4995,7 @@ class DownloadQueueNotifier extends Notifier { if (shouldForceTidalSafM4aHandling) { _log.w( - 'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; forcing FFmpeg conversion to FLAC.', + 'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; preserving it as M4A instead.', ); } @@ -5075,82 +5113,61 @@ class DownloadQueueNotifier extends Notifier { } } } else { - _log.d('M4A file detected (SAF), converting to FLAC...'); + _log.d('M4A file detected (SAF), preserving native container...'); final tempPath = await _copySafToTemp(currentFilePath); if (tempPath != null) { - String? flacPath; try { - final length = await File(tempPath).length(); - if (length < 1024) { - _log.w('Temp M4A is too small (<1KB), skipping conversion'); - } else { + if (metadataEmbeddingEnabled) { updateItemStatus( item.id, DownloadStatus.finalizing, - progress: 0.95, + progress: 0.99, ); - flacPath = await FFmpegService.convertM4aToFlac(tempPath); - if (flacPath != null) { - _log.d('Converted to FLAC (temp): $flacPath'); - _log.d( - 'Embedding metadata and cover to converted FLAC...', - ); - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; + await _embedMetadataToFile( + tempPath, + finalTrack, + format: 'm4a', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + writeExternalLrc: false, + ); + } - await _embedMetadataToFile( - flacPath, - finalTrack, - format: 'flac', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, - writeExternalLrc: false, - ); + final newFileName = '${safBaseName ?? 'track'}.m4a'; + final newUri = await _writeTempToSaf( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: newFileName, + mimeType: _mimeTypeForExt('.m4a'), + srcPath: tempPath, + ); - final newFileName = '${safBaseName ?? 'track'}.flac'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), - srcPath: flacPath, - ); - - if (newUri != null) { - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); - } - filePath = newUri; - finalSafFileName = newFileName; - } else { - _log.w('Failed to write FLAC to SAF, keeping M4A'); - } - } else { - _log.w( - 'FFmpeg conversion returned null, keeping M4A file', - ); + if (newUri != null) { + if (newUri != currentFilePath) { + await _deleteSafFile(currentFilePath); } + filePath = newUri; + finalSafFileName = newFileName; + } else { + _log.w('Failed to write M4A to SAF, keeping original'); } } catch (e) { - _log.w('SAF M4A->FLAC conversion failed: $e'); + _log.w('SAF native M4A handling failed: $e'); } finally { try { await File(tempPath).delete(); } catch (_) {} - if (flacPath != null) { - try { - await File(flacPath).delete(); - } catch (_) {} - } } } } @@ -5230,82 +5247,58 @@ class DownloadQueueNotifier extends Notifier { actualQuality = 'AAC 320kbps'; } } else { - _log.d( - 'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', - ); + _log.d('M4A file detected, preserving native container...'); try { - final file = File(currentFilePath); + var targetPath = currentFilePath; + final file = File(targetPath); if (!await file.exists()) { _log.e('File does not exist at path: $filePath'); } else { - final length = await file.length(); - _log.i('File size before conversion: ${length / 1024} KB'); - - if (length < 1024) { - _log.w( - 'File is too small (<1KB), skipping conversion. Download might be corrupt.', + if (!targetPath.toLowerCase().endsWith('.m4a')) { + final renamedPath = targetPath.replaceAll( + RegExp(r'\.[^.]+$'), + '.m4a', ); + final finalRenamedPath = renamedPath == targetPath + ? '$targetPath.m4a' + : renamedPath; + await file.rename(finalRenamedPath); + targetPath = finalRenamedPath; + filePath = finalRenamedPath; } else { + filePath = targetPath; + } + + if (metadataEmbeddingEnabled) { updateItemStatus( item.id, DownloadStatus.finalizing, - progress: 0.95, + progress: 0.99, ); - final flacPath = await FFmpegService.convertM4aToFlac( - currentFilePath, + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, ); - if (flacPath != null) { - filePath = flacPath; - _log.d('Converted to FLAC: $flacPath'); + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; - _log.d( - 'Embedding metadata and cover to converted FLAC...', - ); - try { - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - if (backendGenre != null || - backendLabel != null || - backendCopyright != null) { - _log.d( - 'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright', - ); - } - - await _embedMetadataToFile( - flacPath, - finalTrack, - format: 'flac', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, - ); - _log.d('Metadata and cover embedded successfully'); - } catch (e) { - _log.w('Warning: Failed to embed metadata/cover: $e'); - } - } else { - _log.w( - 'FFmpeg conversion returned null, keeping M4A file', - ); - } + await _embedMetadataToFile( + targetPath, + finalTrack, + format: 'm4a', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + ); } } } catch (e) { - _log.w( - 'FFmpeg conversion process failed: $e, keeping M4A file', - ); + _log.w('Native M4A handling failed: $e'); } } } diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index a86b8ca5..95c392dc 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -178,6 +178,12 @@ class Extension { bool get hasPostProcessing => postProcessing?.enabled ?? false; bool get hasHomeFeed => capabilities['homeFeed'] == true; bool get hasBrowseCategories => capabilities['browseCategories'] == true; + String? get preferredDownloadOutputExtension { + final value = capabilities['downloadOutputExtension']; + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } } class SearchFilter { diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index a233c7b4..52b419d1 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -171,6 +171,7 @@ class _RecentDonorsCard extends StatelessWidget { 'R4ND0MIZ3D', 'Isra', 'bigJr48', + 'Mick', ]; // Match SettingsGroup color logic diff --git a/pubspec.yaml b/pubspec.yaml index d20d1389..c27ccd50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 4.2.2+123 +version: 4.2.3+124 environment: sdk: ^3.10.0