mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-26 19:31:45 +02:00
1239 lines
40 KiB
Dart
1239 lines
40 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'dart:io';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/services/history_database.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart' hide log;
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
|
import 'package:spotiflac_android/utils/int_utils.dart';
|
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
|
|
|
part 'download_history_models.dart';
|
|
part 'download_history_provider_maintenance.dart';
|
|
|
|
final _historyLog = AppLogger('DownloadHistory');
|
|
|
|
typedef StartupOrphanDecision = ({
|
|
Set<String> confirmedIds,
|
|
Set<String> pendingIds,
|
|
});
|
|
|
|
StartupOrphanDecision reconcileStartupOrphanSuspects({
|
|
required Set<String> checkedIds,
|
|
required Set<String> missingIds,
|
|
required Set<String> previousSuspectIds,
|
|
}) {
|
|
final currentMissing = missingIds.intersection(checkedIds);
|
|
return (
|
|
confirmedIds: currentMissing.intersection(previousSuspectIds),
|
|
pendingIds: currentMissing.difference(previousSuspectIds),
|
|
);
|
|
}
|
|
|
|
bool historyItemsReferToSameStoredFile(
|
|
DownloadHistoryItem first,
|
|
DownloadHistoryItem second,
|
|
) {
|
|
if (first.id == second.id) return true;
|
|
final firstKeys = buildPathMatchKeys(first.filePath);
|
|
if (firstKeys.isEmpty) return false;
|
|
return buildPathMatchKeys(
|
|
second.filePath,
|
|
).any((key) => firstKeys.contains(key));
|
|
}
|
|
|
|
String? resolvePersistedHistoryQuality({
|
|
required String? incoming,
|
|
required String? existing,
|
|
}) {
|
|
return nonPlaceholderQuality(incoming) ??
|
|
nonPlaceholderQuality(existing) ??
|
|
normalizeOptionalString(incoming) ??
|
|
normalizeOptionalString(existing);
|
|
}
|
|
|
|
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|
static const int _initialHistoryLoadLimit = 100;
|
|
static const int _safRepairMaxPerLaunch = 60;
|
|
static const int _orphanCleanupMaxPerLaunch = 80;
|
|
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
|
static const _startupMaintenanceDelay = Duration(seconds: 4);
|
|
static const _startupMaintenanceStepGap = Duration(milliseconds: 250);
|
|
static const _startupSafRepairCursorKey =
|
|
'history_startup_saf_repair_cursor_v1';
|
|
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
|
|
static const _startupOrphanSuspectPrefix =
|
|
'history_startup_orphan_suspect_v2_';
|
|
static const _legacyStartupOrphanSuspectPrefix =
|
|
'history_startup_orphan_suspect_v1_';
|
|
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
|
|
static const _audioProbeFailedPathsKey =
|
|
'history_audio_probe_failed_paths_v1';
|
|
static const _audioProbeFailedPathsMax = 300;
|
|
final HistoryDatabase _db = HistoryDatabase.instance;
|
|
bool _isLoaded = false;
|
|
bool _isSafRepairInProgress = false;
|
|
bool _isAudioMetadataBackfillInProgress = false;
|
|
bool _startupMaintenanceScheduled = false;
|
|
Future<void> _historyWriteChain = Future<void>.value();
|
|
Timer? _indexBumpTimer;
|
|
DateTime? _lastIndexBumpAt;
|
|
static const _indexBumpWindow = Duration(seconds: 1);
|
|
|
|
@override
|
|
DownloadHistoryState build() {
|
|
ref.onDispose(() => _indexBumpTimer?.cancel());
|
|
_loadFromDatabaseSync();
|
|
return DownloadHistoryState();
|
|
}
|
|
|
|
void _loadFromDatabaseSync() {
|
|
if (_isLoaded) return;
|
|
_isLoaded = true;
|
|
Future.microtask(() async {
|
|
await _loadFromDatabase();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadFromDatabase() async {
|
|
try {
|
|
final migrated = await _db.migrateFromSharedPreferences();
|
|
if (migrated) {
|
|
_historyLog.i('Migrated history from SharedPreferences to SQLite');
|
|
}
|
|
|
|
if (Platform.isIOS) {
|
|
final pathsMigrated = await _db.migrateIosContainerPaths();
|
|
if (pathsMigrated) {
|
|
_historyLog.i('Migrated iOS container paths after app update');
|
|
}
|
|
}
|
|
|
|
final countFuture = _db.getCount();
|
|
final jsonList = await _db.getAll(limit: _initialHistoryLoadLimit);
|
|
final items = jsonList
|
|
.map((e) => DownloadHistoryItem.fromJson(e))
|
|
.toList();
|
|
final totalCount = await countFuture;
|
|
|
|
state = state.copyWith(
|
|
items: items,
|
|
totalCount: totalCount,
|
|
loadedIndexVersion: state.loadedIndexVersion + 1,
|
|
lookupItems: items,
|
|
);
|
|
_historyLog.i(
|
|
'Loaded ${items.length}/$totalCount recent history items from SQLite database',
|
|
);
|
|
_scheduleStartupMaintenance(items);
|
|
} catch (e, stack) {
|
|
_historyLog.e('Failed to load history from database: $e', e, stack);
|
|
}
|
|
}
|
|
|
|
Future<void> reloadFromStorage() async {
|
|
await _loadFromDatabase();
|
|
}
|
|
|
|
void _bumpHistoryRevision() {
|
|
state = state.copyWith(loadedIndexVersion: state.loadedIndexVersion + 1);
|
|
}
|
|
|
|
Future<({DownloadHistoryItem item, String? existingId})> _resolveHistoryItem(
|
|
DownloadHistoryItem item,
|
|
) async {
|
|
DownloadHistoryItem? existing;
|
|
for (final candidate in state.lookupItems) {
|
|
if (historyItemsReferToSameStoredFile(candidate, item)) {
|
|
existing = candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (existing == null) {
|
|
final json = await _db.getById(item.id);
|
|
if (json != null) {
|
|
existing = DownloadHistoryItem.fromJson(json);
|
|
}
|
|
}
|
|
|
|
if (existing == null) {
|
|
final json = await _db.findByFilePath(item.filePath);
|
|
if (json != null) {
|
|
existing = DownloadHistoryItem.fromJson(json);
|
|
}
|
|
}
|
|
|
|
final incomingItem = existing != null && existing.id != item.id
|
|
? DownloadHistoryItem.fromJson(item.toJson()..['id'] = existing.id)
|
|
: item;
|
|
final mergedItem = existing == null
|
|
? incomingItem
|
|
: incomingItem.copyWith(
|
|
quality: resolvePersistedHistoryQuality(
|
|
incoming: item.quality,
|
|
existing: existing.quality,
|
|
),
|
|
bitDepth: item.bitDepth ?? existing.bitDepth,
|
|
sampleRate: item.sampleRate ?? existing.sampleRate,
|
|
bitrate: item.bitrate ?? existing.bitrate,
|
|
format:
|
|
normalizeOptionalString(item.format) ??
|
|
normalizeOptionalString(existing.format),
|
|
trackNumber: item.trackNumber ?? existing.trackNumber,
|
|
totalTracks: item.totalTracks ?? existing.totalTracks,
|
|
discNumber: item.discNumber ?? existing.discNumber,
|
|
totalDiscs: item.totalDiscs ?? existing.totalDiscs,
|
|
genre:
|
|
normalizeOptionalString(item.genre) ??
|
|
normalizeOptionalString(existing.genre),
|
|
composer:
|
|
normalizeOptionalString(item.composer) ??
|
|
normalizeOptionalString(existing.composer),
|
|
label:
|
|
normalizeOptionalString(item.label) ??
|
|
normalizeOptionalString(existing.label),
|
|
copyright:
|
|
normalizeOptionalString(item.copyright) ??
|
|
normalizeOptionalString(existing.copyright),
|
|
explicit: item.explicit || existing.explicit,
|
|
hasLyrics:
|
|
item.lyricsMetadataScanVersion >=
|
|
existing.lyricsMetadataScanVersion
|
|
? item.hasLyrics
|
|
: existing.hasLyrics,
|
|
lyricsMetadataScanVersion: max(
|
|
item.lyricsMetadataScanVersion,
|
|
existing.lyricsMetadataScanVersion,
|
|
),
|
|
);
|
|
return (item: mergedItem, existingId: existing?.id);
|
|
}
|
|
|
|
void _putResolvedHistoryInMemory(
|
|
DownloadHistoryItem item,
|
|
String? existingId,
|
|
) {
|
|
if (existingId != null) {
|
|
final updatedItems = state.items
|
|
.where((candidate) => candidate.id != existingId)
|
|
.toList();
|
|
updatedItems.insert(0, item);
|
|
final updatedLookupItems = state.lookupItems
|
|
.where((candidate) => candidate.id != existingId)
|
|
.toList(growable: false);
|
|
state = state.copyWith(
|
|
items: updatedItems,
|
|
lookupItems: [item, ...updatedLookupItems],
|
|
);
|
|
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
|
} else {
|
|
state = state.copyWith(
|
|
items: [item, ...state.items],
|
|
totalCount: state.totalCount + 1,
|
|
lookupItems: [item, ...state.lookupItems],
|
|
);
|
|
_historyLog.d('Added new history entry: ${item.trackName}');
|
|
}
|
|
}
|
|
|
|
List<DownloadHistoryItem> _lookupItemsWithUpdates(
|
|
Iterable<DownloadHistoryItem> updates, {
|
|
Set<String> deletedIds = const <String>{},
|
|
}) {
|
|
final byId = <String, DownloadHistoryItem>{
|
|
for (final item in state.lookupItems)
|
|
if (!deletedIds.contains(item.id)) item.id: item,
|
|
};
|
|
for (final item in updates) {
|
|
if (!deletedIds.contains(item.id)) {
|
|
byId[item.id] = item;
|
|
}
|
|
}
|
|
return byId.values.toList(growable: false);
|
|
}
|
|
|
|
Future<void> addToHistory(
|
|
DownloadHistoryItem item, {
|
|
bool preserveTrackVariant = false,
|
|
}) => _enqueueHistoryWrite(
|
|
() => _persistHistoryItem(
|
|
item,
|
|
'save to database',
|
|
preserveTrackVariant: preserveTrackVariant,
|
|
),
|
|
);
|
|
|
|
Future<void> adoptNativeHistoryItem(
|
|
DownloadHistoryItem item, {
|
|
bool preserveTrackVariant = false,
|
|
}) => _enqueueHistoryWrite(
|
|
() => _persistHistoryItem(
|
|
item,
|
|
'adopt native history item',
|
|
preserveTrackVariant: preserveTrackVariant,
|
|
),
|
|
);
|
|
|
|
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,
|
|
}) 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);
|
|
}
|
|
_scheduleIndexBump();
|
|
} catch (e, stack) {
|
|
_historyLog.e('Failed to $action: $e', e, stack);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Coalesces the post-persist count refresh + index bump to at most one per
|
|
/// [_indexBumpWindow]. Every bump invalidates the DB-derived views (queue
|
|
/// union queries, grouped counts, per-row exists checks) across all
|
|
/// keep-alive tabs, so per-item bumps during a batch fan out into repeated
|
|
/// full-table work.
|
|
void _scheduleIndexBump() {
|
|
if (_indexBumpTimer != null) return;
|
|
final now = DateTime.now();
|
|
final sinceLast = _lastIndexBumpAt == null
|
|
? _indexBumpWindow
|
|
: now.difference(_lastIndexBumpAt!);
|
|
if (sinceLast >= _indexBumpWindow) {
|
|
_lastIndexBumpAt = now;
|
|
unawaited(_bumpIndexNow());
|
|
return;
|
|
}
|
|
_indexBumpTimer = Timer(_indexBumpWindow - sinceLast, () {
|
|
_indexBumpTimer = null;
|
|
_lastIndexBumpAt = DateTime.now();
|
|
unawaited(_bumpIndexNow());
|
|
});
|
|
}
|
|
|
|
Future<void> _bumpIndexNow() async {
|
|
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,
|
|
);
|
|
}
|
|
|
|
DownloadHistoryItem _putInMemoryTrackVariant(DownloadHistoryItem item) {
|
|
final isReplacement = state.items.any((existing) => existing.id == item.id);
|
|
final items = [
|
|
item,
|
|
...state.items.where((existing) => existing.id != item.id),
|
|
];
|
|
final lookupItems = [
|
|
item,
|
|
...state.lookupItems.where((existing) => existing.id != item.id),
|
|
];
|
|
state = state.copyWith(
|
|
items: items,
|
|
totalCount: isReplacement ? state.totalCount : state.totalCount + 1,
|
|
lookupItems: lookupItems,
|
|
);
|
|
_historyLog.d('Added independent history variant: ${item.trackName}');
|
|
return item;
|
|
}
|
|
|
|
void removeFromHistory(String id) {
|
|
state = state.copyWith(
|
|
items: state.items.where((item) => item.id != id).toList(),
|
|
totalCount: state.totalCount > 0
|
|
? state.totalCount - 1
|
|
: state.totalCount,
|
|
lookupItems: state.lookupItems
|
|
.where((item) => item.id != id)
|
|
.toList(growable: false),
|
|
);
|
|
_db
|
|
.deleteById(id)
|
|
.catchError((Object e) {
|
|
_historyLog.e('Failed to delete from database: $e');
|
|
})
|
|
.then((_) {
|
|
_bumpHistoryRevision();
|
|
});
|
|
}
|
|
|
|
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
|
|
return state.getBySpotifyId(spotifyId);
|
|
}
|
|
|
|
DownloadHistoryItem? getByIsrc(String isrc) {
|
|
return state.getByIsrc(isrc);
|
|
}
|
|
|
|
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
|
|
final inMemory = state.getBySpotifyId(spotifyId);
|
|
if (inMemory != null) return inMemory;
|
|
|
|
final json = await _db.getBySpotifyId(spotifyId);
|
|
if (json == null) return null;
|
|
return DownloadHistoryItem.fromJson(json);
|
|
}
|
|
|
|
Future<DownloadHistoryItem?> getByIsrcAsync(String isrc) async {
|
|
final inMemory = state.getByIsrc(isrc);
|
|
if (inMemory != null) return inMemory;
|
|
|
|
final json = await _db.getByIsrc(isrc);
|
|
if (json == null) return null;
|
|
return DownloadHistoryItem.fromJson(json);
|
|
}
|
|
|
|
Future<DownloadHistoryItem?> getByFilePathAsync(String filePath) async {
|
|
final targetKeys = buildPathMatchKeys(filePath);
|
|
if (targetKeys.isEmpty) return null;
|
|
|
|
for (final item in state.lookupItems) {
|
|
if (buildPathMatchKeys(item.filePath).any(targetKeys.contains)) {
|
|
return item;
|
|
}
|
|
}
|
|
|
|
final json = await _db.findByFilePath(filePath);
|
|
if (json == null) return null;
|
|
return DownloadHistoryItem.fromJson(json);
|
|
}
|
|
|
|
Future<DownloadHistoryItem?> findByTrackAndArtistAsync(
|
|
String trackName,
|
|
String artistName,
|
|
) async {
|
|
final inMemory = state.findByTrackAndArtist(trackName, artistName);
|
|
if (inMemory != null) return inMemory;
|
|
|
|
final json = await _db.findByTrackAndArtist(trackName, artistName);
|
|
if (json == null) return null;
|
|
return DownloadHistoryItem.fromJson(json);
|
|
}
|
|
|
|
Future<DownloadHistoryItem?> findExistingTrackAsync(
|
|
HistoryLookupRequest request,
|
|
) async {
|
|
final bySpotifyId = state.getBySpotifyId(request.spotifyId);
|
|
if (bySpotifyId != null) return bySpotifyId;
|
|
|
|
final isrc = request.isrc?.trim();
|
|
if (isrc != null && isrc.isNotEmpty) {
|
|
final byIsrc = state.getByIsrc(isrc);
|
|
if (byIsrc != null) return byIsrc;
|
|
}
|
|
|
|
final byTrackArtist = state.findByTrackAndArtist(
|
|
request.trackName,
|
|
request.artistName,
|
|
);
|
|
if (byTrackArtist != null) return byTrackArtist;
|
|
|
|
final json = await _db.findExistingTrack(request);
|
|
if (json == null) return null;
|
|
return DownloadHistoryItem.fromJson(json);
|
|
}
|
|
|
|
Future<({DownloadHistoryItem item, int index})?> _historyItemForUpdate(
|
|
String id,
|
|
) async {
|
|
final index = state.items.indexWhere((item) => item.id == id);
|
|
if (index >= 0) {
|
|
return (item: state.items[index], index: index);
|
|
}
|
|
|
|
final json = await _db.getById(id);
|
|
if (json == null) return null;
|
|
return (item: DownloadHistoryItem.fromJson(json), index: -1);
|
|
}
|
|
|
|
Future<void> updateAudioMetadataForItem({
|
|
required String id,
|
|
String? quality,
|
|
int? bitDepth,
|
|
int? sampleRate,
|
|
int? bitrate,
|
|
String? format,
|
|
int? trackNumber,
|
|
int? totalTracks,
|
|
int? discNumber,
|
|
int? totalDiscs,
|
|
int? duration,
|
|
String? composer,
|
|
bool? explicit,
|
|
bool? hasLyrics,
|
|
int? lyricsMetadataScanVersion,
|
|
}) async {
|
|
final target = await _historyItemForUpdate(id);
|
|
if (target == null) {
|
|
_historyLog.w(
|
|
'Cannot update audio metadata for missing history item: $id',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final current = target.item;
|
|
final updated = current.copyWith(
|
|
quality: quality,
|
|
bitDepth: bitDepth,
|
|
sampleRate: sampleRate,
|
|
bitrate: bitrate,
|
|
format: format,
|
|
trackNumber: trackNumber,
|
|
totalTracks: totalTracks,
|
|
discNumber: discNumber,
|
|
totalDiscs: totalDiscs,
|
|
duration: duration,
|
|
composer: composer,
|
|
explicit: explicit,
|
|
hasLyrics: hasLyrics,
|
|
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
|
|
);
|
|
|
|
if (updated.quality == current.quality &&
|
|
updated.bitDepth == current.bitDepth &&
|
|
updated.sampleRate == current.sampleRate &&
|
|
updated.bitrate == current.bitrate &&
|
|
updated.format == current.format &&
|
|
updated.trackNumber == current.trackNumber &&
|
|
updated.totalTracks == current.totalTracks &&
|
|
updated.discNumber == current.discNumber &&
|
|
updated.totalDiscs == current.totalDiscs &&
|
|
updated.duration == current.duration &&
|
|
updated.composer == current.composer &&
|
|
updated.explicit == current.explicit &&
|
|
updated.hasLyrics == current.hasLyrics &&
|
|
updated.lyricsMetadataScanVersion ==
|
|
current.lyricsMetadataScanVersion) {
|
|
return;
|
|
}
|
|
|
|
final updatedItems = target.index >= 0
|
|
? ([...state.items]..[target.index] = updated)
|
|
: state.items;
|
|
state = state.copyWith(
|
|
items: updatedItems,
|
|
lookupItems: _lookupItemsWithUpdates([updated]),
|
|
);
|
|
await _db.upsert(updated.toJson());
|
|
// Swiping through older tracks can backfill several quality records in a
|
|
// short burst. Coalesce their DB-derived Library refreshes just like
|
|
// download completion writes instead of rebuilding every view per swipe.
|
|
_scheduleIndexBump();
|
|
}
|
|
|
|
Future<void> updateMetadataForItem({
|
|
required String id,
|
|
required String trackName,
|
|
required String artistName,
|
|
required String albumName,
|
|
String? albumArtist,
|
|
String? isrc,
|
|
int? trackNumber,
|
|
int? totalTracks,
|
|
int? discNumber,
|
|
int? totalDiscs,
|
|
String? releaseDate,
|
|
String? genre,
|
|
String? composer,
|
|
String? label,
|
|
String? copyright,
|
|
bool? explicit,
|
|
bool? hasLyrics,
|
|
int? lyricsMetadataScanVersion,
|
|
}) async {
|
|
final target = await _historyItemForUpdate(id);
|
|
if (target == null) {
|
|
_historyLog.w('Cannot update metadata for missing history item: $id');
|
|
return;
|
|
}
|
|
|
|
final current = target.item;
|
|
final updated = current.copyWith(
|
|
trackName: trackName,
|
|
artistName: artistName,
|
|
albumName: albumName,
|
|
albumArtist: albumArtist,
|
|
isrc: isrc,
|
|
trackNumber: trackNumber,
|
|
totalTracks: totalTracks,
|
|
discNumber: discNumber,
|
|
totalDiscs: totalDiscs,
|
|
releaseDate: releaseDate,
|
|
genre: genre,
|
|
composer: composer,
|
|
label: label,
|
|
copyright: copyright,
|
|
explicit: explicit,
|
|
hasLyrics: hasLyrics,
|
|
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
|
|
);
|
|
|
|
final updatedItems = target.index >= 0
|
|
? ([...state.items]..[target.index] = updated)
|
|
: state.items;
|
|
state = state.copyWith(
|
|
items: updatedItems,
|
|
lookupItems: _lookupItemsWithUpdates([updated]),
|
|
);
|
|
await _db.upsert(updated.toJson());
|
|
_bumpHistoryRevision();
|
|
}
|
|
|
|
static const _audioExtensions = [
|
|
'.flac',
|
|
'.m4a',
|
|
'.mp3',
|
|
'.opus',
|
|
'.ogg',
|
|
'.wav',
|
|
'.aiff',
|
|
'.aac',
|
|
'.mp4',
|
|
];
|
|
|
|
Future<String?> _findConvertedSibling(
|
|
String originalPath, {
|
|
bool includeAlternateExtensions = true,
|
|
}) async {
|
|
final dotIndex = originalPath.lastIndexOf('.');
|
|
if (dotIndex < 0) return null;
|
|
final directoryPrefix = originalPath.substring(
|
|
0,
|
|
originalPath.lastIndexOf(Platform.pathSeparator) + 1,
|
|
);
|
|
final fileName = originalPath.substring(
|
|
originalPath.lastIndexOf(Platform.pathSeparator) + 1,
|
|
);
|
|
|
|
for (final candidateName in _conversionRenameCandidates(
|
|
fileName,
|
|
includeAlternateExtensions: includeAlternateExtensions,
|
|
)) {
|
|
final candidatePath = '$directoryPrefix$candidateName';
|
|
if (candidatePath == originalPath) continue;
|
|
try {
|
|
if (await fileExists(candidatePath)) return candidatePath;
|
|
} catch (_) {}
|
|
}
|
|
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,
|
|
Map<String, String> replacementPaths,
|
|
Map<String, String> replacementFileNames,
|
|
Map<String, String> replacementRelativeDirs,
|
|
Map<String, String> pathById,
|
|
})
|
|
>
|
|
_inspectOrphanedEntries(List<Map<String, dynamic>> entries) async {
|
|
final orphanedIds = <String>[];
|
|
final replacementPaths = <String, String>{};
|
|
final replacementFileNames = <String, String>{};
|
|
final replacementRelativeDirs = <String, String>{};
|
|
final pathById = <String, String>{};
|
|
final regularEntries = <Map<String, dynamic>>[];
|
|
final safEntries = <Map<String, dynamic>>[];
|
|
|
|
for (final entry in entries) {
|
|
final id = entry['id'] as String;
|
|
final filePath = (entry['file_path'] as String? ?? '').trim();
|
|
if (filePath.isEmpty) continue;
|
|
pathById[id] = filePath;
|
|
if (entry['storage_mode'] == 'saf') {
|
|
safEntries.add(entry);
|
|
} else {
|
|
regularEntries.add(entry);
|
|
}
|
|
}
|
|
|
|
const checkChunkSize = 16;
|
|
|
|
for (var i = 0; i < regularEntries.length; i += checkChunkSize) {
|
|
final end = (i + checkChunkSize < regularEntries.length)
|
|
? i + checkChunkSize
|
|
: regularEntries.length;
|
|
final chunk = regularEntries.sublist(i, end);
|
|
|
|
final checks = await Future.wait<MapEntry<String, bool?>?>(
|
|
chunk.map((entry) async {
|
|
final id = entry['id'] as String;
|
|
final filePath = entry['file_path'] as String;
|
|
try {
|
|
if (await fileExists(filePath)) return MapEntry(id, true);
|
|
|
|
final sibling = await _findConvertedSibling(filePath);
|
|
if (sibling != null) {
|
|
_historyLog.i(
|
|
'Found converted sibling for $id: $filePath -> $sibling',
|
|
);
|
|
replacementPaths[id] = sibling;
|
|
pathById[id] = sibling;
|
|
return MapEntry(id, true);
|
|
}
|
|
|
|
return MapEntry(id, false);
|
|
} catch (e) {
|
|
_historyLog.w('Error checking file existence for $id: $e');
|
|
return MapEntry(id, null);
|
|
}
|
|
}),
|
|
);
|
|
|
|
for (final check in checks) {
|
|
if (check == null || check.value != false) continue;
|
|
orphanedIds.add(check.key);
|
|
_historyLog.d(
|
|
'Found orphaned entry: ${check.key} (${pathById[check.key] ?? ''})',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (safEntries.isNotEmpty && Platform.isAndroid) {
|
|
final requests = <Map<String, dynamic>>[];
|
|
for (final entry in safEntries) {
|
|
final id = entry['id'] as String;
|
|
final filePath = entry['file_path'] as String;
|
|
var fileName = (entry['saf_file_name'] as String? ?? '').trim();
|
|
if (fileName.isEmpty && isContentUri(filePath)) {
|
|
fileName = _fileNameFromUri(filePath);
|
|
}
|
|
requests.add({
|
|
'key': id,
|
|
'tree_uri': entry['download_tree_uri'] as String? ?? '',
|
|
'relative_dir': entry['saf_relative_dir'] as String? ?? '',
|
|
'current_uri': isContentUri(filePath) ? filePath : '',
|
|
'file_names': _conversionRenameCandidates(
|
|
fileName,
|
|
includeAlternateExtensions: true,
|
|
),
|
|
});
|
|
}
|
|
|
|
try {
|
|
final results = await PlatformBridge.inspectSafFiles(requests);
|
|
final resultsById = <String, Map<String, dynamic>>{
|
|
for (final result in results)
|
|
if ((result['key'] as String? ?? '').isNotEmpty)
|
|
result['key'] as String: result,
|
|
};
|
|
for (final entry in safEntries) {
|
|
final id = entry['id'] as String;
|
|
final result = resultsById[id];
|
|
final status = result?['status'] as String? ?? 'unknown';
|
|
if (status == 'missing') {
|
|
orphanedIds.add(id);
|
|
_historyLog.d(
|
|
'Found orphaned SAF entry: $id (${pathById[id] ?? ''})',
|
|
);
|
|
continue;
|
|
}
|
|
if (status != 'found' || result == null) continue;
|
|
|
|
final uri = (result['uri'] as String? ?? '').trim();
|
|
if (uri.isEmpty) continue;
|
|
final fileName = (result['file_name'] as String? ?? '').trim();
|
|
final relativeDir = (result['relative_dir'] as String? ?? '').trim();
|
|
final oldPath = pathById[id] ?? '';
|
|
final oldFileName = (entry['saf_file_name'] as String? ?? '').trim();
|
|
final oldRelativeDir = (entry['saf_relative_dir'] as String? ?? '')
|
|
.trim();
|
|
if (uri != oldPath ||
|
|
(fileName.isNotEmpty && fileName != oldFileName) ||
|
|
(relativeDir.isNotEmpty && relativeDir != oldRelativeDir)) {
|
|
replacementPaths[id] = uri;
|
|
if (fileName.isNotEmpty) replacementFileNames[id] = fileName;
|
|
if (relativeDir.isNotEmpty) {
|
|
replacementRelativeDirs[id] = relativeDir;
|
|
}
|
|
pathById[id] = uri;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// A bridge or provider error is inconclusive. Never delete SAF history
|
|
// when the batch inspection could not establish that files are absent.
|
|
_historyLog.w('Unable to inspect SAF history entries: $error');
|
|
}
|
|
}
|
|
|
|
return (
|
|
orphanedIds: orphanedIds,
|
|
replacementPaths: replacementPaths,
|
|
replacementFileNames: replacementFileNames,
|
|
replacementRelativeDirs: replacementRelativeDirs,
|
|
pathById: pathById,
|
|
);
|
|
}
|
|
|
|
void _applyHistoryPathAndDeletionChanges({
|
|
required List<String> deletedIds,
|
|
required Map<String, String> replacementPaths,
|
|
Map<String, String> replacementFileNames = const {},
|
|
Map<String, String> replacementRelativeDirs = const {},
|
|
}) {
|
|
if (deletedIds.isEmpty && replacementPaths.isEmpty) {
|
|
return;
|
|
}
|
|
final deletedSet = deletedIds.toSet();
|
|
final updatedItems = <DownloadHistoryItem>[];
|
|
for (final item in state.items) {
|
|
if (deletedSet.contains(item.id)) {
|
|
continue;
|
|
}
|
|
final replacementPath = replacementPaths[item.id];
|
|
final replacementFileName = replacementFileNames[item.id];
|
|
final replacementRelativeDir = replacementRelativeDirs[item.id];
|
|
if (replacementPath != null &&
|
|
(replacementPath != item.filePath ||
|
|
(replacementFileName != null &&
|
|
replacementFileName != item.safFileName) ||
|
|
(replacementRelativeDir != null &&
|
|
replacementRelativeDir != item.safRelativeDir))) {
|
|
updatedItems.add(
|
|
item.copyWith(
|
|
filePath: replacementPath,
|
|
safFileName: replacementFileName,
|
|
safRelativeDir: replacementRelativeDir,
|
|
),
|
|
);
|
|
} else {
|
|
updatedItems.add(item);
|
|
}
|
|
}
|
|
state = state.copyWith(
|
|
items: updatedItems,
|
|
loadedIndexVersion: state.loadedIndexVersion + 1,
|
|
lookupItems: _lookupItemsWithUpdates(
|
|
updatedItems,
|
|
deletedIds: deletedSet,
|
|
),
|
|
totalCount: max(0, state.totalCount - deletedSet.length),
|
|
);
|
|
}
|
|
|
|
Future<int> _cleanupOrphanedDownloadsIncremental({
|
|
required int maxItems,
|
|
required SharedPreferences prefs,
|
|
}) async {
|
|
final cursor = prefs.getInt(_startupOrphanCursorKey) ?? 0;
|
|
final safeCursor = cursor < 0 ? 0 : cursor;
|
|
final entries = await _db.getEntriesWithPathsPage(
|
|
limit: maxItems,
|
|
offset: safeCursor,
|
|
);
|
|
if (entries.isEmpty) {
|
|
await prefs.remove(_startupOrphanCursorKey);
|
|
return 0;
|
|
}
|
|
|
|
final result = await _inspectOrphanedEntries(entries);
|
|
final checkedIds = result.pathById.keys.toSet();
|
|
final previousSuspectIds = <String>{
|
|
for (final id in checkedIds)
|
|
if (prefs.getBool('$_startupOrphanSuspectPrefix$id') == true) id,
|
|
};
|
|
final decision = reconcileStartupOrphanSuspects(
|
|
checkedIds: checkedIds,
|
|
missingIds: result.orphanedIds.toSet(),
|
|
previousSuspectIds: previousSuspectIds,
|
|
);
|
|
for (final id in checkedIds) {
|
|
final key = '$_startupOrphanSuspectPrefix$id';
|
|
await prefs.remove('$_legacyStartupOrphanSuspectPrefix$id');
|
|
if (decision.pendingIds.contains(id)) {
|
|
await prefs.setBool(key, true);
|
|
_historyLog.d(
|
|
'Deferring orphan removal until next pass: $id (${result.pathById[id] ?? ''})',
|
|
);
|
|
} else {
|
|
await prefs.remove(key);
|
|
}
|
|
}
|
|
for (final replacement in result.replacementPaths.entries) {
|
|
await _db.updateFilePath(
|
|
replacement.key,
|
|
replacement.value,
|
|
newSafFileName: result.replacementFileNames[replacement.key],
|
|
newSafRelativeDir: result.replacementRelativeDirs[replacement.key],
|
|
);
|
|
await prefs.remove('$_startupOrphanSuspectPrefix${replacement.key}');
|
|
}
|
|
|
|
final confirmedOrphanIds = decision.confirmedIds.toList(growable: false);
|
|
final deletedCount = confirmedOrphanIds.isEmpty
|
|
? 0
|
|
: await _db.deleteByIds(confirmedOrphanIds);
|
|
|
|
_applyHistoryPathAndDeletionChanges(
|
|
deletedIds: confirmedOrphanIds,
|
|
replacementPaths: result.replacementPaths,
|
|
replacementFileNames: result.replacementFileNames,
|
|
replacementRelativeDirs: result.replacementRelativeDirs,
|
|
);
|
|
|
|
if (entries.length < maxItems) {
|
|
await prefs.remove(_startupOrphanCursorKey);
|
|
} else {
|
|
final nextCursor = result.orphanedIds.isNotEmpty
|
|
? safeCursor
|
|
: safeCursor + entries.length;
|
|
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
|
|
}
|
|
|
|
if (deletedCount > 0 || result.replacementPaths.isNotEmpty) {
|
|
_historyLog.i(
|
|
'Startup orphan cleanup pass: removed=$deletedCount, repaired=${result.replacementPaths.length}, checked=${entries.length}',
|
|
);
|
|
}
|
|
return deletedCount;
|
|
}
|
|
|
|
Future<int> cleanupOrphanedDownloads() async {
|
|
_historyLog.i('Starting orphaned downloads cleanup...');
|
|
final orphanedIds = <String>[];
|
|
final replacementPaths = <String, String>{};
|
|
final replacementFileNames = <String, String>{};
|
|
final replacementRelativeDirs = <String, String>{};
|
|
const pageSize = 256;
|
|
var offset = 0;
|
|
|
|
while (true) {
|
|
final entries = await _db.getEntriesWithPathsPage(
|
|
limit: pageSize,
|
|
offset: offset,
|
|
);
|
|
if (entries.isEmpty) {
|
|
break;
|
|
}
|
|
|
|
final result = await _inspectOrphanedEntries(entries);
|
|
orphanedIds.addAll(result.orphanedIds);
|
|
replacementPaths.addAll(result.replacementPaths);
|
|
replacementFileNames.addAll(result.replacementFileNames);
|
|
replacementRelativeDirs.addAll(result.replacementRelativeDirs);
|
|
|
|
if (entries.length < pageSize) {
|
|
break;
|
|
}
|
|
// Deletions are applied only after inspection finishes, so advance by
|
|
// the full page. Subtracting missing rows here can repeatedly fetch the
|
|
// same page forever when an entire page is orphaned.
|
|
offset += entries.length;
|
|
}
|
|
|
|
for (final replacement in replacementPaths.entries) {
|
|
await _db.updateFilePath(
|
|
replacement.key,
|
|
replacement.value,
|
|
newSafFileName: replacementFileNames[replacement.key],
|
|
newSafRelativeDir: replacementRelativeDirs[replacement.key],
|
|
);
|
|
}
|
|
|
|
if (orphanedIds.isEmpty && replacementPaths.isEmpty) {
|
|
_historyLog.i('No orphaned entries found');
|
|
return 0;
|
|
}
|
|
|
|
final deletedCount = orphanedIds.isEmpty
|
|
? 0
|
|
: await _db.deleteByIds(orphanedIds);
|
|
_applyHistoryPathAndDeletionChanges(
|
|
deletedIds: orphanedIds,
|
|
replacementPaths: replacementPaths,
|
|
replacementFileNames: replacementFileNames,
|
|
replacementRelativeDirs: replacementRelativeDirs,
|
|
);
|
|
|
|
_historyLog.i(
|
|
'Cleaned up $deletedCount orphaned entries and repaired ${replacementPaths.length} paths',
|
|
);
|
|
return deletedCount;
|
|
}
|
|
|
|
void clearHistory() {
|
|
state = DownloadHistoryState(loadedIndexVersion: state.loadedIndexVersion);
|
|
_db
|
|
.clearAll()
|
|
.then((_) {
|
|
_bumpHistoryRevision();
|
|
})
|
|
.catchError((Object e) {
|
|
_historyLog.e('Failed to clear database: $e');
|
|
});
|
|
}
|
|
|
|
/// Replaces all download history with [items] (each in the
|
|
/// [DownloadHistoryItem.toJson] shape) from a restored backup, then reloads
|
|
/// the in-memory state from storage.
|
|
Future<void> restoreFromBackup(List<Map<String, dynamic>> items) async {
|
|
await _db.clearAll();
|
|
if (items.isNotEmpty) {
|
|
await _db.upsertBatch(items);
|
|
}
|
|
await reloadFromStorage();
|
|
}
|
|
|
|
/// Restores a large v2 backup without retaining the complete history in
|
|
/// Dart memory. SQLite writes are grouped to keep JNI/channel overhead low.
|
|
Future<void> restoreFromBackupStream(
|
|
Stream<Map<String, dynamic>> items,
|
|
) async {
|
|
await _db.clearAll();
|
|
var batch = <Map<String, dynamic>>[];
|
|
await for (final item in items) {
|
|
batch.add(item);
|
|
if (batch.length < 500) continue;
|
|
await _db.upsertBatch(batch);
|
|
batch = <Map<String, dynamic>>[];
|
|
}
|
|
if (batch.isNotEmpty) await _db.upsertBatch(batch);
|
|
await reloadFromStorage();
|
|
}
|
|
}
|
|
|
|
final downloadHistoryProvider =
|
|
NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
|
|
DownloadHistoryNotifier.new,
|
|
);
|
|
|
|
class DownloadHistoryGroupedCounts {
|
|
final int albumCount;
|
|
final int singleTrackCount;
|
|
|
|
const DownloadHistoryGroupedCounts({
|
|
required this.albumCount,
|
|
required this.singleTrackCount,
|
|
});
|
|
}
|
|
|
|
final downloadHistoryGroupedCountsProvider =
|
|
FutureProvider<DownloadHistoryGroupedCounts>((ref) async {
|
|
ref.watch(
|
|
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
|
);
|
|
final counts = await HistoryDatabase.instance.getGroupedCounts();
|
|
return DownloadHistoryGroupedCounts(
|
|
albumCount: counts['albums'] ?? 0,
|
|
singleTrackCount: counts['singles'] ?? 0,
|
|
);
|
|
});
|
|
|
|
HistoryLookupRequest historyLookupForTrack(Track track) {
|
|
return HistoryLookupRequest(
|
|
spotifyId: track.id,
|
|
isrc: track.isrc,
|
|
trackName: track.name,
|
|
artistName: track.artistName,
|
|
);
|
|
}
|
|
|
|
final downloadHistoryExistsProvider = FutureProvider.autoDispose
|
|
.family<bool, HistoryLookupRequest>((ref, request) async {
|
|
ref.watch(
|
|
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
|
);
|
|
final notifier = ref.read(downloadHistoryProvider.notifier);
|
|
final row = await HistoryDatabase.instance.findExistingTrack(request);
|
|
if (row == null) return false;
|
|
return notifier.verifyOrRepairHistoryItem(
|
|
DownloadHistoryItem.fromJson(row),
|
|
);
|
|
});
|
|
|
|
// Batch lookups deliberately avoid per-row SAF verification. Startup repair
|
|
// reconciles stale rows; the single-track provider above keeps strict checks.
|
|
final downloadHistoryBatchExistsProvider = FutureProvider.autoDispose
|
|
.family<Set<String>, HistoryBatchLookupRequest>((ref, request) async {
|
|
ref.watch(
|
|
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
|
);
|
|
final rows = await HistoryDatabase.instance.findExistingTracks(
|
|
request.tracks,
|
|
);
|
|
return <String>{
|
|
for (var i = 0; i < rows.length; i++)
|
|
if (rows[i] != null) request.tracks[i].lookupKey,
|
|
};
|
|
});
|
|
|
|
bool historyLookupExistsInMemory(
|
|
DownloadHistoryState state,
|
|
HistoryLookupRequest request,
|
|
) {
|
|
for (final candidate in HistoryDatabase.spotifyLookupCandidates(
|
|
request.spotifyId,
|
|
)) {
|
|
if (state.getBySpotifyId(candidate) != null) return true;
|
|
}
|
|
|
|
final isrc = request.isrc?.trim();
|
|
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
|
|
return true;
|
|
}
|
|
|
|
return state.findByTrackAndArtist(request.trackName, request.artistName) !=
|
|
null;
|
|
}
|
|
|
|
Set<String> mergeVisibleHistoryLookupKeys({
|
|
required DownloadHistoryState state,
|
|
required HistoryBatchLookupRequest request,
|
|
required Set<String> persistedKeys,
|
|
}) {
|
|
final keys = <String>{...persistedKeys};
|
|
for (final track in request.tracks) {
|
|
if (historyLookupExistsInMemory(state, track)) {
|
|
keys.add(track.lookupKey);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/// UI-facing history lookup that keeps the last database result visible while
|
|
/// a batch query reloads and immediately merges downloads persisted in memory.
|
|
/// This prevents completed tracks from losing their Play action until the
|
|
/// remainder of a large download batch finishes.
|
|
final downloadHistoryVisibleBatchExistsProvider = Provider.autoDispose
|
|
.family<Set<String>, HistoryBatchLookupRequest>((ref, request) {
|
|
final state = ref.watch(downloadHistoryProvider);
|
|
final persistedKeys = ref
|
|
.watch(downloadHistoryBatchExistsProvider(request))
|
|
.maybeWhen(
|
|
skipLoadingOnReload: true,
|
|
skipLoadingOnRefresh: true,
|
|
data: (keys) => keys,
|
|
orElse: () => const <String>{},
|
|
);
|
|
return mergeVisibleHistoryLookupKeys(
|
|
state: state,
|
|
request: request,
|
|
persistedKeys: persistedKeys,
|
|
);
|
|
});
|
|
|
|
class DownloadedAlbumTracksRequest {
|
|
final String albumName;
|
|
final String artistName;
|
|
|
|
const DownloadedAlbumTracksRequest({
|
|
required this.albumName,
|
|
required this.artistName,
|
|
});
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is DownloadedAlbumTracksRequest &&
|
|
other.albumName == albumName &&
|
|
other.artistName == artistName;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(albumName, artistName);
|
|
}
|
|
|
|
final downloadedAlbumTracksProvider = FutureProvider.autoDispose
|
|
.family<List<DownloadHistoryItem>, DownloadedAlbumTracksRequest>((
|
|
ref,
|
|
request,
|
|
) async {
|
|
ref.watch(
|
|
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
|
);
|
|
final rows = await HistoryDatabase.instance.getAlbumTracks(
|
|
request.albumName,
|
|
request.artistName,
|
|
);
|
|
return rows.map(DownloadHistoryItem.fromJson).toList(growable: false);
|
|
});
|