mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
refactor(downloads): move pure queue policy helpers into download_queue_policy.dart
The queue provider library mixed pure, notifier-free helpers (processing gate, storage-write recovery, native worker status/progress mapping, batch album-artist normalization) with the notifier. They now live in a standalone file that the library imports and re-exports, so existing importers are unchanged.
This commit is contained in:
1 parent
a6dda02396
commit
c2b7a0b782
2 files changed
+211
-205
No files matched your search
@@ -0,0 +1,209 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
/// Prevents asynchronous queue startup checks from overlapping before
|
||||
/// [DownloadQueueState.isProcessing] can be published.
|
||||
class QueueProcessingGate {
|
||||
bool _active = false;
|
||||
bool _pending = false;
|
||||
|
||||
bool tryEnter() {
|
||||
if (_active) {
|
||||
_pending = true;
|
||||
return false;
|
||||
}
|
||||
_active = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool leave() {
|
||||
_active = false;
|
||||
final shouldRunAgain = _pending;
|
||||
_pending = false;
|
||||
return shouldRunAgain;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set on queued items when the persisted Android SAF grant fails validation.
|
||||
/// The queue UI matches on this to offer re-selecting the download folder.
|
||||
const String safPermissionLostErrorMessage =
|
||||
'SAF permission invalid or revoked. Please reconfigure download location in Settings.';
|
||||
|
||||
/// Set on queued items when the iOS download folder bookmark can no longer be
|
||||
/// opened. The queue UI matches on this to offer re-selecting the folder.
|
||||
const String downloadFolderAccessLostErrorMessage =
|
||||
'Download folder access lost. Please re-select your download folder in Settings.';
|
||||
|
||||
enum StorageWriteRecovery { requestSafAccess, useAppFolderFallback }
|
||||
|
||||
StorageWriteRecovery storageWriteRecoveryFor({required bool useSaf}) {
|
||||
return useSaf
|
||||
? StorageWriteRecovery.requestSafAccess
|
||||
: StorageWriteRecovery.useAppFolderFallback;
|
||||
}
|
||||
|
||||
bool canStartForegroundDownloadForLifecycle(AppLifecycleState? lifecycleState) {
|
||||
return lifecycleState == AppLifecycleState.resumed;
|
||||
}
|
||||
|
||||
String nativeWorkerStatusAfterSnapshotStop({
|
||||
required bool workerRunning,
|
||||
required String status,
|
||||
}) {
|
||||
if (workerRunning) return status;
|
||||
return switch (status) {
|
||||
'preparing' || 'downloading' || 'finalizing' => 'queued',
|
||||
_ => status,
|
||||
};
|
||||
}
|
||||
|
||||
/// Keeps a download in its finalizing state until its durable Library record
|
||||
/// has been written. If persistence fails, completion is deliberately not
|
||||
/// published so the queue can surface the error instead of losing the file
|
||||
/// from the app while it still exists on disk.
|
||||
Future<void> persistBeforePublishingDownloadCompletion({
|
||||
required Future<void> Function() persist,
|
||||
required void Function() publish,
|
||||
}) async {
|
||||
await persist();
|
||||
publish();
|
||||
}
|
||||
|
||||
/// Keeps native-worker finalization visually distinct from completion.
|
||||
/// Download bytes may reach 100% before SAF publishing, metadata persistence,
|
||||
/// and queue reconciliation have finished.
|
||||
double nativeWorkerFinalizingProgress(double progress) {
|
||||
final normalized = progress.clamp(0.0, 1.0).toDouble();
|
||||
if (normalized <= 0) return 0.95;
|
||||
return min(normalized, 0.99);
|
||||
}
|
||||
|
||||
/// Whether a backend failure means the selected destination could not be
|
||||
/// written. This intentionally stays provider-agnostic: once an output path is
|
||||
/// unwritable, trying more audio providers against that same path only wastes
|
||||
/// time and hides the actionable storage failure behind a later network error.
|
||||
bool isStorageWriteFailure({String? errorType, String? errorMessage}) {
|
||||
if (errorType?.trim().toLowerCase() == 'permission') return true;
|
||||
|
||||
final message = errorMessage?.trim().toLowerCase() ?? '';
|
||||
if (message.isEmpty) return false;
|
||||
return message.contains('operation not permitted') ||
|
||||
message.contains('permission denied') ||
|
||||
message.contains('read-only file system') ||
|
||||
message.contains('failed to create file') ||
|
||||
message.contains('failed to create directory') ||
|
||||
message.contains('failed to access saf directory') ||
|
||||
message.contains('failed to create saf file') ||
|
||||
message.contains('failed to open saf file') ||
|
||||
message.contains('failed to publish saf') ||
|
||||
message.contains('failed to publish deferred saf') ||
|
||||
message.contains('failed to copy extension output to saf');
|
||||
}
|
||||
|
||||
/// Makes the album-artist credit stable for tracks from the same album.
|
||||
///
|
||||
/// Some metadata providers expose every `MAIN` track artist as the album
|
||||
/// artist. On collaboration tracks that turns one album into values such as
|
||||
/// "Artist", "Artist, Guest A", and "Artist, Guest B". Music libraries then
|
||||
/// split the files into separate albums. Keep the complete track artist credit
|
||||
/// intact, but reduce inconsistent album-artist credits to the artist names
|
||||
/// shared by every track in that album.
|
||||
List<Track> normalizeBatchAlbumArtists(List<Track> tracks) {
|
||||
if (tracks.length < 2) return tracks;
|
||||
|
||||
final albumGroups = <String, List<int>>{};
|
||||
for (var index = 0; index < tracks.length; index++) {
|
||||
final key = _batchAlbumIdentity(tracks[index]);
|
||||
if (key == null) continue;
|
||||
albumGroups.putIfAbsent(key, () => <int>[]).add(index);
|
||||
}
|
||||
|
||||
List<Track>? normalized;
|
||||
for (final indices in albumGroups.values) {
|
||||
if (indices.length < 2) continue;
|
||||
final albumTracks = [for (final index in indices) tracks[index]];
|
||||
final canonicalArtist = _sharedBatchAlbumArtist(albumTracks);
|
||||
if (canonicalArtist == null) continue;
|
||||
|
||||
for (final index in indices) {
|
||||
final currentArtist = normalizeOptionalString(tracks[index].albumArtist);
|
||||
if (currentArtist == canonicalArtist) continue;
|
||||
normalized ??= List<Track>.of(tracks);
|
||||
normalized[index] = tracks[index].copyWith(albumArtist: canonicalArtist);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized ?? tracks;
|
||||
}
|
||||
|
||||
String? _batchAlbumIdentity(Track track) {
|
||||
final source = normalizeOptionalString(track.source)?.toLowerCase() ?? '';
|
||||
final albumId = normalizeOptionalString(track.albumId)?.toLowerCase();
|
||||
if (albumId != null) return '$source|id:$albumId';
|
||||
|
||||
final albumName = normalizeOptionalString(track.albumName)?.toLowerCase();
|
||||
if (albumName == null) return null;
|
||||
|
||||
final cover = normalizeOptionalString(
|
||||
normalizeCoverReference(track.coverUrl),
|
||||
)?.toLowerCase();
|
||||
if (cover != null) return '$source|cover:$albumName|$cover';
|
||||
|
||||
final releaseDate = normalizeOptionalString(track.releaseDate) ?? '';
|
||||
final totalTracks = track.totalTracks ?? 0;
|
||||
final primaryArtist = primaryArtistName(
|
||||
track.artistName,
|
||||
albumArtist: track.albumArtist,
|
||||
).trim().toLowerCase();
|
||||
return '$source|meta:$albumName|$releaseDate|$totalTracks|$primaryArtist';
|
||||
}
|
||||
|
||||
String? _sharedBatchAlbumArtist(List<Track> tracks) {
|
||||
final albumArtists = tracks
|
||||
.map((track) => normalizeOptionalString(track.albumArtist))
|
||||
.toList(growable: false);
|
||||
final firstAlbumArtist = albumArtists.first;
|
||||
if (firstAlbumArtist != null &&
|
||||
albumArtists.every(
|
||||
(artist) => artist?.toLowerCase() == firstAlbumArtist.toLowerCase(),
|
||||
)) {
|
||||
return firstAlbumArtist;
|
||||
}
|
||||
|
||||
final credits = <List<String>>[];
|
||||
for (var index = 0; index < tracks.length; index++) {
|
||||
final rawCredit = albumArtists[index] ?? tracks[index].artistName;
|
||||
final names = splitArtistNames(rawCredit);
|
||||
if (names.isEmpty) return null;
|
||||
credits.add(names);
|
||||
}
|
||||
|
||||
final sharedKeys = credits.first.map((name) => name.toLowerCase()).toSet();
|
||||
for (final credit in credits.skip(1)) {
|
||||
final keys = credit.map((name) => name.toLowerCase()).toSet();
|
||||
sharedKeys.removeWhere((name) => !keys.contains(name));
|
||||
}
|
||||
if (sharedKeys.isEmpty) return null;
|
||||
|
||||
// Preserve a provider's original separator whenever one of its credits is
|
||||
// already exactly the shared album credit.
|
||||
for (final rawCredit in albumArtists.whereType<String>()) {
|
||||
final keys = splitArtistNames(
|
||||
rawCredit,
|
||||
).map((name) => name.toLowerCase()).toSet();
|
||||
if (keys.length == sharedKeys.length && keys.containsAll(sharedKeys)) {
|
||||
return rawCredit;
|
||||
}
|
||||
}
|
||||
|
||||
final displayNames = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final name in credits.first) {
|
||||
final key = name.toLowerCase();
|
||||
if (sharedKeys.contains(key) && seen.add(key)) displayNames.add(name);
|
||||
}
|
||||
return displayNames.isEmpty ? null : displayNames.join(', ');
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_verification_retry_guard.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_policy.dart';
|
||||
import 'package:spotiflac_android/services/download_queue_persistence.dart';
|
||||
import 'package:spotiflac_android/services/download_progress.dart';
|
||||
import 'package:spotiflac_android/services/download_file_finalizer.dart';
|
||||
@@ -40,7 +41,6 @@ import 'package:spotiflac_android/services/verification_notification.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
@@ -54,6 +54,7 @@ import 'package:spotiflac_android/services/native_download_history.dart';
|
||||
import 'package:spotiflac_android/services/native_download_worker_monitor.dart';
|
||||
|
||||
export 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
export 'package:spotiflac_android/providers/download_queue_policy.dart';
|
||||
export 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
export 'package:spotiflac_android/services/download_metadata_resolver.dart'
|
||||
show copyTrackWithResolvedMetadata;
|
||||
@@ -76,210 +77,6 @@ part 'download_queue_provider_hires_check.dart';
|
||||
|
||||
final _log = AppLogger('DownloadQueue');
|
||||
|
||||
/// Prevents asynchronous queue startup checks from overlapping before
|
||||
/// [DownloadQueueState.isProcessing] can be published.
|
||||
class QueueProcessingGate {
|
||||
bool _active = false;
|
||||
bool _pending = false;
|
||||
|
||||
bool tryEnter() {
|
||||
if (_active) {
|
||||
_pending = true;
|
||||
return false;
|
||||
}
|
||||
_active = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool leave() {
|
||||
_active = false;
|
||||
final shouldRunAgain = _pending;
|
||||
_pending = false;
|
||||
return shouldRunAgain;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set on queued items when the persisted Android SAF grant fails validation.
|
||||
/// The queue UI matches on this to offer re-selecting the download folder.
|
||||
const String safPermissionLostErrorMessage =
|
||||
'SAF permission invalid or revoked. Please reconfigure download location in Settings.';
|
||||
|
||||
/// Set on queued items when the iOS download folder bookmark can no longer be
|
||||
/// opened. The queue UI matches on this to offer re-selecting the folder.
|
||||
const String downloadFolderAccessLostErrorMessage =
|
||||
'Download folder access lost. Please re-select your download folder in Settings.';
|
||||
|
||||
enum StorageWriteRecovery { requestSafAccess, useAppFolderFallback }
|
||||
|
||||
StorageWriteRecovery storageWriteRecoveryFor({required bool useSaf}) {
|
||||
return useSaf
|
||||
? StorageWriteRecovery.requestSafAccess
|
||||
: StorageWriteRecovery.useAppFolderFallback;
|
||||
}
|
||||
|
||||
bool canStartForegroundDownloadForLifecycle(AppLifecycleState? lifecycleState) {
|
||||
return lifecycleState == AppLifecycleState.resumed;
|
||||
}
|
||||
|
||||
String nativeWorkerStatusAfterSnapshotStop({
|
||||
required bool workerRunning,
|
||||
required String status,
|
||||
}) {
|
||||
if (workerRunning) return status;
|
||||
return switch (status) {
|
||||
'preparing' || 'downloading' || 'finalizing' => 'queued',
|
||||
_ => status,
|
||||
};
|
||||
}
|
||||
|
||||
/// Keeps a download in its finalizing state until its durable Library record
|
||||
/// has been written. If persistence fails, completion is deliberately not
|
||||
/// published so the queue can surface the error instead of losing the file
|
||||
/// from the app while it still exists on disk.
|
||||
Future<void> persistBeforePublishingDownloadCompletion({
|
||||
required Future<void> Function() persist,
|
||||
required void Function() publish,
|
||||
}) async {
|
||||
await persist();
|
||||
publish();
|
||||
}
|
||||
|
||||
/// Keeps native-worker finalization visually distinct from completion.
|
||||
/// Download bytes may reach 100% before SAF publishing, metadata persistence,
|
||||
/// and queue reconciliation have finished.
|
||||
double nativeWorkerFinalizingProgress(double progress) {
|
||||
final normalized = progress.clamp(0.0, 1.0).toDouble();
|
||||
if (normalized <= 0) return 0.95;
|
||||
return min(normalized, 0.99);
|
||||
}
|
||||
|
||||
/// Whether a backend failure means the selected destination could not be
|
||||
/// written. This intentionally stays provider-agnostic: once an output path is
|
||||
/// unwritable, trying more audio providers against that same path only wastes
|
||||
/// time and hides the actionable storage failure behind a later network error.
|
||||
bool isStorageWriteFailure({String? errorType, String? errorMessage}) {
|
||||
if (errorType?.trim().toLowerCase() == 'permission') return true;
|
||||
|
||||
final message = errorMessage?.trim().toLowerCase() ?? '';
|
||||
if (message.isEmpty) return false;
|
||||
return message.contains('operation not permitted') ||
|
||||
message.contains('permission denied') ||
|
||||
message.contains('read-only file system') ||
|
||||
message.contains('failed to create file') ||
|
||||
message.contains('failed to create directory') ||
|
||||
message.contains('failed to access saf directory') ||
|
||||
message.contains('failed to create saf file') ||
|
||||
message.contains('failed to open saf file') ||
|
||||
message.contains('failed to publish saf') ||
|
||||
message.contains('failed to publish deferred saf') ||
|
||||
message.contains('failed to copy extension output to saf');
|
||||
}
|
||||
|
||||
/// Makes the album-artist credit stable for tracks from the same album.
|
||||
///
|
||||
/// Some metadata providers expose every `MAIN` track artist as the album
|
||||
/// artist. On collaboration tracks that turns one album into values such as
|
||||
/// "Artist", "Artist, Guest A", and "Artist, Guest B". Music libraries then
|
||||
/// split the files into separate albums. Keep the complete track artist credit
|
||||
/// intact, but reduce inconsistent album-artist credits to the artist names
|
||||
/// shared by every track in that album.
|
||||
List<Track> normalizeBatchAlbumArtists(List<Track> tracks) {
|
||||
if (tracks.length < 2) return tracks;
|
||||
|
||||
final albumGroups = <String, List<int>>{};
|
||||
for (var index = 0; index < tracks.length; index++) {
|
||||
final key = _batchAlbumIdentity(tracks[index]);
|
||||
if (key == null) continue;
|
||||
albumGroups.putIfAbsent(key, () => <int>[]).add(index);
|
||||
}
|
||||
|
||||
List<Track>? normalized;
|
||||
for (final indices in albumGroups.values) {
|
||||
if (indices.length < 2) continue;
|
||||
final albumTracks = [for (final index in indices) tracks[index]];
|
||||
final canonicalArtist = _sharedBatchAlbumArtist(albumTracks);
|
||||
if (canonicalArtist == null) continue;
|
||||
|
||||
for (final index in indices) {
|
||||
final currentArtist = normalizeOptionalString(tracks[index].albumArtist);
|
||||
if (currentArtist == canonicalArtist) continue;
|
||||
normalized ??= List<Track>.of(tracks);
|
||||
normalized[index] = tracks[index].copyWith(albumArtist: canonicalArtist);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized ?? tracks;
|
||||
}
|
||||
|
||||
String? _batchAlbumIdentity(Track track) {
|
||||
final source = normalizeOptionalString(track.source)?.toLowerCase() ?? '';
|
||||
final albumId = normalizeOptionalString(track.albumId)?.toLowerCase();
|
||||
if (albumId != null) return '$source|id:$albumId';
|
||||
|
||||
final albumName = normalizeOptionalString(track.albumName)?.toLowerCase();
|
||||
if (albumName == null) return null;
|
||||
|
||||
final cover = normalizeOptionalString(
|
||||
normalizeCoverReference(track.coverUrl),
|
||||
)?.toLowerCase();
|
||||
if (cover != null) return '$source|cover:$albumName|$cover';
|
||||
|
||||
final releaseDate = normalizeOptionalString(track.releaseDate) ?? '';
|
||||
final totalTracks = track.totalTracks ?? 0;
|
||||
final primaryArtist = primaryArtistName(
|
||||
track.artistName,
|
||||
albumArtist: track.albumArtist,
|
||||
).trim().toLowerCase();
|
||||
return '$source|meta:$albumName|$releaseDate|$totalTracks|$primaryArtist';
|
||||
}
|
||||
|
||||
String? _sharedBatchAlbumArtist(List<Track> tracks) {
|
||||
final albumArtists = tracks
|
||||
.map((track) => normalizeOptionalString(track.albumArtist))
|
||||
.toList(growable: false);
|
||||
final firstAlbumArtist = albumArtists.first;
|
||||
if (firstAlbumArtist != null &&
|
||||
albumArtists.every(
|
||||
(artist) => artist?.toLowerCase() == firstAlbumArtist.toLowerCase(),
|
||||
)) {
|
||||
return firstAlbumArtist;
|
||||
}
|
||||
|
||||
final credits = <List<String>>[];
|
||||
for (var index = 0; index < tracks.length; index++) {
|
||||
final rawCredit = albumArtists[index] ?? tracks[index].artistName;
|
||||
final names = splitArtistNames(rawCredit);
|
||||
if (names.isEmpty) return null;
|
||||
credits.add(names);
|
||||
}
|
||||
|
||||
final sharedKeys = credits.first.map((name) => name.toLowerCase()).toSet();
|
||||
for (final credit in credits.skip(1)) {
|
||||
final keys = credit.map((name) => name.toLowerCase()).toSet();
|
||||
sharedKeys.removeWhere((name) => !keys.contains(name));
|
||||
}
|
||||
if (sharedKeys.isEmpty) return null;
|
||||
|
||||
// Preserve a provider's original separator whenever one of its credits is
|
||||
// already exactly the shared album credit.
|
||||
for (final rawCredit in albumArtists.whereType<String>()) {
|
||||
final keys = splitArtistNames(
|
||||
rawCredit,
|
||||
).map((name) => name.toLowerCase()).toSet();
|
||||
if (keys.length == sharedKeys.length && keys.containsAll(sharedKeys)) {
|
||||
return rawCredit;
|
||||
}
|
||||
}
|
||||
|
||||
final displayNames = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final name in credits.first) {
|
||||
final key = name.toLowerCase();
|
||||
if (sharedKeys.contains(key) && seen.add(key)) displayNames.add(name);
|
||||
}
|
||||
return displayNames.isEmpty ? null : displayNames.join(', ');
|
||||
}
|
||||
|
||||
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
|
||||
final _trimDotsAndSpacesRegex = RegExp(r'^[. ]+|[. ]+$');
|
||||
final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
|
||||
|
||||
Reference in new issue
Block a user