mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 08:30:33 +02:00
feat(settings): add backup and restore for settings, history and library
Add a Backup & Restore page that exports app settings, download history, liked tracks, wishlist, playlists (with cover images) and favorite artists into a single JSON file, and restores them on another device. Settings restore preserves device-specific storage location (SAF tree URI, download dir). Includes EN strings and ID translations.
This commit is contained in:
@@ -1634,6 +1634,17 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
Future<int> getDatabaseCount() async {
|
||||
return await _db.getCount();
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
final downloadHistoryProvider =
|
||||
|
||||
@@ -953,6 +953,90 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
|
||||
/// Returns the full collections snapshot (wishlist, loved, playlists,
|
||||
/// favorite artists) for a backup, ensuring data is loaded first.
|
||||
Future<Map<String, dynamic>> exportCollections() async {
|
||||
await _ensureLoaded();
|
||||
return state.toJson();
|
||||
}
|
||||
|
||||
/// Exports custom playlist cover images as base64, keyed by playlist id.
|
||||
/// Each value contains the original file extension and the encoded bytes so a
|
||||
/// restore on another device can recreate the cover files.
|
||||
Future<Map<String, Map<String, String>>> exportPlaylistCovers() async {
|
||||
await _ensureLoaded();
|
||||
final covers = <String, Map<String, String>>{};
|
||||
for (final playlist in state.playlists) {
|
||||
final path = playlist.coverImagePath;
|
||||
if (path == null || path.isEmpty) continue;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) continue;
|
||||
final bytes = await file.readAsBytes();
|
||||
if (bytes.isEmpty) continue;
|
||||
covers[playlist.id] = {
|
||||
'ext': p.extension(path).toLowerCase(),
|
||||
'data': base64Encode(bytes),
|
||||
};
|
||||
} catch (_) {
|
||||
// Skip unreadable cover; the rest of the backup still succeeds.
|
||||
}
|
||||
}
|
||||
return covers;
|
||||
}
|
||||
|
||||
/// Replaces all collections (wishlist, loved, playlists, favorite artists)
|
||||
/// with the contents of a backup. [collectionsJson] uses the
|
||||
/// [LibraryCollectionsState.toJson] shape; [coverImages] is the map produced
|
||||
/// by [exportPlaylistCovers]. Cover images are rewritten into this device's
|
||||
/// covers directory and their paths fixed up before persisting.
|
||||
Future<void> restoreFromBackup(
|
||||
Map<String, dynamic> collectionsJson, {
|
||||
Map<String, dynamic>? coverImages,
|
||||
}) async {
|
||||
final normalized = Map<String, dynamic>.from(collectionsJson);
|
||||
final coversDir = await _playlistCoversDir();
|
||||
|
||||
final playlistsRaw = normalized['playlists'];
|
||||
if (playlistsRaw is List) {
|
||||
final rewritten = <Map<String, dynamic>>[];
|
||||
for (final entry in playlistsRaw.whereType<Map<Object?, Object?>>()) {
|
||||
final playlist = Map<String, dynamic>.from(entry);
|
||||
final id = playlist['id'] as String?;
|
||||
String? newCoverPath;
|
||||
final coverEntry = (id != null && coverImages != null)
|
||||
? coverImages[id]
|
||||
: null;
|
||||
if (id != null && coverEntry is Map) {
|
||||
final data = coverEntry['data'] as String?;
|
||||
final ext = (coverEntry['ext'] as String?) ?? '.jpg';
|
||||
if (data != null && data.isNotEmpty) {
|
||||
try {
|
||||
final destPath = p.join(coversDir.path, '$id$ext');
|
||||
await File(destPath).writeAsBytes(base64Decode(data));
|
||||
newCoverPath = destPath;
|
||||
} catch (_) {
|
||||
newCoverPath = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always replace the backup's device-specific path: either with the
|
||||
// freshly written local cover, or drop it so a stale path is not kept.
|
||||
if (newCoverPath != null) {
|
||||
playlist['coverImagePath'] = newCoverPath;
|
||||
} else {
|
||||
playlist.remove('coverImagePath');
|
||||
}
|
||||
rewritten.add(playlist);
|
||||
}
|
||||
normalized['playlists'] = rewritten;
|
||||
}
|
||||
|
||||
await _db.replaceAllFromBackup(normalized);
|
||||
await _load();
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
}
|
||||
|
||||
final libraryCollectionsProvider =
|
||||
|
||||
@@ -194,6 +194,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores settings from a backup payload (the map produced by
|
||||
/// [AppSettings.toJson]). Device-specific storage location fields
|
||||
/// (download directory and SAF tree URI) are intentionally preserved from the
|
||||
/// current device, because a SAF tree URI from another phone is not valid
|
||||
/// here and would break downloads.
|
||||
Future<void> restoreFromBackup(Map<String, dynamic> json) async {
|
||||
final current = state;
|
||||
AppSettings restored;
|
||||
try {
|
||||
restored = AppSettings.fromJson(Map<String, dynamic>.from(json));
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed to parse settings from backup: $e', e, stack);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
state = restored.copyWith(
|
||||
// Always keep extension providers enabled (matches _loadSettings).
|
||||
useExtensionProviders: true,
|
||||
// Preserve this device's storage location; the backup's values point at
|
||||
// the original device and would not resolve here.
|
||||
downloadDirectory: current.downloadDirectory,
|
||||
downloadDirectoryBookmark: current.downloadDirectoryBookmark,
|
||||
storageMode: current.storageMode,
|
||||
downloadTreeUri: current.downloadTreeUri,
|
||||
);
|
||||
|
||||
await _saveSettings();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
_syncLyricsSettingsToBackend();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
}
|
||||
|
||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||
if (!Platform.isIOS) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user