mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
fix(download): persist and reconcile history reliably
This commit is contained in:
@@ -33,7 +33,7 @@ object NativeDownloadFinalizer {
|
||||
const val NATIVE_WORKER_CONTRACT_VERSION = 1
|
||||
// Native finalizer owns background-safe history writes while Flutter may be suspended.
|
||||
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
|
||||
private const val HISTORY_SCHEMA_VERSION = 9
|
||||
private const val HISTORY_SCHEMA_VERSION = 10
|
||||
private val activeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
private val nativeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
private val activeFFmpegSessionLock = Any()
|
||||
@@ -85,6 +85,8 @@ object NativeDownloadFinalizer {
|
||||
"spotify_id_norm",
|
||||
"isrc_norm",
|
||||
"match_key",
|
||||
"album_key",
|
||||
"search_text",
|
||||
)
|
||||
private val androidStoragePathAliases = listOf(
|
||||
"/storage/emulated/0",
|
||||
@@ -2139,7 +2141,12 @@ object NativeDownloadFinalizer {
|
||||
genre TEXT,
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT
|
||||
copyright TEXT,
|
||||
spotify_id_norm TEXT,
|
||||
isrc_norm TEXT,
|
||||
match_key TEXT,
|
||||
album_key TEXT,
|
||||
search_text TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -2156,6 +2163,8 @@ object NativeDownloadFinalizer {
|
||||
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
|
||||
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
|
||||
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
|
||||
ensureHistoryColumn(db, "album_key", "ALTER TABLE history ADD COLUMN album_key TEXT")
|
||||
ensureHistoryColumn(db, "search_text", "ALTER TABLE history ADD COLUMN search_text TEXT")
|
||||
ensureHistoryPathKeyTable(db)
|
||||
if (needsBackfill) {
|
||||
backfillNormalizedHistoryColumns(db)
|
||||
@@ -2170,6 +2179,7 @@ object NativeDownloadFinalizer {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_album_key ON history(album_key)")
|
||||
if (db.version < HISTORY_SCHEMA_VERSION) db.version = HISTORY_SCHEMA_VERSION
|
||||
if (deduplicateTrack) deleteDuplicateHistoryRows(db, values)
|
||||
db.insertWithOnConflict("history", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
@@ -2296,8 +2306,8 @@ object NativeDownloadFinalizer {
|
||||
private fun backfillNormalizedHistoryColumns(db: SQLiteDatabase) {
|
||||
db.query(
|
||||
"history",
|
||||
arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name"),
|
||||
"spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL",
|
||||
arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name", "album_name", "album_artist"),
|
||||
"spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL OR album_key IS NULL OR search_text IS NULL",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@@ -2308,6 +2318,8 @@ object NativeDownloadFinalizer {
|
||||
val isrcIndex = cursor.getColumnIndex("isrc")
|
||||
val trackIndex = cursor.getColumnIndex("track_name")
|
||||
val artistIndex = cursor.getColumnIndex("artist_name")
|
||||
val albumIndex = cursor.getColumnIndex("album_name")
|
||||
val albumArtistIndex = cursor.getColumnIndex("album_artist")
|
||||
while (cursor.moveToNext()) {
|
||||
if (idIndex < 0) continue
|
||||
val values = ContentValues()
|
||||
@@ -2315,9 +2327,18 @@ object NativeDownloadFinalizer {
|
||||
val isrc = cursor.getNullableString(isrcIndex)
|
||||
val trackName = cursor.getNullableString(trackIndex)
|
||||
val artistName = cursor.getNullableString(artistIndex)
|
||||
val albumName = cursor.getNullableString(albumIndex)
|
||||
val albumArtist = cursor.getNullableString(albumArtistIndex)
|
||||
values.put("spotify_id_norm", normalizeSpotifyId(spotifyId))
|
||||
values.put("isrc_norm", normalizeIsrc(isrc))
|
||||
values.put("match_key", matchKeyFor(trackName, artistName))
|
||||
putAlbumSearchHistoryColumns(
|
||||
values,
|
||||
trackName = trackName,
|
||||
artistName = artistName,
|
||||
albumName = albumName,
|
||||
albumArtist = albumArtist,
|
||||
)
|
||||
db.update("history", values, "id = ?", arrayOf(cursor.getString(idIndex)))
|
||||
}
|
||||
}
|
||||
@@ -2354,6 +2375,35 @@ object NativeDownloadFinalizer {
|
||||
"match_key",
|
||||
matchKeyFor(values.getAsString("track_name"), values.getAsString("artist_name")),
|
||||
)
|
||||
putAlbumSearchHistoryColumns(
|
||||
values,
|
||||
trackName = values.getAsString("track_name"),
|
||||
artistName = values.getAsString("artist_name"),
|
||||
albumName = values.getAsString("album_name"),
|
||||
albumArtist = values.getAsString("album_artist"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun putAlbumSearchHistoryColumns(
|
||||
values: ContentValues,
|
||||
trackName: String?,
|
||||
artistName: String?,
|
||||
albumName: String?,
|
||||
albumArtist: String?,
|
||||
) {
|
||||
val track = normalizeLookupText(trackName)
|
||||
val artist = normalizeLookupText(artistName)
|
||||
val album = normalizeLookupText(albumName)
|
||||
val resolvedAlbumArtist = normalizeLookupText(
|
||||
albumArtist?.takeIf { it.trim().isNotEmpty() } ?: artistName,
|
||||
)
|
||||
values.put("album_key", "$album|$resolvedAlbumArtist")
|
||||
values.put(
|
||||
"search_text",
|
||||
listOf(track, artist, album, resolvedAlbumArtist)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" "),
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeLookupText(value: String?): String =
|
||||
|
||||
@@ -316,6 +316,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
bool _isSafRepairInProgress = false;
|
||||
bool _isAudioMetadataBackfillInProgress = false;
|
||||
bool _startupMaintenanceScheduled = false;
|
||||
Future<void> _historyWriteChain = Future<void>.value();
|
||||
|
||||
@override
|
||||
DownloadHistoryState build() {
|
||||
@@ -443,6 +444,28 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return '';
|
||||
}
|
||||
|
||||
List<String> _conversionRenameCandidates(
|
||||
String fileName, {
|
||||
bool includeAlternateExtensions = false,
|
||||
}) {
|
||||
if (fileName.trim().isEmpty) return const [];
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
if (dotIndex < 0) return [fileName];
|
||||
final baseName = fileName.substring(0, dotIndex);
|
||||
final extension = fileName.substring(dotIndex);
|
||||
final plainBase = baseName.endsWith('_converted')
|
||||
? baseName.substring(0, baseName.length - '_converted'.length)
|
||||
: baseName;
|
||||
return <String>{
|
||||
fileName,
|
||||
if (plainBase != baseName) '$plainBase$extension',
|
||||
if (plainBase == baseName) '${baseName}_converted$extension',
|
||||
if (includeAlternateExtensions)
|
||||
for (final audioExtension in _audioExtensions)
|
||||
'$plainBase$audioExtension',
|
||||
}.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _repairMissingSafEntries(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required int maxItems,
|
||||
@@ -457,7 +480,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
final item = items[i];
|
||||
if (item.storageMode != 'saf') continue;
|
||||
if (item.safRepaired) continue;
|
||||
if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
@@ -531,13 +553,23 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final newUri = (resolved['uri'] as String? ?? '').trim();
|
||||
if (newUri.isEmpty) continue;
|
||||
Map<String, dynamic>? resolved;
|
||||
String? resolvedFileName;
|
||||
for (final candidate in _conversionRenameCandidates(fallbackName)) {
|
||||
final candidateResult = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: candidate,
|
||||
);
|
||||
final candidateUri = (candidateResult['uri'] as String? ?? '')
|
||||
.trim();
|
||||
if (candidateUri.isEmpty) continue;
|
||||
resolved = candidateResult;
|
||||
resolvedFileName = candidate;
|
||||
break;
|
||||
}
|
||||
if (resolved == null || resolvedFileName == null) continue;
|
||||
final newUri = (resolved['uri'] as String).trim();
|
||||
|
||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
||||
final updated = item.copyWith(
|
||||
@@ -546,7 +578,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
(newRelativeDir != null && newRelativeDir.isNotEmpty)
|
||||
? newRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: fallbackName,
|
||||
safFileName: resolvedFileName,
|
||||
safRepaired: true,
|
||||
);
|
||||
|
||||
@@ -935,7 +967,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
state = state.copyWith(loadedIndexVersion: state.loadedIndexVersion + 1);
|
||||
}
|
||||
|
||||
Future<DownloadHistoryItem> _putInMemoryHistory(
|
||||
Future<({DownloadHistoryItem item, String? existingId})> _resolveHistoryItem(
|
||||
DownloadHistoryItem item,
|
||||
) async {
|
||||
DownloadHistoryItem? existing;
|
||||
@@ -987,29 +1019,34 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
normalizeOptionalString(item.copyright) ??
|
||||
normalizeOptionalString(existing.copyright),
|
||||
);
|
||||
return (item: mergedItem, existingId: existing?.id);
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
void _putResolvedHistoryInMemory(
|
||||
DownloadHistoryItem item,
|
||||
String? existingId,
|
||||
) {
|
||||
if (existingId != null) {
|
||||
final updatedItems = state.items
|
||||
.where((i) => i.id != existing!.id)
|
||||
.where((candidate) => candidate.id != existingId)
|
||||
.toList();
|
||||
updatedItems.insert(0, mergedItem);
|
||||
updatedItems.insert(0, item);
|
||||
final updatedLookupItems = state.lookupItems
|
||||
.where((i) => i.id != existing!.id)
|
||||
.where((candidate) => candidate.id != existingId)
|
||||
.toList(growable: false);
|
||||
state = state.copyWith(
|
||||
items: updatedItems,
|
||||
lookupItems: [mergedItem, ...updatedLookupItems],
|
||||
lookupItems: [item, ...updatedLookupItems],
|
||||
);
|
||||
_historyLog.d('Updated existing history entry: ${mergedItem.trackName}');
|
||||
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
items: [mergedItem, ...state.items],
|
||||
items: [item, ...state.items],
|
||||
totalCount: state.totalCount + 1,
|
||||
lookupItems: [mergedItem, ...state.lookupItems],
|
||||
lookupItems: [item, ...state.lookupItems],
|
||||
);
|
||||
_historyLog.d('Added new history entry: ${mergedItem.trackName}');
|
||||
_historyLog.d('Added new history entry: ${item.trackName}');
|
||||
}
|
||||
return mergedItem;
|
||||
}
|
||||
|
||||
List<DownloadHistoryItem> _lookupItemsWithUpdates(
|
||||
@@ -1028,40 +1065,65 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return byId.values.toList(growable: false);
|
||||
}
|
||||
|
||||
void addToHistory(
|
||||
Future<void> addToHistory(
|
||||
DownloadHistoryItem item, {
|
||||
bool preserveTrackVariant = false,
|
||||
}) => _persistHistoryItem(
|
||||
item,
|
||||
'save to database',
|
||||
preserveTrackVariant: preserveTrackVariant,
|
||||
}) => _enqueueHistoryWrite(
|
||||
() => _persistHistoryItem(
|
||||
item,
|
||||
'save to database',
|
||||
preserveTrackVariant: preserveTrackVariant,
|
||||
),
|
||||
);
|
||||
|
||||
void adoptNativeHistoryItem(
|
||||
Future<void> adoptNativeHistoryItem(
|
||||
DownloadHistoryItem item, {
|
||||
bool preserveTrackVariant = false,
|
||||
}) => _persistHistoryItem(
|
||||
item,
|
||||
'adopt native history item',
|
||||
preserveTrackVariant: preserveTrackVariant,
|
||||
}) => _enqueueHistoryWrite(
|
||||
() => _persistHistoryItem(
|
||||
item,
|
||||
'adopt native history item',
|
||||
preserveTrackVariant: preserveTrackVariant,
|
||||
),
|
||||
);
|
||||
|
||||
void _persistHistoryItem(
|
||||
Future<void> _enqueueHistoryWrite(Future<void> Function() operation) {
|
||||
final pending = _historyWriteChain.then((_) => operation());
|
||||
_historyWriteChain = pending.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace _) {},
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
Future<void> _persistHistoryItem(
|
||||
DownloadHistoryItem item,
|
||||
String action, {
|
||||
required bool preserveTrackVariant,
|
||||
}) {
|
||||
unawaited(
|
||||
() async {
|
||||
final persistedItem = preserveTrackVariant
|
||||
? _putInMemoryTrackVariant(item)
|
||||
: await _putInMemoryHistory(item);
|
||||
await _db.upsert(persistedItem.toJson());
|
||||
_bumpHistoryRevision();
|
||||
}().catchError((Object e, StackTrace stack) {
|
||||
_historyLog.e('Failed to $action: $e', e, stack);
|
||||
}),
|
||||
);
|
||||
}) async {
|
||||
try {
|
||||
if (preserveTrackVariant) {
|
||||
await _db.upsert(item.toJson());
|
||||
_putInMemoryTrackVariant(item);
|
||||
} else {
|
||||
final resolved = await _resolveHistoryItem(item);
|
||||
await _db.upsert(resolved.item.toJson());
|
||||
_putResolvedHistoryInMemory(resolved.item, resolved.existingId);
|
||||
}
|
||||
int? persistedCount;
|
||||
try {
|
||||
persistedCount = await _db.getCount();
|
||||
} catch (error) {
|
||||
_historyLog.w('History saved but count refresh failed: $error');
|
||||
}
|
||||
state = state.copyWith(
|
||||
totalCount: persistedCount ?? state.totalCount,
|
||||
loadedIndexVersion: state.loadedIndexVersion + 1,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_historyLog.e('Failed to $action: $e', e, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
DownloadHistoryItem _putInMemoryTrackVariant(DownloadHistoryItem item) {
|
||||
@@ -1298,18 +1360,31 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
'.opus',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.aiff',
|
||||
'.aac',
|
||||
'.mp4',
|
||||
];
|
||||
|
||||
Future<String?> _findConvertedSibling(String originalPath) async {
|
||||
Future<String?> _findConvertedSibling(
|
||||
String originalPath, {
|
||||
bool includeAlternateExtensions = true,
|
||||
}) async {
|
||||
final dotIndex = originalPath.lastIndexOf('.');
|
||||
if (dotIndex < 0) return null;
|
||||
final basePath = originalPath.substring(0, dotIndex);
|
||||
final originalExt = originalPath.substring(dotIndex).toLowerCase();
|
||||
final directoryPrefix = originalPath.substring(
|
||||
0,
|
||||
originalPath.lastIndexOf(Platform.pathSeparator) + 1,
|
||||
);
|
||||
final fileName = originalPath.substring(
|
||||
originalPath.lastIndexOf(Platform.pathSeparator) + 1,
|
||||
);
|
||||
|
||||
for (final ext in _audioExtensions) {
|
||||
if (ext == originalExt) continue;
|
||||
final candidatePath = '$basePath$ext';
|
||||
for (final candidateName in _conversionRenameCandidates(
|
||||
fileName,
|
||||
includeAlternateExtensions: includeAlternateExtensions,
|
||||
)) {
|
||||
final candidatePath = '$directoryPrefix$candidateName';
|
||||
if (candidatePath == originalPath) continue;
|
||||
try {
|
||||
if (await fileExists(candidatePath)) return candidatePath;
|
||||
} catch (_) {}
|
||||
@@ -1317,6 +1392,67 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> verifyOrRepairHistoryItem(DownloadHistoryItem item) async {
|
||||
if (await fileExists(item.filePath)) return true;
|
||||
|
||||
DownloadHistoryItem? repaired;
|
||||
if (item.storageMode == 'saf' &&
|
||||
item.downloadTreeUri != null &&
|
||||
item.downloadTreeUri!.isNotEmpty) {
|
||||
var fileName = (item.safFileName ?? '').trim();
|
||||
if (fileName.isEmpty && isContentUri(item.filePath)) {
|
||||
fileName = _fileNameFromUri(item.filePath);
|
||||
}
|
||||
for (final candidate in _conversionRenameCandidates(fileName)) {
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: candidate,
|
||||
);
|
||||
final uri = (resolved['uri'] as String? ?? '').trim();
|
||||
if (uri.isEmpty || !await fileExists(uri)) continue;
|
||||
final relativeDir = (resolved['relative_dir'] as String? ?? '')
|
||||
.trim();
|
||||
repaired = item.copyWith(
|
||||
filePath: uri,
|
||||
safFileName: candidate,
|
||||
safRelativeDir: relativeDir.isEmpty
|
||||
? item.safRelativeDir
|
||||
: relativeDir,
|
||||
safRepaired: true,
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
_historyLog.w('Failed to resolve renamed SAF file: $error');
|
||||
}
|
||||
}
|
||||
} else if (!isContentUri(item.filePath)) {
|
||||
final sibling = await _findConvertedSibling(
|
||||
item.filePath,
|
||||
includeAlternateExtensions: false,
|
||||
);
|
||||
if (sibling != null) repaired = item.copyWith(filePath: sibling);
|
||||
}
|
||||
|
||||
if (repaired == null) return false;
|
||||
await _db.upsert(repaired.toJson());
|
||||
final updatedItems = state.items
|
||||
.map((entry) => entry.id == repaired!.id ? repaired : entry)
|
||||
.toList(growable: false);
|
||||
final updatedLookupItems = state.lookupItems
|
||||
.map((entry) => entry.id == repaired!.id ? repaired : entry)
|
||||
.toList(growable: false);
|
||||
state = state.copyWith(
|
||||
items: updatedItems,
|
||||
lookupItems: updatedLookupItems,
|
||||
);
|
||||
_historyLog.i(
|
||||
'Reconciled renamed conversion: ${item.filePath} -> ${repaired.filePath}',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<
|
||||
({
|
||||
List<String> orphanedIds,
|
||||
@@ -1615,7 +1751,12 @@ final downloadHistoryExistsProvider = FutureProvider.autoDispose
|
||||
ref.watch(
|
||||
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
||||
);
|
||||
return HistoryDatabase.instance.existsTrack(request);
|
||||
final notifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final row = await HistoryDatabase.instance.findExistingTrack(request);
|
||||
if (row == null) return false;
|
||||
return notifier.verifyOrRepairHistoryItem(
|
||||
DownloadHistoryItem.fromJson(row),
|
||||
);
|
||||
});
|
||||
|
||||
final downloadHistoryBatchExistsProvider = FutureProvider.autoDispose
|
||||
@@ -1623,7 +1764,29 @@ final downloadHistoryBatchExistsProvider = FutureProvider.autoDispose
|
||||
ref.watch(
|
||||
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
||||
);
|
||||
return HistoryDatabase.instance.existingTrackKeys(request.tracks);
|
||||
final notifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final rows = await HistoryDatabase.instance.findExistingTracks(
|
||||
request.tracks,
|
||||
);
|
||||
final found = <String>{};
|
||||
const chunkSize = 16;
|
||||
for (var start = 0; start < rows.length; start += chunkSize) {
|
||||
final end = min(start + chunkSize, rows.length);
|
||||
final checks = await Future.wait(
|
||||
List.generate(end - start, (offset) async {
|
||||
final index = start + offset;
|
||||
final row = rows[index];
|
||||
if (row == null) return null;
|
||||
final exists = await notifier.verifyOrRepairHistoryItem(
|
||||
DownloadHistoryItem.fromJson(row),
|
||||
);
|
||||
if (!exists) return null;
|
||||
return request.tracks[index].lookupKey;
|
||||
}),
|
||||
);
|
||||
found.addAll(checks.whereType<String>());
|
||||
}
|
||||
return found;
|
||||
});
|
||||
|
||||
class DownloadedAlbumTracksRequest {
|
||||
|
||||
@@ -3856,7 +3856,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final historyBitrate = isLossyOutput ? finalBitrateKbps : null;
|
||||
|
||||
if (settings.saveDownloadHistory) {
|
||||
ref
|
||||
await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.addToHistory(
|
||||
_historyItemFromResult(
|
||||
|
||||
@@ -31,6 +31,73 @@ class _NativeWorkerStartupTimeout implements Exception {
|
||||
}
|
||||
|
||||
extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
Future<void> _persistNativeFinalizedHistoryFallback(
|
||||
_NativeWorkerRequestContext context,
|
||||
Map<String, dynamic> result,
|
||||
String filePath,
|
||||
) async {
|
||||
final format =
|
||||
normalizeAudioFormatValue(
|
||||
result['audio_codec']?.toString() ?? result['format']?.toString(),
|
||||
) ??
|
||||
normalizeAudioFormatValue(audioFormatForPath(filePath));
|
||||
final isLossy = isLossyAudioFormat(format);
|
||||
final bitDepth = isLossy
|
||||
? null
|
||||
: readPositiveInt(result['actual_bit_depth'] ?? result['bit_depth']);
|
||||
final sampleRate = isLossy
|
||||
? null
|
||||
: readPositiveInt(
|
||||
result['actual_sample_rate'] ?? result['sample_rate'],
|
||||
);
|
||||
final bitrate = isLossy
|
||||
? readPositiveBitrateKbps(result['actual_bitrate'] ?? result['bitrate'])
|
||||
: null;
|
||||
final storedQuality =
|
||||
result['quality']?.toString().trim().isNotEmpty == true
|
||||
? result['quality'].toString()
|
||||
: context.quality;
|
||||
final quality =
|
||||
resolveDisplayQuality(
|
||||
filePath: filePath,
|
||||
fileName: result['file_name']?.toString(),
|
||||
detectedFormat: format,
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
bitrateKbps: bitrate,
|
||||
storedQuality: storedQuality,
|
||||
) ??
|
||||
storedQuality;
|
||||
final useSaf = context.storageMode == 'saf';
|
||||
final resultFileName = result['file_name']?.toString().trim();
|
||||
|
||||
await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.addToHistory(
|
||||
_historyItemFromResult(
|
||||
item: context.item,
|
||||
trackToDownload: context.item.track,
|
||||
result: result,
|
||||
filePath: filePath,
|
||||
quality: quality,
|
||||
useSaf: useSaf,
|
||||
downloadTreeUri: context.downloadTreeUri,
|
||||
safRelativeDir: context.safRelativeDir,
|
||||
safFileName: resultFileName?.isNotEmpty == true
|
||||
? resultFileName
|
||||
: context.safFileName,
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
bitrate: bitrate,
|
||||
format: format,
|
||||
genre: normalizeOptionalString(result['genre']?.toString()),
|
||||
label: normalizeOptionalString(result['label']?.toString()),
|
||||
copyright: normalizeOptionalString(result['copyright']?.toString()),
|
||||
),
|
||||
preserveTrackVariant: context.item.preserveQualityVariant,
|
||||
);
|
||||
}
|
||||
|
||||
bool _canUseAndroidNativeWorker(AppSettings settings) {
|
||||
if (!Platform.isAndroid || !settings.nativeDownloadWorkerEnabled) {
|
||||
return false;
|
||||
@@ -869,7 +936,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
final historyItem = result['history_item'];
|
||||
if (historyItem is Map) {
|
||||
try {
|
||||
ref
|
||||
await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.adoptNativeHistoryItem(
|
||||
DownloadHistoryItem.fromJson(
|
||||
@@ -879,12 +946,25 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to adopt native history item: $e');
|
||||
await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.reloadFromStorage();
|
||||
await _persistNativeFinalizedHistoryFallback(
|
||||
context,
|
||||
result,
|
||||
filePath,
|
||||
);
|
||||
}
|
||||
} else if (result['history_written'] == true) {
|
||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
} else if (result['already_exists'] == true) {
|
||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
} else {
|
||||
_log.w(
|
||||
'Native finalizer completed without history; persisting Dart fallback',
|
||||
);
|
||||
await _persistNativeFinalizedHistoryFallback(
|
||||
context,
|
||||
result,
|
||||
filePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
_completedInSession++;
|
||||
@@ -1107,7 +1187,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
lowerFilePath.endsWith('.ogg');
|
||||
|
||||
if (settings.saveDownloadHistory) {
|
||||
ref
|
||||
await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.addToHistory(
|
||||
_historyItemFromResult(
|
||||
|
||||
Reference in New Issue
Block a user