feat: primary artist only folders, fix notifications v20, fix SAF duplicate dirs

- Add 'Use Primary Artist Only' setting to strip featured artists from folder names
- Fix flutter_local_notifications v20 breaking changes (positional params)
- Fix SAF duplicate folder bug: synchronized ensureDocumentDir to prevent race condition creating empty folders with (1), (2) suffixes during concurrent downloads
This commit is contained in:
zarzet
2026-02-10 09:07:18 +07:00
parent ebe7d87da7
commit f7be2c1e12
24 changed files with 324 additions and 71 deletions
+18
View File
@@ -3550,6 +3550,24 @@ abstract class AppLocalizations {
/// **'Artist folders use Track Artist only'**
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
/// Setting - strip featured artists from folder name
///
/// In en, this message translates to:
/// **'Primary artist only for folders'**
String get downloadUsePrimaryArtistOnly;
/// Subtitle when primary artist only is enabled
///
/// In en, this message translates to:
/// **'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'**
String get downloadUsePrimaryArtistOnlyEnabled;
/// Subtitle when primary artist only is disabled
///
/// In en, this message translates to:
/// **'Full artist string used for folder name'**
String get downloadUsePrimaryArtistOnlyDisabled;
/// Setting - output file format
///
/// In en, this message translates to:
+11
View File
@@ -1960,6 +1960,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1945,6 +1945,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1945,6 +1945,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1945,6 +1945,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1945,6 +1945,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1958,6 +1958,17 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Folder artis hanya memakai Track Artist';
@override
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Nama artis lengkap dipakai untuk folder';
@override
String get downloadSaveFormat => 'Simpan Format';
+11
View File
@@ -1933,6 +1933,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => '形式を保存';
+11
View File
@@ -1945,6 +1945,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1945,6 +1945,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1945,6 +1945,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1983,6 +1983,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Формат сохранения';
+11
View File
@@ -1960,6 +1960,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+11
View File
@@ -1945,6 +1945,17 @@ class AppLocalizationsZh extends AppLocalizations {
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
+6
View File
@@ -1431,6 +1431,12 @@
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
"@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"},
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
"@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"},
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
"@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"},
"downloadSaveFormat": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service",
+12
View File
@@ -2489,6 +2489,18 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
},
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
"@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name"
},
"downloadUsePrimaryArtistOnlyEnabled": "Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)",
"@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled"
},
"downloadUsePrimaryArtistOnlyDisabled": "Nama artis lengkap dipakai untuk folder",
"@downloadUsePrimaryArtistOnlyDisabled": {
"description": "Subtitle when primary artist only is disabled"
},
"downloadSaveFormat": "Simpan Format",
"@downloadSaveFormat": {
"description": "Setting - output file format"
+5
View File
@@ -20,6 +20,7 @@ class AppSettings {
final bool hasSearchedBefore;
final String folderOrganization;
final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
@@ -65,6 +66,7 @@ class AppSettings {
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false,
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
@@ -109,6 +111,7 @@ class AppSettings {
bool? hasSearchedBefore,
String? folderOrganization,
bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly,
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
@@ -154,6 +157,8 @@ class AppSettings {
folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly:
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
+2
View File
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -70,6 +71,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
+26 -2
View File
@@ -1033,11 +1033,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bool separateSingles = false,
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
}) async {
String baseDir = state.outputDir;
final folderArtist = useAlbumArtistForFolders
var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (separateSingles) {
final isSingle = track.isSingle;
@@ -1129,6 +1133,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.trim();
}
static final _featuredArtistPattern = RegExp(
r'\s*[,;&]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
caseSensitive: false,
);
String _extractPrimaryArtist(String artist) {
final match = _featuredArtistPattern.firstMatch(artist);
if (match != null && match.start > 0) {
return artist.substring(0, match.start).trim();
}
return artist;
}
bool _isSafMode(AppSettings settings) {
return Platform.isAndroid &&
settings.storageMode == 'saf' &&
@@ -1152,10 +1169,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bool separateSingles = false,
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
}) async {
final folderArtist = useAlbumArtistForFolders
var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (separateSingles) {
final isSingle = track.isSingle;
@@ -2565,6 +2586,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
)
: '';
String? appOutputDir;
@@ -2576,6 +2598,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
);
var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode;
@@ -2873,6 +2896,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
);
final fallbackResult = await runDownload(
useSaf: false,
+5
View File
@@ -231,6 +231,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUsePrimaryArtistOnly(bool enabled) {
state = state.copyWith(usePrimaryArtistOnly: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
@@ -365,6 +365,18 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.setUseAlbumArtistForFolders(value),
showDivider: false,
),
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
showDivider: false,
),
],
),
),
+31 -31
View File
@@ -30,7 +30,7 @@ class NotificationService {
iOS: iosSettings,
);
await _notifications.initialize(settings: initSettings);
await _notifications.initialize(initSettings);
if (Platform.isAndroid) {
await _notifications
@@ -90,10 +90,10 @@ class NotificationService {
);
await _notifications.show(
id: downloadProgressId,
title: 'Downloading $trackName',
body: '$artistName$percentage%',
notificationDetails: details,
downloadProgressId,
'Downloading $trackName',
'$artistName$percentage%',
details,
);
}
@@ -133,10 +133,10 @@ class NotificationService {
);
await _notifications.show(
id: downloadProgressId,
title: 'Finalizing $trackName',
body: '$artistName • Embedding metadata...',
notificationDetails: details,
downloadProgressId,
'Finalizing $trackName',
'$artistName • Embedding metadata...',
details,
);
}
@@ -183,10 +183,10 @@ class NotificationService {
);
await _notifications.show(
id: downloadProgressId,
title: title,
body: '$trackName - $artistName',
notificationDetails: details,
downloadProgressId,
title,
'$trackName - $artistName',
details,
);
}
@@ -223,15 +223,15 @@ class NotificationService {
);
await _notifications.show(
id: downloadProgressId,
title: title,
body: '$completedCount tracks downloaded successfully',
notificationDetails: details,
downloadProgressId,
title,
'$completedCount tracks downloaded successfully',
details,
);
}
Future<void> cancelDownloadNotification() async {
await _notifications.cancel(id: downloadProgressId);
await _notifications.cancel(downloadProgressId);
}
Future<void> showUpdateDownloadProgress({
@@ -274,10 +274,10 @@ class NotificationService {
);
await _notifications.show(
id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version',
body: '$receivedMB / $totalMB MB • $percentage%',
notificationDetails: details,
updateDownloadId,
'Downloading SpotiFLAC v$version',
'$receivedMB / $totalMB MB • $percentage%',
details,
);
}
@@ -307,10 +307,10 @@ class NotificationService {
);
await _notifications.show(
id: updateDownloadId,
title: 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.',
notificationDetails: details,
updateDownloadId,
'Update Ready',
'SpotiFLAC v$version downloaded. Tap to install.',
details,
);
}
@@ -339,14 +339,14 @@ class NotificationService {
);
await _notifications.show(
id: updateDownloadId,
title: 'Update Failed',
body: 'Could not download update. Try again later.',
notificationDetails: details,
updateDownloadId,
'Update Failed',
'Could not download update. Try again later.',
details,
);
}
Future<void> cancelUpdateNotification() async {
await _notifications.cancel(id: updateDownloadId);
await _notifications.cancel(updateDownloadId);
}
}