diff --git a/CHANGELOG.md b/CHANGELOG.md index aac4e343..b04f60df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,15 @@ - `lib/screens/queue_screen.dart` - `lib/screens/settings_screen.dart` - `lib/screens/settings_tab.dart` +- Concurrent download limit increased from `3` to `5` (settings clamp + Options UI chips now support `1..5`) +- Download queue now uses a single parallel scheduler path; `1` concurrency is handled as parallel-with-limit-1 (no separate sequential engine) +- Download queue now listens to settings updates in real-time so concurrency/output settings stay in sync while queue is active ### Fixed - CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import +- Fixed dynamic concurrency update during active downloads: changing limit (e.g. `1 -> 3`) now schedules additional queued items without waiting current active item to finish +- Queue scheduler now re-checks capacity/queued items on short intervals to avoid blocking on long-running single active download --- diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index 5d7438d7..5b11797e 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -22,6 +22,11 @@ type LogBuffer struct { loggingEnabled bool } +const ( + defaultLogBufferSize = 500 + maxLogMessageLength = 500 +) + var ( globalLogBuffer *LogBuffer logBufferOnce sync.Once @@ -30,14 +35,22 @@ var ( func GetLogBuffer() *LogBuffer { logBufferOnce.Do(func() { globalLogBuffer = &LogBuffer{ - entries: make([]LogEntry, 0, 1000), - maxSize: 1000, + entries: make([]LogEntry, 0, defaultLogBufferSize), + maxSize: defaultLogBufferSize, loggingEnabled: false, // Default: disabled for performance (user can enable in settings) } }) return globalLogBuffer } +func truncateLogMessage(message string) string { + runes := []rune(message) + if len(runes) <= maxLogMessageLength { + return message + } + return string(runes[:maxLogMessageLength]) + "...[truncated]" +} + func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { lb.mu.Lock() defer lb.mu.Unlock() @@ -58,6 +71,8 @@ func (lb *LogBuffer) Add(level, tag, message string) { return } + message = truncateLogMessage(message) + entry := LogEntry{ Timestamp: time.Now().Format("15:04:05.000"), Level: level, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e5386f13..ebf76698 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -614,6 +614,7 @@ class DownloadQueueNotifier extends Notifier { static const _cleanupInterval = 50; static const _queueStorageKey = 'download_queue'; static const _progressPollingInterval = Duration(milliseconds: 800); + static const _queueSchedulingInterval = Duration(milliseconds: 250); final NotificationService _notificationService = NotificationService(); final Future _prefs = SharedPreferences.getInstance(); int _totalQueuedAtStart = 0; @@ -630,12 +631,24 @@ class DownloadQueueNotifier extends Notifier { @override DownloadQueueState build() { + ref.listen(settingsProvider, (previous, next) { + final previousConcurrent = + previous?.concurrentDownloads ?? state.concurrentDownloads; + updateSettings(next); + if (previousConcurrent != next.concurrentDownloads) { + _log.i( + 'Concurrent downloads updated: $previousConcurrent -> ${next.concurrentDownloads}', + ); + } + }); + ref.onDispose(() { _progressTimer?.cancel(); _progressTimer = null; }); Future.microtask(() async { + updateSettings(ref.read(settingsProvider)); await _initOutputDir(); await _loadQueueFromStorage(); }); @@ -1222,6 +1235,7 @@ class DownloadQueueNotifier extends Notifier { } void updateSettings(AppSettings settings) { + final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5); state = state.copyWith( outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory @@ -1229,7 +1243,7 @@ class DownloadQueueNotifier extends Notifier { filenameFormat: settings.filenameFormat, audioQuality: settings.audioQuality, autoFallback: settings.autoFallback, - concurrentDownloads: settings.concurrentDownloads, + concurrentDownloads: concurrentDownloads, ); } @@ -2178,6 +2192,7 @@ class DownloadQueueNotifier extends Notifier { // Check network connectivity before starting final settings = ref.read(settingsProvider); + updateSettings(settings); final isSafMode = _isSafMode(settings); if (settings.downloadNetworkMode == 'wifi_only') { final connectivityResult = await Connectivity().checkConnectivity(); @@ -2288,12 +2303,7 @@ class DownloadQueueNotifier extends Notifier { } } _log.d('Concurrent downloads: ${state.concurrentDownloads}'); - - if (state.concurrentDownloads > 1) { - await _processQueueParallel(); - } else { - await _processQueueSequential(); - } + await _processQueueParallel(); _stopProgressPolling(); @@ -2349,56 +2359,25 @@ class DownloadQueueNotifier extends Notifier { } } - Future _processQueueSequential() async { - _startMultiProgressPolling(); - - while (true) { - if (state.isPaused) { - _log.d('Queue is paused, waiting...'); - await Future.delayed(_progressPollingInterval); - continue; - } - - final currentItems = state.items; - final nextIndex = currentItems.indexWhere( - (item) => item.status == DownloadStatus.queued, - ); - if (nextIndex == -1) { - _log.d( - 'No more items to process (checked ${currentItems.length} items)', - ); - break; - } - - final nextItem = currentItems[nextIndex]; - _log.d( - 'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})', - ); - await _downloadSingleItem(nextItem); - - PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {}); - } - - _stopProgressPolling(); - } - Future _processQueueParallel() async { - final maxConcurrent = state.concurrentDownloads; final activeDownloads = >{}; + var lastLoggedMaxConcurrent = -1; _startMultiProgressPolling(); while (true) { if (state.isPaused) { _log.d('Queue is paused, waiting for active downloads...'); - if (activeDownloads.isNotEmpty) { - await Future.any(activeDownloads.values); - } else { - await Future.delayed(_progressPollingInterval); - } + await Future.delayed(_queueSchedulingInterval); continue; } + final maxConcurrent = max(1, state.concurrentDownloads); + if (lastLoggedMaxConcurrent != maxConcurrent) { + _log.d('Parallel worker max concurrency now: $maxConcurrent'); + lastLoggedMaxConcurrent = maxConcurrent; + } + final queuedItems = state.items .where((item) => item.status == DownloadStatus.queued) .toList(); @@ -2427,7 +2406,14 @@ class DownloadQueueNotifier extends Notifier { } if (activeDownloads.isNotEmpty) { - await Future.any(activeDownloads.values); + // Re-check queue/settings periodically so concurrency changes + // (e.g. 1 -> 3) can take effect before any active item finishes. + await Future.any([ + Future.any(activeDownloads.values), + Future.delayed(_queueSchedulingInterval), + ]); + } else { + await Future.delayed(_queueSchedulingInterval); } } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 83762706..b00037eb 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -199,7 +199,7 @@ class SettingsNotifier extends Notifier { } void setConcurrentDownloads(int count) { - final clamped = count.clamp(1, 3); + final clamped = count.clamp(1, 5); state = state.copyWith(concurrentDownloads: clamped); _saveSettings(); } diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 4c941095..648b779d 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -24,46 +24,48 @@ class OptionsSettingsPage extends ConsumerWidget { body: CustomScrollView( slivers: [ SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = - ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only( - left: leftPadding, - bottom: 16, - ), - title: Text( - context.l10n.optionsTitle, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, ), - ), - ); - }, + title: Text( + context.l10n.optionsTitle, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), ), - ), SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionSearchSource), + child: SettingsSectionHeader( + title: context.l10n.sectionSearchSource, + ), ), SliverToBoxAdapter( child: SettingsGroup( @@ -86,14 +88,18 @@ class OptionsSettingsPage extends ConsumerWidget { children: [ Icon( Icons.warning_amber_rounded, - color: Theme.of(context).colorScheme.onErrorContainer, + color: Theme.of( + context, + ).colorScheme.onErrorContainer, ), const SizedBox(width: 12), Expanded( child: Text( context.l10n.optionsSpotifyWarning, style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, + color: Theme.of( + context, + ).colorScheme.onErrorContainer, fontSize: 12, ), ), @@ -107,7 +113,11 @@ class OptionsSettingsPage extends ConsumerWidget { icon: Icons.key, title: context.l10n.optionsSpotifyCredentials, subtitle: settings.spotifyClientId.isNotEmpty - ? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId) + ? context.l10n.optionsSpotifyCredentialsConfigured( + settings.spotifyClientId.length > 8 + ? settings.spotifyClientId.substring(0, 8) + : settings.spotifyClientId, + ) : context.l10n.optionsSpotifyCredentialsRequired, onTap: () => _showSpotifyCredentialsDialog(context, ref, settings), @@ -168,7 +178,9 @@ class OptionsSettingsPage extends ConsumerWidget { ), SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionPerformance), + child: SettingsSectionHeader( + title: context.l10n.sectionPerformance, + ), ), SliverToBoxAdapter( child: SettingsGroup( @@ -277,9 +289,7 @@ class OptionsSettingsPage extends ConsumerWidget { context: context, builder: (context) => AlertDialog( title: Text(context.l10n.dialogClearHistoryTitle), - content: Text( - context.l10n.dialogClearHistoryMessage, - ), + content: Text(context.l10n.dialogClearHistoryMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -289,11 +299,14 @@ class OptionsSettingsPage extends ConsumerWidget { onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); Navigator.pop(context); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarHistoryCleared)), + ); }, - child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)), + child: Text( + context.l10n.dialogClear, + style: TextStyle(color: colorScheme.error), + ), ), ], ), @@ -323,7 +336,7 @@ class OptionsSettingsPage extends ConsumerWidget { final removed = await ref .read(downloadHistoryProvider.notifier) .cleanupOrphanedDownloads(); - + if (context.mounted) { Navigator.pop(context); // Close loading dialog ScaffoldMessenger.of(context).showSnackBar( @@ -339,9 +352,9 @@ class OptionsSettingsPage extends ConsumerWidget { } catch (e) { if (context.mounted) { Navigator.pop(context); // Close loading dialog - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } } @@ -493,7 +506,11 @@ class OptionsSettingsPage extends ConsumerWidget { .setSpotifyCredentials(clientId, clientSecret); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)), + SnackBar( + content: Text( + context.l10n.snackbarCredentialsSaved, + ), + ), ); } else { ScaffoldMessenger.of(context).showSnackBar( @@ -524,7 +541,11 @@ class OptionsSettingsPage extends ConsumerWidget { .clearSpotifyCredentials(); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)), + SnackBar( + content: Text( + context.l10n.snackbarCredentialsCleared, + ), + ), ); }, style: TextButton.styleFrom( @@ -582,7 +603,9 @@ class _ConcurrentDownloadsItem extends StatelessWidget { Text( currentValue == 1 ? context.l10n.optionsConcurrentSequential - : context.l10n.optionsConcurrentParallel(currentValue), + : context.l10n.optionsConcurrentParallel( + currentValue, + ), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -612,6 +635,18 @@ class _ConcurrentDownloadsItem extends StatelessWidget { isSelected: currentValue == 3, onTap: () => onChanged(3), ), + const SizedBox(width: 8), + _ConcurrentChip( + label: '4', + isSelected: currentValue == 4, + onTap: () => onChanged(4), + ), + const SizedBox(width: 8), + _ConcurrentChip( + label: '5', + isSelected: currentValue == 5, + onTap: () => onChanged(5), + ), ], ), const SizedBox(height: 12), @@ -837,20 +872,21 @@ class _MetadataSourceSelector extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); - + Extension? activeExtension; - if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { + if (settings.searchProvider != null && + settings.searchProvider!.isNotEmpty) { activeExtension = extState.extensions .where((e) => e.id == settings.searchProvider && e.enabled) .firstOrNull; } final hasExtensionSearch = activeExtension != null; - + String? extensionName; if (hasExtensionSearch) { extensionName = activeExtension.displayName; } - + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -868,8 +904,8 @@ class _MetadataSourceSelector extends ConsumerWidget { ? context.l10n.optionsUsingExtension(extensionName!) : context.l10n.optionsPrimaryProviderSubtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: hasExtensionSearch - ? colorScheme.primary + color: hasExtensionSearch + ? colorScheme.primary : colorScheme.onSurfaceVariant, ), ), diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index acc2c823..3faaa991 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -10,6 +10,8 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('FFmpeg'); class FFmpegService { + static const int _commandLogPreviewLength = 300; + static String _buildOutputPath(String inputPath, String extension) { final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; final inputFile = File(inputPath); @@ -26,6 +28,25 @@ class FFmpegService { return outputPath; } + static String _previewCommandForLog(String command) { + final redacted = command + .replaceAll( + RegExp(r'-metadata\s+lyrics="[^"]*"', caseSensitive: false), + '-metadata lyrics=""', + ) + .replaceAll( + RegExp(r'-metadata\s+unsyncedlyrics="[^"]*"', caseSensitive: false), + '-metadata unsyncedlyrics=""', + ) + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + if (redacted.length <= _commandLogPreviewLength) { + return redacted; + } + return '${redacted.substring(0, _commandLogPreviewLength)}...'; + } + static Future _execute(String command) async { try { final session = await FFmpegKit.execute(command); @@ -280,7 +301,7 @@ class FFmpegService { cmdBuffer.write('"$tempOutput" -y'); final command = cmdBuffer.toString(); - _log.d('Executing FFmpeg command: $command'); + _log.d('Executing FFmpeg command: ${_previewCommandForLog(command)}'); final result = await _execute(command); @@ -359,7 +380,9 @@ class FFmpegService { cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y'); final command = cmdBuffer.toString(); - _log.d('Executing FFmpeg MP3 embed command: $command'); + _log.d( + 'Executing FFmpeg MP3 embed command: ${_previewCommandForLog(command)}', + ); final result = await _execute(command); diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 78bd3fb1..9c8ef00d 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -7,6 +7,15 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +const int _maxLogMessageLength = 500; + +String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) { + if (value.length <= maxLength) { + return value; + } + return '${value.substring(0, maxLength)}...[truncated]'; +} + class LogEntry { final DateTime timestamp; final String level; @@ -70,10 +79,26 @@ class LogBuffer extends ChangeNotifier { return; } + final sanitizedMessage = _truncateLogText(entry.message); + final sanitizedError = entry.error != null + ? _truncateLogText(entry.error!) + : null; + final sanitizedEntry = + (sanitizedMessage == entry.message && sanitizedError == entry.error) + ? entry + : LogEntry( + timestamp: entry.timestamp, + level: entry.level, + tag: entry.tag, + message: sanitizedMessage, + error: sanitizedError, + isFromGo: entry.isFromGo, + ); + if (_entries.length >= maxEntries) { _entries.removeFirst(); } - _entries.add(entry); + _entries.add(sanitizedEntry); notifyListeners(); } @@ -283,12 +308,12 @@ class BufferedOutput extends LogOutput { void output(OutputEvent event) { if (kDebugMode) { for (final line in event.lines) { - debugPrint(line); + debugPrint(_truncateLogText(line)); } } final level = _levelToString(event.level); - final message = event.lines.join('\n'); + final message = _truncateLogText(event.lines.join('\n')); LogBuffer().add( LogEntry( @@ -386,7 +411,9 @@ class AppLogger { if (error != null) { _addToBuffer('ERROR', message, error: error.toString()); if (kDebugMode) { - debugPrint('[$_tag] ERROR: $message | $error'); + debugPrint( + '[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}', + ); if (stackTrace != null) { debugPrint(stackTrace.toString()); }