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:
zarzet committed 2026-10-18 23:28:33 +07:00
1 parent a6dda02396
commit c2b7a0b782
2 files changed
+211 -205

No files matched your search

+209
View File
@@ -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(', ');
}
+2 -205
View File
@@ -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'^[_ ]+|[_ ]+$');