feat: propagate download cancellation through entire pipeline, add MusicBrainz album artist fallback, and allow disabling home feed

- Add reference-counted cancel entries to prevent premature cleanup when multiple operations share the same itemID
- Propagate cancellation to DownloadTrack, DownloadWithFallback, DownloadWithExtensionsJSON, extension providers, and ISRC search
- Fetch album artist from MusicBrainz when missing during download and re-enrich
- Make ALBUMARTIST tag nullable to avoid writing artistName as album artist
- Add home feed 'Off' option in extension settings
- Skip deezer in download provider priority sanitization
This commit is contained in:
zarzet
2026-04-16 02:55:40 +07:00
parent 57051bd649
commit bcd8a05352
31 changed files with 683 additions and 59 deletions
+12
View File
@@ -5571,6 +5571,18 @@ abstract class AppLocalizations {
/// **'Automatically select the best available'**
String get extensionsHomeFeedAutoSubtitle;
/// Extensions page - home feed provider option: off
///
/// In en, this message translates to:
/// **'Off'**
String get extensionsHomeFeedOff;
/// Extensions page - subtitle for off home feed option
///
/// In en, this message translates to:
/// **'Do not show the home feed on the main screen'**
String get extensionsHomeFeedOffSubtitle;
/// Extensions page - subtitle for a specific extension home feed option
///
/// In en, this message translates to:
+7
View File
@@ -3281,6 +3281,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3249,6 +3249,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3249,6 +3249,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3250,6 +3250,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3248,6 +3248,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3259,6 +3259,13 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3235,6 +3235,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3228,6 +3228,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3248,6 +3248,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3249,6 +3249,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3308,6 +3308,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3306,6 +3306,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+7
View File
@@ -3249,6 +3249,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String get extensionsHomeFeedOff => 'Off';
@override
String get extensionsHomeFeedOffSubtitle =>
'Do not show the home feed on the main screen';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
+8
View File
@@ -4255,6 +4255,14 @@
"@extensionsHomeFeedAutoSubtitle": {
"description": "Extensions page - subtitle for auto home feed option"
},
"extensionsHomeFeedOff": "Off",
"@extensionsHomeFeedOff": {
"description": "Extensions page - home feed provider option: off"
},
"extensionsHomeFeedOffSubtitle": "Do not show the home feed on the main screen",
"@extensionsHomeFeedOffSubtitle": {
"description": "Extensions page - subtitle for off home feed option"
},
"extensionsHomeFeedUse": "Use {extensionName} home feed",
"@extensionsHomeFeedUse": {
"description": "Extensions page - subtitle for a specific extension home feed option",
+2
View File
@@ -5,6 +5,8 @@ part 'settings.g.dart';
@JsonSerializable()
class AppSettings {
static const String homeFeedProviderOff = '__off__';
final String defaultService;
final String audioQuality;
final String filenameFormat;
+23 -13
View File
@@ -2208,11 +2208,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return artist;
}
String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) {
var albumArtist =
normalizeOptionalString(track.albumArtist) ?? track.artistName;
String? _resolveAlbumArtistForMetadata(Track track, AppSettings settings) {
var albumArtist = normalizeOptionalString(track.albumArtist);
if (settings.filterContributingArtistsInAlbumArtist) {
albumArtist = _extractPrimaryArtist(albumArtist);
albumArtist = albumArtist == null
? null
: normalizeOptionalString(_extractPrimaryArtist(albumArtist));
}
return albumArtist;
}
@@ -2488,6 +2489,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Future<String?> _searchDeezerTrackIdByIsrc(
String? isrc, {
required String lookupContext,
String? itemId,
}) async {
final normalizedIsrc = normalizeOptionalString(isrc);
if (normalizedIsrc == null || !_isValidISRC(normalizedIsrc)) {
@@ -2498,6 +2500,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('No Deezer ID, searching by $lookupContext: $normalizedIsrc');
final deezerResult = await PlatformBridge.searchDeezerByISRC(
normalizedIsrc,
itemId: itemId,
);
if (deezerResult['success'] == true && deezerResult['track_id'] != null) {
final deezerTrackId = deezerResult['track_id'].toString();
@@ -2564,6 +2567,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Future<_DeezerLookupPreparation> _resolveProviderTrackForDeezerLookup(
Track track,
String itemId,
) async {
try {
final colonIdx = track.id.indexOf(':');
@@ -2608,6 +2612,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final deezerTrackId = await _searchDeezerTrackIdByIsrc(
resolvedIsrc,
lookupContext: '$provider ISRC',
itemId: itemId,
);
return _DeezerLookupPreparation(
@@ -3347,7 +3352,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'album_artist': resolvedAlbumArtist,
'track_number': track.trackNumber ?? 0,
'disc_number': track.discNumber ?? 0,
'isrc': track.isrc ?? '',
@@ -3355,6 +3359,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'duration_ms': track.duration * 1000,
'cover_url': track.coverUrl ?? '',
};
if (resolvedAlbumArtist != null) {
metadata['album_artist'] = resolvedAlbumArtist;
}
final result = await PlatformBridge.runPostProcessingV2(
filePath,
@@ -3706,7 +3713,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Track _buildTrackForMetadataEmbedding(
Track baseTrack,
Map<String, dynamic> backendResult,
String resolvedAlbumArtist,
String? resolvedAlbumArtist,
) {
final backendTrackNum = _parsePositiveInt(backendResult['track_number']);
final backendDiscNum = _parsePositiveInt(backendResult['disc_number']);
@@ -3849,7 +3856,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (albumArtist != null) {
metadata['ALBUMARTIST'] = albumArtist;
}
if (track.trackNumber != null && track.trackNumber! > 0) {
final trackTag = formatIndexTag(track.trackNumber!, track.totalTracks);
@@ -4017,7 +4026,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await PlatformBridge.rewriteSplitArtistTags(
filePath,
track.artistName,
albumArtist,
albumArtist ?? '',
);
_log.d('Split artist tags rewritten via native FLAC writer');
} catch (e) {
@@ -4607,6 +4616,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
deezerTrackId = await _searchDeezerTrackIdByIsrc(
trackToDownload.isrc,
lookupContext: 'ISRC',
itemId: item.id,
);
if (shouldAbortWork('during Deezer ISRC lookup')) {
@@ -4624,6 +4634,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackToDownload.id.startsWith('qobuz:'))) {
final providerLookup = await _resolveProviderTrackForDeezerLookup(
trackToDownload,
item.id,
);
trackToDownload = providerLookup.track;
deezerTrackId ??= providerLookup.deezerTrackId;
@@ -4746,7 +4757,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: resolvedAlbumArtist,
albumArtist: resolvedAlbumArtist ?? '',
coverUrl: metadataEmbeddingEnabled
? (trackToDownload.coverUrl ?? '')
: '',
@@ -5889,10 +5900,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist =
resolvedAlbumArtist != trackToDownload.artistName
? resolvedAlbumArtist
: null;
final historyAlbumArtist = normalizeOptionalString(
trackToDownload.albumArtist,
);
final isLossyOutput =
lowerFilePath.endsWith('.mp3') ||
+14
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -221,6 +222,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> _restoreFromCache() async {
try {
if (ref.read(settingsProvider).homeFeedProvider ==
AppSettings.homeFeedProviderOff) {
_log.d('Home feed disabled, skipping cache restore');
return;
}
final prefs = await SharedPreferences.getInstance();
final cached = prefs.getString(_cacheKey);
final cachedTs = prefs.getInt(_cacheTsKey);
@@ -271,6 +278,13 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
if (ref.read(settingsProvider).homeFeedProvider ==
AppSettings.homeFeedProviderOff) {
_log.d('Home feed disabled by user setting');
state = const ExploreState();
return;
}
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
+2 -2
View File
@@ -397,7 +397,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
.toString(),
albumArtist: data['album_artist']?.toString() ?? widget.artistName,
albumArtist: normalizeOptionalString(data['album_artist']?.toString()),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
@@ -1124,7 +1124,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
name: (data['title'] ?? data['name'] ?? '').toString(),
artistName: artistName,
albumName: album.name,
albumArtist: widget.artistName,
albumArtist: null,
artistId: widget.artistId,
albumId: album.id.isNotEmpty ? album.id : null,
coverUrl: album.coverUrl,
+15 -1
View File
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -376,6 +377,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
void _fetchExploreIfNeeded() {
if (ref.read(settingsProvider).homeFeedProvider ==
AppSettings.homeFeedProviderOff) {
ref.read(exploreProvider.notifier).clear();
return;
}
final extState = ref.read(extensionProvider);
final exploreState = ref.read(exploreProvider);
final hasHomeFeedExtension = extState.extensions.any(
@@ -1259,6 +1266,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
),
);
final homeFeedDisabled = ref.watch(
settingsProvider.select(
(s) => s.homeFeedProvider == AppSettings.homeFeedProviderOff,
),
);
final colorScheme = Theme.of(context).colorScheme;
final searchText = _urlController.text.trim();
@@ -1285,6 +1297,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
!hasActualResults &&
!isLoading &&
!showRecentAccess &&
!homeFeedDisabled &&
(hasHomeFeedExtension || hasExploreContent) &&
hasExploreContent;
@@ -1536,6 +1549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
if (hasHomeFeedExtension &&
!homeFeedDisabled &&
!hasActualResults &&
!isLoading &&
exploreLoading)
@@ -4687,7 +4701,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? widget.albumName).toString(),
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
albumArtist: normalizeOptionalString(data['album_artist']?.toString()),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
+1 -1
View File
@@ -891,7 +891,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
'track_name': item.trackName,
'artist_name': item.artistName,
'album_name': item.albumName,
'album_artist': item.albumArtist ?? item.artistName,
'album_artist': item.albumArtist ?? '',
'track_number': item.trackNumber ?? 0,
'disc_number': item.discNumber ?? 0,
'release_date': item.releaseDate ?? '',
+1 -1
View File
@@ -5136,7 +5136,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
'track_name': item.trackName,
'artist_name': item.artistName,
'album_name': item.albumName,
'album_artist': item.albumArtist ?? item.artistName,
'album_artist': item.albumArtist ?? '',
'track_number': item.trackNumber ?? 0,
'disc_number': item.discNumber ?? 0,
'release_date': item.releaseDate ?? '',
+40 -22
View File
@@ -859,9 +859,13 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
.toList();
final hasAnyProvider = homeFeedProviders.isNotEmpty;
final homeFeedDisabled =
settings.homeFeedProvider == AppSettings.homeFeedProviderOff;
String currentProviderName = context.l10n.extensionsHomeFeedAuto;
if (settings.homeFeedProvider != null &&
if (homeFeedDisabled) {
currentProviderName = context.l10n.extensionsHomeFeedOff;
} else if (settings.homeFeedProvider != null &&
settings.homeFeedProvider!.isNotEmpty) {
final ext = homeFeedProviders
.where((e) => e.id == settings.homeFeedProvider)
@@ -873,23 +877,19 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: !hasAnyProvider
? null
: () => _showHomeFeedProviderPicker(
context,
ref,
settings,
homeFeedProviders,
),
onTap: () => _showHomeFeedProviderPicker(
context,
ref,
settings,
homeFeedProviders,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.explore_outlined,
color: !hasAnyProvider
? colorScheme.outline
: colorScheme.onSurfaceVariant,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
@@ -898,13 +898,11 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
children: [
Text(
context.l10n.extensionsHomeFeedProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: !hasAnyProvider ? colorScheme.outline : null,
),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
!hasAnyProvider
!hasAnyProvider && !homeFeedDisabled
? context.l10n.extensionsNoHomeFeedExtensions
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -914,12 +912,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
],
),
),
Icon(
Icons.chevron_right,
color: !hasAnyProvider
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
),
),
@@ -982,6 +975,31 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Navigator.pop(ctx);
},
),
ListTile(
leading: Icon(Icons.block, color: colorScheme.error),
title: Text(ctx.l10n.extensionsHomeFeedOff),
subtitle: Text(ctx.l10n.extensionsHomeFeedOffSubtitle),
trailing:
settings.homeFeedProvider == AppSettings.homeFeedProviderOff
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setHomeFeedProvider(AppSettings.homeFeedProviderOff);
ref.read(exploreProvider.notifier).clear();
Navigator.pop(ctx);
},
),
if (homeFeedProviders.isEmpty)
ListTile(
enabled: false,
leading: Icon(
Icons.extension_off,
color: colorScheme.outline,
),
title: Text(ctx.l10n.extensionsNoHomeFeedExtensions),
),
...homeFeedProviders.map(
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
+1 -1
View File
@@ -2674,7 +2674,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'track_name': trackName,
'artist_name': artistName,
'album_name': albumName,
'album_artist': albumArtist ?? artistName,
'album_artist': albumArtist ?? '',
'track_number': trackNumber ?? 0,
'total_tracks': totalTracks ?? 0,
'disc_number': discNumber ?? 0,
+5 -1
View File
@@ -609,9 +609,13 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
static Future<Map<String, dynamic>> searchDeezerByISRC(
String isrc, {
String? itemId,
}) async {
final result = await _channel.invokeMethod('searchDeezerByISRC', {
'isrc': isrc,
'item_id': itemId ?? '',
});
return jsonDecode(result as String) as Map<String, dynamic>;
}