From cd6beaa7d46bf651e0651547e41e0a6c7e905834 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 12 Feb 2026 01:06:08 +0700 Subject: [PATCH] feat: add filterContributingArtistsInAlbumArtist setting - new option to strip contributing artists from Album Artist metadata - applies to folder organization and metadata embedding - collapsible Artist Name Filters section in download settings UI --- lib/models/settings.dart | 50 +++++++---- lib/models/settings.g.dart | 4 + lib/providers/download_queue_provider.dart | 75 +++++++++++------ lib/providers/settings_provider.dart | 5 ++ .../settings/download_settings_page.dart | 82 +++++++++++++++---- 5 files changed, 161 insertions(+), 55 deletions(-) diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 11c904c7..415e7a22 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -21,6 +21,7 @@ class AppSettings { final String folderOrganization; final bool useAlbumArtistForFolders; final bool usePrimaryArtistOnly; // Strip featured artists from folder name + final bool filterContributingArtistsInAlbumArtist; final String historyViewMode; final String historyFilterMode; final bool askQualityBeforeDownload; @@ -36,18 +37,24 @@ class AppSettings { final bool showExtensionStore; final String locale; final String lyricsMode; - final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' - final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE - final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file - final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only - + final String + tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' + final bool + useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE + final bool + autoExportFailedDownloads; // Auto export failed downloads to TXT file + final String + downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only + // Local Library Settings final bool localLibraryEnabled; // Enable local library scanning final String localLibraryPath; // Path to scan for audio files - final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks - + final bool + localLibraryShowDuplicates; // Show indicator when searching for existing tracks + // Tutorial/Onboarding - final bool hasCompletedTutorial; // Track if user has completed the app tutorial + final bool + hasCompletedTutorial; // Track if user has completed the app tutorial const AppSettings({ this.defaultService = 'tidal', @@ -67,6 +74,7 @@ class AppSettings { this.folderOrganization = 'none', this.useAlbumArtistForFolders = true, this.usePrimaryArtistOnly = false, + this.filterContributingArtistsInAlbumArtist = false, this.historyViewMode = 'grid', this.historyFilterMode = 'all', this.askQualityBeforeDownload = true, @@ -112,6 +120,7 @@ class AppSettings { String? folderOrganization, bool? useAlbumArtistForFolders, bool? usePrimaryArtistOnly, + bool? filterContributingArtistsInAlbumArtist, String? historyViewMode, String? historyFilterMode, bool? askQualityBeforeDownload, @@ -157,18 +166,25 @@ class AppSettings { folderOrganization: folderOrganization ?? this.folderOrganization, useAlbumArtistForFolders: useAlbumArtistForFolders ?? this.useAlbumArtistForFolders, - usePrimaryArtistOnly: - usePrimaryArtistOnly ?? this.usePrimaryArtistOnly, + usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + filterContributingArtistsInAlbumArtist ?? + this.filterContributingArtistsInAlbumArtist, historyViewMode: historyViewMode ?? this.historyViewMode, historyFilterMode: historyFilterMode ?? this.historyFilterMode, - askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, + askQualityBeforeDownload: + askQualityBeforeDownload ?? this.askQualityBeforeDownload, spotifyClientId: spotifyClientId ?? this.spotifyClientId, spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, - useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, + useCustomSpotifyCredentials: + useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, metadataSource: metadataSource ?? this.metadataSource, enableLogging: enableLogging ?? this.enableLogging, - useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, - searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider), + useExtensionProviders: + useExtensionProviders ?? this.useExtensionProviders, + searchProvider: clearSearchProvider + ? null + : (searchProvider ?? this.searchProvider), separateSingles: separateSingles ?? this.separateSingles, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, showExtensionStore: showExtensionStore ?? this.showExtensionStore, @@ -176,12 +192,14 @@ class AppSettings { lyricsMode: lyricsMode ?? this.lyricsMode, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, - autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, + autoExportFailedDownloads: + autoExportFailedDownloads ?? this.autoExportFailedDownloads, downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode, // Local Library localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, - localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, + localLibraryShowDuplicates: + localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, // Tutorial hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, ); diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 51c83220..3775bc89 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -24,6 +24,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( folderOrganization: json['folderOrganization'] as String? ?? 'none', useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true, usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false, + filterContributingArtistsInAlbumArtist: + json['filterContributingArtistsInAlbumArtist'] as bool? ?? false, historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyFilterMode: json['historyFilterMode'] as String? ?? 'all', askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, @@ -72,6 +74,8 @@ Map _$AppSettingsToJson(AppSettings instance) => 'folderOrganization': instance.folderOrganization, 'useAlbumArtistForFolders': instance.useAlbumArtistForFolders, 'usePrimaryArtistOnly': instance.usePrimaryArtistOnly, + 'filterContributingArtistsInAlbumArtist': + instance.filterContributingArtistsInAlbumArtist, 'historyViewMode': instance.historyViewMode, 'historyFilterMode': instance.historyFilterMode, 'askQualityBeforeDownload': instance.askQualityBeforeDownload, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 3b2003e5..b3108e2e 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1197,11 +1197,18 @@ class DownloadQueueNotifier extends Notifier { String albumFolderStructure = 'artist_album', bool useAlbumArtistForFolders = true, bool usePrimaryArtistOnly = false, + bool filterContributingArtistsInAlbumArtist = false, }) async { String baseDir = state.outputDir; + final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders - ? _normalizeOptionalString(track.albumArtist) ?? track.artistName + ? normalizedAlbumArtist ?? track.artistName : track.artistName; + if (useAlbumArtistForFolders && + filterContributingArtistsInAlbumArtist && + normalizedAlbumArtist != null) { + folderArtist = _extractPrimaryArtist(folderArtist); + } if (usePrimaryArtistOnly) { folderArtist = _extractPrimaryArtist(folderArtist); } @@ -1309,6 +1316,15 @@ class DownloadQueueNotifier extends Notifier { return artist; } + String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) { + var albumArtist = + _normalizeOptionalString(track.albumArtist) ?? track.artistName; + if (settings.filterContributingArtistsInAlbumArtist) { + albumArtist = _extractPrimaryArtist(albumArtist); + } + return albumArtist; + } + bool _isSafMode(AppSettings settings) { return Platform.isAndroid && settings.storageMode == 'saf' && @@ -1333,10 +1349,17 @@ class DownloadQueueNotifier extends Notifier { String albumFolderStructure = 'artist_album', bool useAlbumArtistForFolders = true, bool usePrimaryArtistOnly = false, + bool filterContributingArtistsInAlbumArtist = false, }) async { + final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders - ? _normalizeOptionalString(track.albumArtist) ?? track.artistName + ? normalizedAlbumArtist ?? track.artistName : track.artistName; + if (useAlbumArtistForFolders && + filterContributingArtistsInAlbumArtist && + normalizedAlbumArtist != null) { + folderArtist = _extractPrimaryArtist(folderArtist); + } if (usePrimaryArtistOnly) { folderArtist = _extractPrimaryArtist(folderArtist); } @@ -1752,6 +1775,10 @@ class DownloadQueueNotifier extends Notifier { try { final settings = ref.read(settingsProvider); final extensionState = ref.read(extensionProvider); + final resolvedAlbumArtist = _resolveAlbumArtistForMetadata( + track, + settings, + ); if (!settings.useExtensionProviders) return; @@ -1766,8 +1793,7 @@ class DownloadQueueNotifier extends Notifier { 'title': track.name, 'artist': track.artistName, 'album': track.albumName, - 'album_artist': - _normalizeOptionalString(track.albumArtist) ?? track.artistName, + 'album_artist': resolvedAlbumArtist, 'track_number': track.trackNumber ?? 1, 'disc_number': track.discNumber ?? 1, 'isrc': track.isrc ?? '', @@ -1827,7 +1853,7 @@ class DownloadQueueNotifier extends Notifier { Track _buildTrackForMetadataEmbedding( Track baseTrack, Map backendResult, - String? normalizedAlbumArtist, + String resolvedAlbumArtist, ) { final backendTrackNum = _parsePositiveInt(backendResult['track_number']); final backendDiscNum = _parsePositiveInt(backendResult['disc_number']); @@ -1850,7 +1876,7 @@ class DownloadQueueNotifier extends Notifier { name: baseTrack.name, artistName: baseTrack.artistName, albumName: backendAlbum ?? baseTrack.albumName, - albumArtist: normalizedAlbumArtist, + albumArtist: resolvedAlbumArtist, coverUrl: baseTrack.coverUrl, duration: baseTrack.duration, isrc: baseTrack.isrc, @@ -1914,8 +1940,7 @@ class DownloadQueueNotifier extends Notifier { 'ALBUM': track.albumName, }; - final albumArtist = - _normalizeOptionalString(track.albumArtist) ?? track.artistName; + final albumArtist = _resolveAlbumArtistForMetadata(track, settings); metadata['ALBUMARTIST'] = albumArtist; if (track.trackNumber != null) { @@ -2057,8 +2082,7 @@ class DownloadQueueNotifier extends Notifier { 'ALBUM': track.albumName, }; - final albumArtist = - _normalizeOptionalString(track.albumArtist) ?? track.artistName; + final albumArtist = _resolveAlbumArtistForMetadata(track, settings); metadata['ALBUMARTIST'] = albumArtist; if (track.trackNumber != null) { @@ -2222,8 +2246,7 @@ class DownloadQueueNotifier extends Notifier { 'ALBUM': track.albumName, }; - final albumArtist = - _normalizeOptionalString(track.albumArtist) ?? track.artistName; + final albumArtist = _resolveAlbumArtistForMetadata(track, settings); metadata['ALBUMARTIST'] = albumArtist; if (track.trackNumber != null) { @@ -2741,8 +2764,9 @@ class DownloadQueueNotifier extends Notifier { _log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}'); - final normalizedAlbumArtist = _normalizeOptionalString( - trackToDownload.albumArtist, + final resolvedAlbumArtist = _resolveAlbumArtistForMetadata( + trackToDownload, + settings, ); final quality = item.qualityOverride ?? state.audioQuality; @@ -2755,6 +2779,8 @@ class DownloadQueueNotifier extends Notifier { albumFolderStructure: settings.albumFolderStructure, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + settings.filterContributingArtistsInAlbumArtist, ) : ''; String? appOutputDir; @@ -2767,6 +2793,8 @@ class DownloadQueueNotifier extends Notifier { albumFolderStructure: settings.albumFolderStructure, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + settings.filterContributingArtistsInAlbumArtist, ); var effectiveOutputDir = initialOutputDir; var effectiveSafMode = isSafMode; @@ -2972,7 +3000,7 @@ class DownloadQueueNotifier extends Notifier { trackName: trackToDownload.name, artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, - albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName, + albumArtist: resolvedAlbumArtist, coverUrl: trackToDownload.coverUrl ?? '', outputDir: outputDir, filenameFormat: state.filenameFormat, @@ -3021,6 +3049,8 @@ class DownloadQueueNotifier extends Notifier { albumFolderStructure: settings.albumFolderStructure, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + settings.filterContributingArtistsInAlbumArtist, ); final fallbackResult = await runDownload( useSaf: false, @@ -3358,7 +3388,7 @@ class DownloadQueueNotifier extends Notifier { final finalTrack = _buildTrackForMetadataEmbedding( trackToDownload, result, - normalizedAlbumArtist, + resolvedAlbumArtist, ); final backendGenre = result['genre'] as String?; @@ -3522,7 +3552,7 @@ class DownloadQueueNotifier extends Notifier { final finalTrack = _buildTrackForMetadataEmbedding( trackToDownload, result, - normalizedAlbumArtist, + resolvedAlbumArtist, ); final backendGenre = result['genre'] as String?; @@ -3582,7 +3612,7 @@ class DownloadQueueNotifier extends Notifier { final finalTrack = _buildTrackForMetadataEmbedding( trackToDownload, result, - normalizedAlbumArtist, + resolvedAlbumArtist, ); final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; @@ -3642,7 +3672,7 @@ class DownloadQueueNotifier extends Notifier { final finalTrack = _buildTrackForMetadataEmbedding( trackToDownload, result, - normalizedAlbumArtist, + resolvedAlbumArtist, ); final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; @@ -3679,7 +3709,7 @@ class DownloadQueueNotifier extends Notifier { final finalTrack = _buildTrackForMetadataEmbedding( trackToDownload, result, - normalizedAlbumArtist, + resolvedAlbumArtist, ); final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; @@ -3926,9 +3956,8 @@ class DownloadQueueNotifier extends Notifier { _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); final historyAlbumArtist = - (normalizedAlbumArtist != null && - normalizedAlbumArtist != trackToDownload.artistName) - ? normalizedAlbumArtist + resolvedAlbumArtist != trackToDownload.artistName + ? resolvedAlbumArtist : null; final isMp3 = filePath.endsWith('.mp3'); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index fb684bb7..7bc96508 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -236,6 +236,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setFilterContributingArtistsInAlbumArtist(bool enabled) { + state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled); + _saveSettings(); + } + void setHistoryViewMode(String mode) { state = state.copyWith(historyViewMode: mode); _saveSettings(); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 9f0456d0..da350dda 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -25,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState { static const _builtInServices = ['tidal', 'qobuz', 'amazon']; int _androidSdkVersion = 0; bool _hasAllFilesAccess = false; + bool _artistFolderFiltersExpanded = false; @override void initState() { @@ -363,19 +364,53 @@ class _DownloadSettingsPageState extends ConsumerState { onChanged: (value) => ref .read(settingsProvider.notifier) .setUseAlbumArtistForFolders(value), - showDivider: false, + ), + SettingsItem( + icon: Icons.filter_alt_outlined, + title: 'Artist Name Filters', + subtitle: _getArtistFolderFilterSubtitle( + context, + usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterAlbumArtistContributors: + settings.filterContributingArtistsInAlbumArtist, ), - 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, + trailing: Icon( + _artistFolderFiltersExpanded + ? Icons.expand_less + : Icons.expand_more, + ), + onTap: () { + setState(() { + _artistFolderFiltersExpanded = + !_artistFolderFiltersExpanded; + }); + }, + showDivider: !_artistFolderFiltersExpanded, + ), + if (_artistFolderFiltersExpanded) + 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), + ), + if (_artistFolderFiltersExpanded) + SettingsSwitchItem( + icon: Icons.group_remove_outlined, + title: 'Filter contributing artists in Album Artist', + subtitle: settings.filterContributingArtistsInAlbumArtist + ? 'Album Artist metadata uses primary artist only' + : 'Keep full Album Artist metadata value', + value: settings.filterContributingArtistsInAlbumArtist, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setFilterContributingArtistsInAlbumArtist(value), + showDivider: false, ), ], ), @@ -937,7 +972,10 @@ class _DownloadSettingsPageState extends ConsumerState { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar( - content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported), + content: Text( + validation.errorReason ?? + context.l10n.setupIcloudNotSupported, + ), backgroundColor: Theme.of(ctx).colorScheme.error, duration: const Duration(seconds: 4), ), @@ -1000,6 +1038,20 @@ class _DownloadSettingsPageState extends ConsumerState { } } + String _getArtistFolderFilterSubtitle( + BuildContext context, { + required bool usePrimaryArtistOnly, + required bool filterAlbumArtistContributors, + }) { + final statuses = [ + usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off', + filterAlbumArtistContributors + ? 'Album Artist metadata: Primary only' + : 'Album Artist metadata: Full', + ]; + return statuses.join(' | '); + } + String _getLyricsModeLabel(BuildContext context, String mode) { switch (mode) { case 'external': @@ -1456,9 +1508,7 @@ class _ServiceChip extends StatelessWidget { return Expanded( child: Material( - color: isSelected - ? colorScheme.primaryContainer - : unselectedColor, + color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap,