perf: unified parallel scheduler, dynamic concurrency 1-5, log truncation + FFmpeg command redaction

This commit is contained in:
zarzet
2026-02-07 19:25:22 +07:00
parent 3d366d21b7
commit 086511d3e9
7 changed files with 208 additions and 116 deletions
+5
View File
@@ -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
---
+17 -2
View File
@@ -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,
+33 -47
View File
@@ -614,6 +614,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<SharedPreferences> _prefs = SharedPreferences.getInstance();
int _totalQueuedAtStart = 0;
@@ -630,12 +631,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
@override
DownloadQueueState build() {
ref.listen<AppSettings>(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<DownloadQueueState> {
}
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<DownloadQueueState> {
filenameFormat: settings.filenameFormat,
audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback,
concurrentDownloads: settings.concurrentDownloads,
concurrentDownloads: concurrentDownloads,
);
}
@@ -2178,6 +2192,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// 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<DownloadQueueState> {
}
}
_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<DownloadQueueState> {
}
}
Future<void> _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<void> _processQueueParallel() async {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{};
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<DownloadQueueState> {
}
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);
}
}
+1 -1
View File
@@ -199,7 +199,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setConcurrentDownloads(int count) {
final clamped = count.clamp(1, 3);
final clamped = count.clamp(1, 5);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
}
+96 -60
View File
@@ -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,
),
),
+25 -2
View File
@@ -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="<redacted>"',
)
.replaceAll(
RegExp(r'-metadata\s+unsyncedlyrics="[^"]*"', caseSensitive: false),
'-metadata unsyncedlyrics="<redacted>"',
)
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (redacted.length <= _commandLogPreviewLength) {
return redacted;
}
return '${redacted.substring(0, _commandLogPreviewLength)}...';
}
static Future<FFmpegResult> _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);
+31 -4
View File
@@ -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());
}