feat(backup): allow selecting categories and settings-only backups

This commit is contained in:
zarzet committed 2026-09-08 19:51:25 +07:00
1 parent 8c7d2aad5c
commit 1da0da18a6
6 files changed
+368 -116

No files matched your search

+8
View File
@@ -4044,6 +4044,14 @@
"@backupContentsTitle": {
"description": "Header above the list summarizing what the backup contains"
},
"backupSettingsOnly": "Settings only",
"backupSelectHistory": "Download history",
"backupSelectCollections": "Library collections",
"backupSelectCollectionsDescription": "Liked tracks, wishlist, favorite artists, playlists and playlist covers.",
"backupSelectExtensions": "Extensions and their settings",
"backupSelectedExportDescription": "Save the selected categories to a backup file. Audio files are not included.",
"backupSelectAtLeastOne": "Select at least one category to create a backup.",
"backupSelectedRestoreMessage": "Only the categories listed below will be restored. Categories not included in this backup will stay unchanged. Restoring can replace existing data and cannot be undone.",
"backupContentsSettings": "App settings",
"@backupContentsSettings": {
"description": "Backup contents row label for settings"
+8
View File
@@ -5914,6 +5914,14 @@
"libraryFilterTitle": "Filters",
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
"extensionHooksAvailable": "{count} hook tersedia",
"backupSettingsOnly": "Pengaturan saja",
"backupSelectHistory": "Riwayat download",
"backupSelectCollections": "Koleksi library",
"backupSelectCollectionsDescription": "Lagu disukai, wishlist, artis favorit, playlist, dan sampul playlist.",
"backupSelectExtensions": "Ekstensi dan pengaturannya",
"backupSelectedExportDescription": "Simpan kategori yang dipilih ke file backup. File audio tidak disertakan.",
"backupSelectAtLeastOne": "Pilih minimal satu kategori untuk membuat backup.",
"backupSelectedRestoreMessage": "Hanya kategori di bawah yang akan dipulihkan. Kategori yang tidak disertakan dalam backup ini tidak akan berubah. Pemulihan dapat mengganti data yang ada dan tidak dapat dibatalkan.",
"backupContentsSettings": "App settings",
"trackLyricsEmbedded": "Lirik berhasil disematkan",
"optionsUpdateChannelPreview": "Dapatkan rilis preview",
+114 -37
View File
@@ -28,25 +28,43 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
bool _isExporting = false;
bool _isImporting = false;
bool _includeSecrets = false;
bool _includeSettings = true;
bool _includeHistory = true;
bool _includeCollections = true;
bool _includeExtensions = true;
bool get _isBusy => _isExporting || _isImporting;
bool get _hasSelection =>
_includeSettings ||
_includeHistory ||
_includeCollections ||
_includeExtensions;
Future<void> _createBackup() async {
if (_isBusy) return;
if (_isBusy || !_hasSelection) return;
setState(() => _isExporting = true);
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
try {
final settings = ref.read(settingsProvider).toJson();
final collectionsNotifier = ref.read(libraryCollectionsProvider.notifier);
final collections = await collectionsNotifier.exportCollections();
final covers = await collectionsNotifier.exportPlaylistCoverFiles();
final extensions = await ref
.read(extensionProvider.notifier)
.exportBackup(includeSecrets: _includeSecrets);
final settings = _includeSettings
? ref.read(settingsProvider).toJson()
: null;
var collections = <String, dynamic>{};
var covers = <String, Map<String, String>>{};
if (_includeCollections) {
final notifier = ref.read(libraryCollectionsProvider.notifier);
collections = await notifier.exportCollections();
covers = await notifier.exportPlaylistCoverFiles();
}
final extensions = _includeExtensions
? await ref
.read(extensionProvider.notifier)
.exportBackup(includeSecrets: _includeSecrets)
: <String, dynamic>{};
final file = await BackupService.writeBackupArchive(
settings: settings,
includeHistory: _includeHistory,
loadHistoryPage: (limit, offset) =>
HistoryDatabase.instance.getAll(limit: limit, offset: offset),
collections: collections,
@@ -111,15 +129,19 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
.read(settingsProvider.notifier)
.restoreFromBackup(bundle.settings!);
}
await ref
.read(downloadHistoryProvider.notifier)
.restoreFromBackupStream(bundle.streamHistory());
await ref
.read(libraryCollectionsProvider.notifier)
.restoreFromBackup(
bundle.collections,
coverImages: bundle.playlistCovers,
);
if (bundle.hasHistory) {
await ref
.read(downloadHistoryProvider.notifier)
.restoreFromBackupStream(bundle.streamHistory());
}
if (bundle.hasCollections) {
await ref
.read(libraryCollectionsProvider.notifier)
.restoreFromBackup(
bundle.collections,
coverImages: bundle.playlistCovers,
);
}
ExtensionRestoreResult? extResult;
if (bundle.hasExtensions) {
@@ -164,7 +186,7 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.backupRestoreConfirmMessage),
Text(l10n.backupSelectedRestoreMessage),
const SizedBox(height: 16),
Text(
l10n.backupContentsTitle,
@@ -178,22 +200,26 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
icon: Icons.settings_outlined,
label: l10n.backupContentsSettings,
),
_ContentRow(
icon: Icons.history,
label: l10n.backupContentsHistory(bundle.historyCount),
),
_ContentRow(
icon: Icons.favorite_outline,
label: l10n.backupContentsLiked(bundle.likedCount),
),
_ContentRow(
icon: Icons.bookmark_outline,
label: l10n.backupContentsWishlist(bundle.wishlistCount),
),
_ContentRow(
icon: Icons.queue_music_outlined,
label: l10n.backupContentsPlaylists(bundle.playlistCount),
),
if (bundle.hasHistory)
_ContentRow(
icon: Icons.history,
label: l10n.backupContentsHistory(bundle.historyCount),
),
if (bundle.hasCollections)
_ContentRow(
icon: Icons.favorite_outline,
label: l10n.backupContentsLiked(bundle.likedCount),
),
if (bundle.hasCollections)
_ContentRow(
icon: Icons.bookmark_outline,
label: l10n.backupContentsWishlist(bundle.wishlistCount),
),
if (bundle.hasCollections)
_ContentRow(
icon: Icons.queue_music_outlined,
label: l10n.backupContentsPlaylists(bundle.playlistCount),
),
if (bundle.favoriteArtistCount > 0)
_ContentRow(
icon: Icons.person_outline,
@@ -235,20 +261,71 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.settings_outlined,
title: l10n.backupSettingsOnly,
onTap: _isBusy
? null
: () => setState(() {
_includeSettings = true;
_includeHistory = false;
_includeCollections = false;
_includeExtensions = false;
_includeSecrets = false;
}),
),
SettingsSwitchItem(
icon: Icons.settings_outlined,
title: l10n.backupContentsSettings,
value: _includeSettings,
onChanged: _isBusy
? null
: (value) => setState(() => _includeSettings = value),
),
SettingsSwitchItem(
icon: Icons.history,
title: l10n.backupSelectHistory,
value: _includeHistory,
onChanged: _isBusy
? null
: (value) => setState(() => _includeHistory = value),
),
SettingsSwitchItem(
icon: Icons.library_music_outlined,
title: l10n.backupSelectCollections,
subtitle: l10n.backupSelectCollectionsDescription,
value: _includeCollections,
onChanged: _isBusy
? null
: (value) => setState(() => _includeCollections = value),
),
SettingsSwitchItem(
icon: Icons.extension_outlined,
title: l10n.backupSelectExtensions,
value: _includeExtensions,
onChanged: _isBusy
? null
: (value) => setState(() {
_includeExtensions = value;
if (!value) _includeSecrets = false;
}),
),
SettingsSwitchItem(
icon: Icons.vpn_key_outlined,
title: l10n.backupIncludeSecrets,
subtitle: l10n.backupIncludeSecretsDescription,
value: _includeSecrets,
onChanged: _isBusy
onChanged: _isBusy || !_includeExtensions
? null
: (value) => setState(() => _includeSecrets = value),
),
SettingsItem(
icon: Icons.ios_share,
title: l10n.backupExportButton,
subtitle: l10n.backupExportSectionDescription,
onTap: _isBusy ? null : _createBackup,
subtitle: _hasSelection
? l10n.backupSelectedExportDescription
: l10n.backupSelectAtLeastOne,
onTap: _isBusy || !_hasSelection ? null : _createBackup,
trailing: _isExporting
? const SizedBox(
width: 20,
+33 -16
View File
@@ -10,7 +10,7 @@ import 'package:spotiflac_android/utils/logger.dart';
typedef BackupHistoryPageLoader =
Future<List<Map<String, dynamic>>> Function(int limit, int offset);
/// Parsed contents of a backup file. Version 2 keeps large history and cover
/// Parsed contents of a backup file. ZIP backups keep large history and cover
/// payloads on disk until restore consumes them.
class BackupBundle {
final int formatVersion;
@@ -21,6 +21,7 @@ class BackupBundle {
final Map<String, dynamic> collections;
final Map<String, dynamic> playlistCovers;
final Map<String, dynamic> extensions;
final bool hasHistory;
final String? _historyNdjsonPath;
final int? _historyCount;
final String? _temporaryDirectoryPath;
@@ -34,6 +35,7 @@ class BackupBundle {
required this.collections,
required this.playlistCovers,
required this.extensions,
this.hasHistory = true,
String? historyNdjsonPath,
int? historyCount,
String? temporaryDirectoryPath,
@@ -42,6 +44,7 @@ class BackupBundle {
_temporaryDirectoryPath = temporaryDirectoryPath;
bool get hasSettings => settings != null && settings!.isNotEmpty;
bool get hasCollections => collections.isNotEmpty;
int get historyCount => _historyCount ?? history.length;
Stream<Map<String, dynamic>> streamHistory() async* {
@@ -97,7 +100,9 @@ class BackupService {
static final _log = AppLogger('BackupService');
static const String magic = 'spotiflac-backup';
static const int formatVersion = 2;
// V3 permits omitted categories. Older readers reject it instead of treating
// an omitted history as an empty history and clearing existing downloads.
static const int formatVersion = 3;
static const String fileExtension = 'sflb';
static const int _historyPageSize = 500;
static const int _maxMetadataBytes = 8 << 20;
@@ -141,6 +146,7 @@ class BackupService {
required Map<String, dynamic> extensions,
Directory? outputDirectory,
Directory? temporaryDirectory,
bool includeHistory = true,
}) async {
final output = await _newBackupFile(outputDirectory);
final tempRoot = temporaryDirectory ?? await getTemporaryDirectory();
@@ -160,7 +166,7 @@ class BackupService {
var offset = 0;
final historySink = historyFile.openWrite();
try {
while (true) {
while (includeHistory) {
final page = await loadHistoryPage(_historyPageSize, offset);
for (final item in page) {
historySink.writeln(jsonEncode(item));
@@ -194,10 +200,10 @@ class BackupService {
'created_at': DateTime.now().toIso8601String(),
'history_count': historyCount,
'data': {
'settings': settings,
'collections': collections,
'playlist_covers': coverManifest,
'extensions': extensions,
'settings': ?settings,
if (collections.isNotEmpty) 'collections': collections,
if (coverManifest.isNotEmpty) 'playlist_covers': coverManifest,
if (extensions.isNotEmpty) 'extensions': extensions,
},
};
await metadataFile.writeAsString(jsonEncode(metadata), flush: true);
@@ -205,7 +211,9 @@ class BackupService {
if (await partFile.exists()) await partFile.delete();
encoder = ZipFileEncoder()..create(partFile.path);
await encoder.addFile(metadataFile, 'metadata.json');
await encoder.addFile(historyFile, 'history.ndjson');
if (includeHistory) {
await encoder.addFile(historyFile, 'history.ndjson');
}
for (final entry in coverManifest.entries) {
final sourcePath = playlistCoverFiles[entry.key]?['path'];
if (sourcePath == null) continue;
@@ -289,14 +297,18 @@ class BackupService {
final historyEntry = archive.find('history.ndjson');
if (metadataEntry == null ||
metadataEntry.size > _maxMetadataBytes ||
historyEntry == null ||
historyEntry.size > _maxHistoryBytes) {
(historyEntry != null && historyEntry.size > _maxHistoryBytes)) {
return null;
}
final rootRaw = jsonDecode(utf8.decode(metadataEntry.content));
if (rootRaw is! Map) return null;
final root = Map<String, dynamic>.from(rootRaw);
if (root['magic'] != magic || root['format_version'] != formatVersion) {
final version = root['format_version'];
if (root['magic'] != magic ||
(version != 2 && version != formatVersion)) {
return null;
}
if (version == 2 && historyEntry == null) {
return null;
}
final dataRaw = root['data'];
@@ -310,10 +322,13 @@ class BackupService {
'spotiflac_restore_${DateTime.now().microsecondsSinceEpoch}',
),
).create(recursive: true);
final historyPath = p.join(extractionDir.path, 'history.ndjson');
final historyOutput = OutputFileStream(historyPath);
historyEntry.writeContent(historyOutput);
historyOutput.closeSync();
String? historyPath;
if (historyEntry != null) {
historyPath = p.join(extractionDir.path, 'history.ndjson');
final historyOutput = OutputFileStream(historyPath);
historyEntry.writeContent(historyOutput);
historyOutput.closeSync();
}
final restoredCovers = <String, dynamic>{};
final coverManifest = data['playlist_covers'];
@@ -350,11 +365,12 @@ class BackupService {
}
return BackupBundle(
formatVersion: formatVersion,
formatVersion: version as int,
appVersion: root['app_version'] as String? ?? '',
createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''),
settings: _mapOrNull(data['settings']),
history: const [],
hasHistory: historyEntry != null,
historyNdjsonPath: historyPath,
historyCount: (root['history_count'] as num?)?.toInt() ?? 0,
collections: _mapOrEmpty(data['collections']),
@@ -399,6 +415,7 @@ class BackupService {
createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''),
settings: _mapOrNull(data['settings']),
history: history,
hasHistory: data['history'] is List,
collections: _mapOrEmpty(data['collections']),
playlistCovers: _mapOrEmpty(data['playlist_covers']),
extensions: _mapOrEmpty(data['extensions']),