mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
Move Mornye Settings to the Home avatar and keep search above the profile card. Center the metadata artwork and actions, and make artist names clickable in the Material player.
517 lines
18 KiB
Dart
517 lines
18 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:isolate';
|
|
|
|
import 'package:archive/archive_io.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:spotiflac_android/constants/app_info.dart';
|
|
import 'package:spotiflac_android/services/user_profile_store.dart';
|
|
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. ZIP backups keep large history and cover
|
|
/// payloads on disk until restore consumes them.
|
|
class BackupBundle {
|
|
final int formatVersion;
|
|
final String appVersion;
|
|
final DateTime? createdAt;
|
|
final Map<String, dynamic>? settings;
|
|
final UserProfile? profile;
|
|
final List<Map<String, dynamic>> history;
|
|
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;
|
|
|
|
const BackupBundle({
|
|
required this.formatVersion,
|
|
required this.appVersion,
|
|
required this.createdAt,
|
|
required this.settings,
|
|
required this.history,
|
|
required this.collections,
|
|
required this.playlistCovers,
|
|
required this.extensions,
|
|
this.profile,
|
|
this.hasHistory = true,
|
|
String? historyNdjsonPath,
|
|
int? historyCount,
|
|
String? temporaryDirectoryPath,
|
|
}) : _historyNdjsonPath = historyNdjsonPath,
|
|
_historyCount = historyCount,
|
|
_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* {
|
|
if (_historyNdjsonPath == null) {
|
|
yield* Stream.fromIterable(history);
|
|
return;
|
|
}
|
|
await for (final line in File(
|
|
_historyNdjsonPath,
|
|
).openRead().transform(utf8.decoder).transform(const LineSplitter())) {
|
|
if (line.trim().isEmpty) continue;
|
|
final decoded = jsonDecode(line);
|
|
if (decoded is Map) yield Map<String, dynamic>.from(decoded);
|
|
}
|
|
}
|
|
|
|
Future<void> cleanup() async {
|
|
final path = _temporaryDirectoryPath;
|
|
if (path == null || path.isEmpty) return;
|
|
try {
|
|
final directory = Directory(path);
|
|
if (await directory.exists()) await directory.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
|
|
int _collectionListCount(String key) {
|
|
final value = collections[key];
|
|
return value is List ? value.length : 0;
|
|
}
|
|
|
|
int get likedCount => _collectionListCount('loved');
|
|
int get wishlistCount => _collectionListCount('wishlist');
|
|
int get playlistCount => _collectionListCount('playlists');
|
|
int get favoriteArtistCount => _collectionListCount('favoriteArtists');
|
|
|
|
int get extensionCount {
|
|
final items = extensions['items'];
|
|
return items is List ? items.length : 0;
|
|
}
|
|
|
|
bool get hasExtensions => extensionCount > 0;
|
|
bool get isEmpty =>
|
|
!hasSettings &&
|
|
profile == null &&
|
|
historyCount == 0 &&
|
|
likedCount == 0 &&
|
|
wishlistCount == 0 &&
|
|
playlistCount == 0 &&
|
|
favoriteArtistCount == 0 &&
|
|
extensionCount == 0;
|
|
}
|
|
|
|
class BackupService {
|
|
static final _log = AppLogger('BackupService');
|
|
|
|
static const String magic = 'spotiflac-backup';
|
|
// 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;
|
|
static const int _maxHistoryBytes = 512 << 20;
|
|
static const int _maxCoverBytes = 20 << 20;
|
|
static const int _maxAllCoversBytes = 256 << 20;
|
|
static const int _maxProfilePhotoBytes = 2 << 20;
|
|
static const String _profilePhotoEntry = 'profile/avatar.png';
|
|
|
|
static String encode(Map<String, dynamic> envelope) =>
|
|
const JsonEncoder.withIndent(' ').convert(envelope);
|
|
|
|
/// Legacy JSON envelope retained so backups from earlier releases and unit
|
|
/// tests remain readable. New backups are written by [writeBackupArchive].
|
|
static Map<String, dynamic> buildEnvelope({
|
|
required Map<String, dynamic>? settings,
|
|
required List<Map<String, dynamic>> history,
|
|
required Map<String, dynamic> collections,
|
|
required Map<String, dynamic> playlistCovers,
|
|
required Map<String, dynamic> extensions,
|
|
}) {
|
|
return {
|
|
'magic': magic,
|
|
'format_version': 1,
|
|
'app': 'SpotiFLAC Mobile',
|
|
'app_version': AppInfo.displayVersion,
|
|
'created_at': DateTime.now().toIso8601String(),
|
|
'data': {
|
|
'settings': settings,
|
|
'history': history,
|
|
'collections': collections,
|
|
'playlist_covers': playlistCovers,
|
|
'extensions': extensions,
|
|
},
|
|
};
|
|
}
|
|
|
|
static Future<File> writeBackupArchive({
|
|
required Map<String, dynamic>? settings,
|
|
required BackupHistoryPageLoader loadHistoryPage,
|
|
required Map<String, dynamic> collections,
|
|
required Map<String, Map<String, String>> playlistCoverFiles,
|
|
required Map<String, dynamic> extensions,
|
|
Directory? outputDirectory,
|
|
Directory? temporaryDirectory,
|
|
bool includeHistory = true,
|
|
UserProfile? profile,
|
|
}) async {
|
|
final output = await _newBackupFile(outputDirectory);
|
|
final tempRoot = temporaryDirectory ?? await getTemporaryDirectory();
|
|
final staging = await Directory(
|
|
p.join(
|
|
tempRoot.path,
|
|
'spotiflac_backup_${DateTime.now().microsecondsSinceEpoch}',
|
|
),
|
|
).create(recursive: true);
|
|
final historyFile = File(p.join(staging.path, 'history.ndjson'));
|
|
final metadataFile = File(p.join(staging.path, 'metadata.json'));
|
|
final partFile = File('${output.path}.part');
|
|
|
|
try {
|
|
var historyCount = 0;
|
|
var offset = 0;
|
|
final historySink = historyFile.openWrite();
|
|
try {
|
|
while (includeHistory) {
|
|
final page = await loadHistoryPage(_historyPageSize, offset);
|
|
for (final item in page) {
|
|
historySink.writeln(jsonEncode(item));
|
|
}
|
|
await historySink.flush();
|
|
historyCount += page.length;
|
|
offset += page.length;
|
|
if (page.length < _historyPageSize) break;
|
|
}
|
|
await historySink.flush();
|
|
} finally {
|
|
await historySink.close();
|
|
}
|
|
|
|
final coverManifest = <String, Map<String, String>>{};
|
|
var coverIndex = 0;
|
|
for (final entry in playlistCoverFiles.entries) {
|
|
final sourcePath = entry.value['path'] ?? '';
|
|
final source = File(sourcePath);
|
|
if (sourcePath.isEmpty || !await source.exists()) continue;
|
|
var ext = (entry.value['ext'] ?? p.extension(sourcePath)).toLowerCase();
|
|
if (!RegExp(r'^\.[a-z0-9]{1,8}$').hasMatch(ext)) ext = '.jpg';
|
|
final archiveName = 'covers/cover_${coverIndex++}$ext';
|
|
coverManifest[entry.key] = {'ext': ext, 'file': archiveName};
|
|
}
|
|
|
|
final photoPath = profile?.photoPath;
|
|
if (photoPath != null &&
|
|
await File(photoPath).length() > _maxProfilePhotoBytes) {
|
|
throw const FormatException('Profile photo is too large');
|
|
}
|
|
final metadata = {
|
|
'magic': magic,
|
|
'format_version': formatVersion,
|
|
'app': 'SpotiFLAC Mobile',
|
|
'app_version': AppInfo.displayVersion,
|
|
'created_at': DateTime.now().toIso8601String(),
|
|
'history_count': historyCount,
|
|
'data': {
|
|
'settings': ?settings,
|
|
if (profile != null)
|
|
'profile': {
|
|
'name': profile.name,
|
|
if (photoPath != null) 'photo': _profilePhotoEntry,
|
|
},
|
|
if (collections.isNotEmpty) 'collections': collections,
|
|
if (coverManifest.isNotEmpty) 'playlist_covers': coverManifest,
|
|
if (extensions.isNotEmpty) 'extensions': extensions,
|
|
},
|
|
};
|
|
await metadataFile.writeAsString(jsonEncode(metadata), flush: true);
|
|
|
|
if (await partFile.exists()) await partFile.delete();
|
|
final archiveFiles = <({String path, String name, bool store})>[
|
|
(path: metadataFile.path, name: 'metadata.json', store: false),
|
|
if (includeHistory)
|
|
(path: historyFile.path, name: 'history.ndjson', store: false),
|
|
if (photoPath != null)
|
|
(path: photoPath, name: _profilePhotoEntry, store: true),
|
|
];
|
|
for (final entry in coverManifest.entries) {
|
|
final sourcePath = playlistCoverFiles[entry.key]?['path'];
|
|
if (sourcePath == null) continue;
|
|
archiveFiles.add((
|
|
path: sourcePath,
|
|
name: entry.value['file']!,
|
|
store: true,
|
|
));
|
|
}
|
|
await _encodeArchiveInBackground(partFile.path, archiveFiles);
|
|
if (await output.exists()) await output.delete();
|
|
await partFile.rename(output.path);
|
|
_log.i('Streaming backup written to ${output.path}');
|
|
return output;
|
|
} finally {
|
|
try {
|
|
if (await staging.exists()) await staging.delete(recursive: true);
|
|
} catch (_) {}
|
|
try {
|
|
if (await partFile.exists()) await partFile.delete();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
// archive's file-backed encoder still performs compression and writes
|
|
// synchronously. Pass only paths to the worker; database/plugin callbacks
|
|
// and the paged history loader stay on their owning isolate.
|
|
static Future<void> _encodeArchiveInBackground(
|
|
String outputPath,
|
|
List<({String path, String name, bool store})> files,
|
|
) => Isolate.run(() async {
|
|
final encoder = ZipFileEncoder()..create(outputPath);
|
|
try {
|
|
for (final file in files) {
|
|
await encoder.addFile(
|
|
File(file.path),
|
|
file.name,
|
|
file.store ? ZipFileEncoder.store : null,
|
|
);
|
|
}
|
|
} finally {
|
|
await encoder.close();
|
|
}
|
|
});
|
|
|
|
static Future<File> _newBackupFile([Directory? outputDirectory]) async {
|
|
final dir = outputDirectory ?? await getApplicationDocumentsDirectory();
|
|
final backupsDir =
|
|
outputDirectory ?? Directory(p.join(dir.path, 'backups'));
|
|
await backupsDir.create(recursive: true);
|
|
final now = DateTime.now();
|
|
String two(int value) => value.toString().padLeft(2, '0');
|
|
final stamp =
|
|
'${now.year}${two(now.month)}${two(now.day)}_'
|
|
'${two(now.hour)}${two(now.minute)}${two(now.second)}';
|
|
return File(
|
|
p.join(backupsDir.path, 'spotiflac_mobile_backup_$stamp.$fileExtension'),
|
|
);
|
|
}
|
|
|
|
/// Legacy JSON writer retained for compatibility with callers outside the
|
|
/// settings UI. It no longer defines the default backup format.
|
|
static Future<File> writeBackupFile(Map<String, dynamic> envelope) async {
|
|
final output = await _newBackupFile();
|
|
await output.writeAsString(encode(envelope), flush: true);
|
|
return output;
|
|
}
|
|
|
|
static Future<BackupBundle?> parseFile(
|
|
String path, {
|
|
Directory? temporaryDirectory,
|
|
}) async {
|
|
final tempRoot = temporaryDirectory ?? await getTemporaryDirectory();
|
|
final bundle = await _parseFileInBackground(path, tempRoot.path);
|
|
if (bundle == null) {
|
|
_log.w('Backup file could not be read: invalid or unsupported contents');
|
|
}
|
|
return bundle;
|
|
}
|
|
|
|
static Future<BackupBundle?> _parseFileInBackground(
|
|
String path,
|
|
String temporaryPath,
|
|
) => Isolate.run(() => _parseFile(path, temporaryPath));
|
|
|
|
static Future<BackupBundle?> _parseFile(
|
|
String path,
|
|
String temporaryPath,
|
|
) async {
|
|
final file = File(path);
|
|
final header = await file
|
|
.openRead(0, 4)
|
|
.fold<List<int>>(<int>[], (bytes, chunk) => bytes..addAll(chunk));
|
|
final isZip =
|
|
header.length == 4 &&
|
|
header[0] == 0x50 &&
|
|
header[1] == 0x4b &&
|
|
header[2] == 0x03 &&
|
|
header[3] == 0x04;
|
|
return isZip
|
|
? _parseArchive(file, temporaryDirectory: Directory(temporaryPath))
|
|
: parse(await file.readAsString());
|
|
}
|
|
|
|
static Future<BackupBundle?> _parseArchive(
|
|
File file, {
|
|
required Directory temporaryDirectory,
|
|
}) async {
|
|
InputFileStream? input;
|
|
Archive? archive;
|
|
Directory? extractionDir;
|
|
try {
|
|
input = InputFileStream(file.path);
|
|
archive = ZipDecoder().decodeStream(input);
|
|
final metadataEntry = archive.find('metadata.json');
|
|
final historyEntry = archive.find('history.ndjson');
|
|
if (metadataEntry == null ||
|
|
metadataEntry.size > _maxMetadataBytes ||
|
|
(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);
|
|
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'];
|
|
if (dataRaw is! Map) return null;
|
|
final data = Map<String, dynamic>.from(dataRaw);
|
|
|
|
extractionDir = await Directory(
|
|
p.join(
|
|
temporaryDirectory.path,
|
|
'spotiflac_restore_${DateTime.now().microsecondsSinceEpoch}',
|
|
),
|
|
).create(recursive: true);
|
|
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'];
|
|
if (coverManifest is Map) {
|
|
var index = 0;
|
|
var extractedCoverBytes = 0;
|
|
for (final manifestEntry in coverManifest.entries) {
|
|
if (manifestEntry.value is! Map) continue;
|
|
final cover = Map<String, dynamic>.from(manifestEntry.value as Map);
|
|
final archiveName = cover['file']?.toString() ?? '';
|
|
if (!archiveName.startsWith('covers/') ||
|
|
archiveName.contains('..')) {
|
|
continue;
|
|
}
|
|
final archiveEntry = archive.find(archiveName);
|
|
if (archiveEntry == null || archiveEntry.size > _maxCoverBytes) {
|
|
continue;
|
|
}
|
|
if (extractedCoverBytes + archiveEntry.size > _maxAllCoversBytes) {
|
|
break;
|
|
}
|
|
var ext = cover['ext']?.toString() ?? '.jpg';
|
|
if (!RegExp(r'^\.[a-z0-9]{1,8}$').hasMatch(ext)) ext = '.jpg';
|
|
final coverPath = p.join(extractionDir.path, 'cover_${index++}$ext');
|
|
final output = OutputFileStream(coverPath);
|
|
archiveEntry.writeContent(output);
|
|
output.closeSync();
|
|
extractedCoverBytes += archiveEntry.size;
|
|
restoredCovers[manifestEntry.key.toString()] = {
|
|
'ext': ext,
|
|
'path': coverPath,
|
|
};
|
|
}
|
|
}
|
|
|
|
UserProfile? profile;
|
|
if (data.containsKey('profile')) {
|
|
final rawProfile = data['profile'];
|
|
if (rawProfile is! Map || rawProfile['name'] is! String) {
|
|
throw const FormatException('Invalid profile');
|
|
}
|
|
String? photoPath;
|
|
if (rawProfile['photo'] != null) {
|
|
if (rawProfile['photo'] != _profilePhotoEntry) {
|
|
throw const FormatException('Invalid profile photo entry');
|
|
}
|
|
final photoEntry = archive.find(_profilePhotoEntry);
|
|
if (photoEntry == null ||
|
|
!photoEntry.isFile ||
|
|
photoEntry.size > _maxProfilePhotoBytes) {
|
|
throw const FormatException('Missing or oversized profile photo');
|
|
}
|
|
photoPath = p.join(extractionDir.path, 'profile.png');
|
|
final output = OutputFileStream(photoPath);
|
|
try {
|
|
photoEntry.writeContent(output);
|
|
} finally {
|
|
output.closeSync();
|
|
}
|
|
}
|
|
profile = UserProfile(
|
|
name: rawProfile['name'] as String,
|
|
photoPath: photoPath,
|
|
);
|
|
}
|
|
|
|
return BackupBundle(
|
|
formatVersion: version as int,
|
|
appVersion: root['app_version'] as String? ?? '',
|
|
createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''),
|
|
settings: _mapOrNull(data['settings']),
|
|
profile: profile,
|
|
history: const [],
|
|
hasHistory: historyEntry != null,
|
|
historyNdjsonPath: historyPath,
|
|
historyCount: (root['history_count'] as num?)?.toInt() ?? 0,
|
|
collections: _mapOrEmpty(data['collections']),
|
|
playlistCovers: restoredCovers,
|
|
extensions: _mapOrEmpty(data['extensions']),
|
|
temporaryDirectoryPath: extractionDir.path,
|
|
);
|
|
} catch (e) {
|
|
_log.w('Backup archive parse failed: $e');
|
|
if (extractionDir != null) {
|
|
try {
|
|
await extractionDir.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
return null;
|
|
} finally {
|
|
if (input != null) await input.close();
|
|
}
|
|
}
|
|
|
|
static BackupBundle? parse(String content) {
|
|
dynamic decoded;
|
|
try {
|
|
decoded = jsonDecode(content);
|
|
} catch (e) {
|
|
_log.w('Backup parse failed: not valid JSON ($e)');
|
|
return null;
|
|
}
|
|
if (decoded is! Map) return null;
|
|
final root = Map<String, dynamic>.from(decoded);
|
|
if (root['magic'] != magic || root['data'] is! Map) return null;
|
|
final data = Map<String, dynamic>.from(root['data'] as Map);
|
|
final history = <Map<String, dynamic>>[];
|
|
if (data['history'] is List) {
|
|
for (final item in data['history'] as List) {
|
|
if (item is Map) history.add(Map<String, dynamic>.from(item));
|
|
}
|
|
}
|
|
return BackupBundle(
|
|
formatVersion: (root['format_version'] as num?)?.toInt() ?? 1,
|
|
appVersion: root['app_version'] as String? ?? '',
|
|
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']),
|
|
);
|
|
}
|
|
|
|
static Map<String, dynamic>? _mapOrNull(dynamic value) =>
|
|
value is Map ? Map<String, dynamic>.from(value) : null;
|
|
|
|
static Map<String, dynamic> _mapOrEmpty(dynamic value) =>
|
|
value is Map ? Map<String, dynamic>.from(value) : <String, dynamic>{};
|
|
}
|