fix: skip M4A conversion for existing files and prevent empty SAF folders on duplicates

This commit is contained in:
zarzet
2026-02-08 15:44:05 +07:00
parent f0790b627d
commit f9dd82010f
4 changed files with 117 additions and 90 deletions
+2
View File
@@ -33,6 +33,8 @@
- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads - 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 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 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 ## [3.5.1] - 2026-02-08
@@ -602,22 +602,30 @@ class MainActivity: FlutterFragmentActivity() {
val relativeDir = req.optString("saf_relative_dir", "") val relativeDir = req.optString("saf_relative_dir", "")
val outputExt = normalizeExt(req.optString("saf_output_ext", "")) val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt) 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) val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory") ?: return errorJson("Failed to access SAF directory")
val fileName = buildSafFileName(req, outputExt) val existingFile = targetDir.findFile(fileName)
val existing = targetDir.findFile(fileName) val document = existingFile ?: targetDir.createFile(mimeType, 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)
?: return errorJson("Failed to create SAF file") ?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw") val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -2813,6 +2813,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(filePath.endsWith('.flac') || (filePath.endsWith('.flac') ||
(mimeType != null && mimeType.contains('flac'))); (mimeType != null && mimeType.contains('flac')));
final shouldForceTidalSafM4aHandling = final shouldForceTidalSafM4aHandling =
!wasExisting &&
isContentUriPath && isContentUriPath &&
effectiveSafMode && effectiveSafMode &&
actualService == 'tidal' && actualService == 'tidal' &&
+93 -77
View File
@@ -54,47 +54,6 @@ class DonatePage extends StatelessWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ 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 // Donate links card
_DonateLinksCard(colorScheme: colorScheme), _DonateLinksCard(colorScheme: colorScheme),
@@ -103,57 +62,83 @@ class DonatePage extends StatelessWidget {
// Recent donors section // Recent donors section
_RecentDonorsCard(colorScheme: colorScheme), _RecentDonorsCard(colorScheme: colorScheme),
const SizedBox(height: 12), const SizedBox(height: 16),
// Notice // Combined notice card
Card( Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Row(
Icons.info_outline_rounded, children: [
size: 20, Icon(
color: colorScheme.onSurfaceVariant, 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), const SizedBox(height: 10),
Expanded( _NoticeLine(
child: Column( icon: Icons.block,
crossAxisAlignment: CrossAxisAlignment.start, text: 'Not selling early access, premium features, or paywalls',
children: [ colorScheme: colorScheme,
Text( ),
'About Supporters', const SizedBox(height: 6),
style: Theme.of(context).textTheme.titleSmall _NoticeLine(
?.copyWith( icon: Icons.build_outlined,
fontWeight: FontWeight.w600, text: 'Funds go to dev tools & testing devices',
color: colorScheme.onSurface, colorScheme: colorScheme,
), ),
), const SizedBox(height: 6),
const SizedBox(height: 6), _NoticeLine(
Text( icon: Icons.favorite_border,
'By supporting SpotiFLAC, you become part of this app\'s history. ' text: 'Your support is the only way to keep this project alive',
'Your name will remain in this version permanently as a token of appreciation. ' colorScheme: colorScheme,
'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 ' Divider(
'every version it was included in.', height: 24,
style: Theme.of(context).textTheme.bodySmall color: colorScheme.outlineVariant.withValues(alpha: 0.3),
?.copyWith( ),
color: colorScheme.onSurfaceVariant, _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,
),
),
),
],
);
}
}