From f9dd82010f3fb4b79ca007ebe66e23cbdff7fcff Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 8 Feb 2026 15:44:05 +0700 Subject: [PATCH] fix: skip M4A conversion for existing files and prevent empty SAF folders on duplicates --- CHANGELOG.md | 2 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 34 ++-- lib/providers/download_queue_provider.dart | 1 + lib/screens/settings/donate_page.dart | 170 ++++++++++-------- 4 files changed, 117 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b645b73..0920d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads - Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`) - Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search) +- Fixed FFmpeg M4A-to-FLAC conversion erroneously triggered on already-existing FLAC files when re-downloading duplicates via Tidal +- Fixed SAF download creating empty artist/album folders when re-downloading duplicate tracks; directory is now only created after confirming the file does not already exist ## [3.5.1] - 2026-02-08 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 c00c5d2..21c0f96 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -602,22 +602,30 @@ class MainActivity: FlutterFragmentActivity() { val relativeDir = req.optString("saf_relative_dir", "") val outputExt = normalizeExt(req.optString("saf_output_ext", "")) val mimeType = mimeTypeForExt(outputExt) + val fileName = buildSafFileName(req, outputExt) + + // Check for existing file WITHOUT creating the directory first. + // This prevents empty folders from being created for duplicate downloads. + val existingDir = findDocumentDir(treeUri, relativeDir) + if (existingDir != null) { + val existing = existingDir.findFile(fileName) + if (existing != null && existing.isFile && existing.length() > 0) { + val obj = JSONObject() + obj.put("success", true) + obj.put("message", "File already exists") + obj.put("file_path", existing.uri.toString()) + obj.put("file_name", existing.name ?: fileName) + obj.put("already_exists", true) + return obj.toString() + } + } + + // Only create the directory now that we know we need to download val targetDir = ensureDocumentDir(treeUri, relativeDir) ?: return errorJson("Failed to access SAF directory") - val fileName = buildSafFileName(req, outputExt) - val existing = targetDir.findFile(fileName) - if (existing != null && existing.isFile && existing.length() > 0) { - val obj = JSONObject() - obj.put("success", true) - obj.put("message", "File already exists") - obj.put("file_path", existing.uri.toString()) - obj.put("file_name", existing.name ?: fileName) - obj.put("already_exists", true) - return obj.toString() - } - - val document = existing ?: targetDir.createFile(mimeType, fileName) + val existingFile = targetDir.findFile(fileName) + val document = existingFile ?: targetDir.createFile(mimeType, fileName) ?: return errorJson("Failed to create SAF file") val pfd = contentResolver.openFileDescriptor(document.uri, "rw") diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9b3f128..8143d9c 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2813,6 +2813,7 @@ class DownloadQueueNotifier extends Notifier { (filePath.endsWith('.flac') || (mimeType != null && mimeType.contains('flac'))); final shouldForceTidalSafM4aHandling = + !wasExisting && isContentUriPath && effectiveSafMode && actualService == 'tidal' && diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index bcd0e46..ef662c0 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -54,47 +54,6 @@ class DonatePage extends StatelessWidget { padding: const EdgeInsets.all(16), child: Column( children: [ - // Header message - Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(28), - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Icon( - Icons.favorite_rounded, - size: 48, - color: colorScheme.primary, - ), - const SizedBox(height: 12), - Text( - 'Support SpotiFLAC-Mobile', - style: Theme.of(context).textTheme.titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 8), - Text( - 'SpotiFLAC-Mobile is free and open source. ' - 'If you enjoy using it, consider supporting ' - 'the development.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - ), - - const SizedBox(height: 16), - // Donate links card _DonateLinksCard(colorScheme: colorScheme), @@ -103,57 +62,83 @@ class DonatePage extends StatelessWidget { // Recent donors section _RecentDonorsCard(colorScheme: colorScheme), - const SizedBox(height: 12), + const SizedBox(height: 16), - // Notice + // Combined notice card Card( elevation: 0, - color: colorScheme.surfaceContainerLow, + color: colorScheme.secondaryContainer.withValues(alpha: 0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), child: Padding( - padding: const EdgeInsets.all(20), - child: Row( + padding: const EdgeInsets.all(16), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.info_outline_rounded, - size: 20, - color: colorScheme.onSurfaceVariant, + Row( + children: [ + Icon( + Icons.volunteer_activism_rounded, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Good to Know', + style: Theme.of(context).textTheme.titleSmall + ?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'About Supporters', - style: Theme.of(context).textTheme.titleSmall - ?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 6), - Text( - 'By supporting SpotiFLAC, you become part of this app\'s history. ' - 'Your name will remain in this version permanently as a token of appreciation. ' - 'The supporter list is updated manually each month and embedded directly in the app ' - '-- no remote server is used. Even if your support period ends, your name stays in ' - 'every version it was included in.', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), + const SizedBox(height: 10), + _NoticeLine( + icon: Icons.block, + text: 'Not selling early access, premium features, or paywalls', + colorScheme: colorScheme, + ), + const SizedBox(height: 6), + _NoticeLine( + icon: Icons.build_outlined, + text: 'Funds go to dev tools & testing devices', + colorScheme: colorScheme, + ), + const SizedBox(height: 6), + _NoticeLine( + icon: Icons.favorite_border, + text: 'Your support is the only way to keep this project alive', + colorScheme: colorScheme, + ), + Divider( + height: 24, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + _NoticeLine( + icon: Icons.history, + text: 'Your name stays permanently in every version it was included in', + colorScheme: colorScheme, + ), + const SizedBox(height: 6), + _NoticeLine( + icon: Icons.update, + text: 'Supporter list is updated monthly and embedded in the app', + colorScheme: colorScheme, + ), + const SizedBox(height: 6), + _NoticeLine( + icon: Icons.cloud_off, + text: 'No remote server -- everything is stored locally', + colorScheme: colorScheme, ), ], ), ), ), + + ], ), ), @@ -417,3 +402,34 @@ class _DonorTile extends StatelessWidget { ); } } + +class _NoticeLine extends StatelessWidget { + final IconData icon; + final String text; + final ColorScheme colorScheme; + + const _NoticeLine({ + required this.icon, + required this.text, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + ), + ), + ), + ], + ); + } +}