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:
zarzet
2026-06-26 19:54:20 +07:00
parent f236d72a19
commit b2705cb2ae
25 changed files with 3068 additions and 0 deletions
@@ -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 =
+34
View File
@@ -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;