mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
refactor(collections): move playlist cover file I/O into PlaylistCoverStore
LibraryCollectionsNotifier carried about 130 lines of File and Directory work for playlist covers. PlaylistCoverStore now owns the playlist_covers directory, the <playlistId>.jpg normalization with its temp file, the backup restore copy or base64 write, and the delete helpers. The directory is still resolved through getApplicationSupportDirectory on each call, at the same points as before. The notifier keeps setPlaylistCover, removePlaylistCover, exportPlaylistCoverFiles and restoreFromBackup with unchanged signatures, mutation ordering and error handling: a failed restore of one cover still drops that cover path, removePlaylistCover still propagates delete errors, and replacing a cover still deletes the old file quietly.
This commit is contained in:
1 parent
8fffb9230a
commit
857c2954d8
2 files changed
+177
-133
No files matched your search
@@ -1,23 +1,18 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/models/library_collections.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_hydration.dart';
|
||||
import 'package:spotiflac_android/services/collection_track_batch.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/services/playlist_cover_store.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
export 'package:spotiflac_android/models/library_collections.dart';
|
||||
|
||||
const _playlistCoverMaxDimension = 1024;
|
||||
const _playlistCoverMaxStoredBytes = 2 * 1024 * 1024;
|
||||
final _log = AppLogger('LibraryCollections');
|
||||
|
||||
class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
@@ -25,6 +20,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
: _db = database ?? LibraryCollectionsDatabase.instance;
|
||||
|
||||
final LibraryCollectionsDatabase _db;
|
||||
final PlaylistCoverStore _coverStore = const PlaylistCoverStore();
|
||||
Future<void>? _loadFuture;
|
||||
final Map<String, Future<void>> _playlistLoadFutures = {};
|
||||
Future<void> _mutationTail = Future<void>.value();
|
||||
@@ -650,107 +646,31 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Directory> _playlistCoversDir() async {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dir = Directory(p.join(appDir.path, 'playlist_covers'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
Future<void> setPlaylistCover(String playlistId, String sourceFilePath) =>
|
||||
_mutate(() async {
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null) return;
|
||||
|
||||
final previousCoverPath = playlist.coverImagePath;
|
||||
final destPath = await _normalizePlaylistCoverFile(
|
||||
playlistId,
|
||||
sourceFilePath,
|
||||
);
|
||||
|
||||
final now = DateTime.now();
|
||||
await _db.updatePlaylistCover(
|
||||
playlistId: playlistId,
|
||||
coverImagePath: destPath,
|
||||
updatedAt: now.toIso8601String(),
|
||||
);
|
||||
_replacePlaylistById(playlistId, (playlist) {
|
||||
if (playlist.coverImagePath == destPath) return playlist;
|
||||
return playlist.copyWith(
|
||||
coverImagePath: () => destPath,
|
||||
updatedAt: now,
|
||||
);
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
if (previousCoverPath != null && previousCoverPath != destPath) {
|
||||
await deleteFileQuietly(File(previousCoverPath));
|
||||
}
|
||||
});
|
||||
|
||||
Future<String> _normalizePlaylistCoverFile(
|
||||
Future<void> setPlaylistCover(
|
||||
String playlistId,
|
||||
String sourceFilePath,
|
||||
) async {
|
||||
final coversDir = await _playlistCoversDir();
|
||||
final destPath = p.join(coversDir.path, '$playlistId.jpg');
|
||||
final tempPath = p.join(
|
||||
coversDir.path,
|
||||
'.$playlistId.${DateTime.now().microsecondsSinceEpoch}.jpg',
|
||||
);
|
||||
try {
|
||||
final dimensions = await FFmpegService.probeImageDimensions(
|
||||
sourceFilePath,
|
||||
);
|
||||
final longestEdge = dimensions == null
|
||||
? _playlistCoverMaxDimension
|
||||
: (dimensions.width > dimensions.height
|
||||
? dimensions.width
|
||||
: dimensions.height);
|
||||
var targetDimension = longestEdge
|
||||
.clamp(64, _playlistCoverMaxDimension)
|
||||
.toInt();
|
||||
while (true) {
|
||||
final resized = await FFmpegService.resizeCoverArt(
|
||||
inputPath: sourceFilePath,
|
||||
outputPath: tempPath,
|
||||
maxDimension: targetDimension,
|
||||
);
|
||||
if (!resized) {
|
||||
throw StateError('Unable to normalize playlist cover');
|
||||
}
|
||||
if (await File(tempPath).length() <= _playlistCoverMaxStoredBytes) {
|
||||
break;
|
||||
}
|
||||
if (targetDimension <= 64) {
|
||||
throw StateError('Normalized playlist cover exceeds the size limit');
|
||||
}
|
||||
targetDimension = (targetDimension * 3 ~/ 4)
|
||||
.clamp(64, targetDimension)
|
||||
.toInt();
|
||||
}
|
||||
final destination = File(destPath);
|
||||
if (await destination.exists()) await destination.delete();
|
||||
await File(tempPath).rename(destPath);
|
||||
return destPath;
|
||||
} finally {
|
||||
await deleteFileQuietly(File(tempPath));
|
||||
}
|
||||
}
|
||||
) => _mutate(() async {
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null) return;
|
||||
|
||||
Future<bool> _playlistCoverNeedsNormalization(String path) async {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return false;
|
||||
if (p.extension(path).toLowerCase() != '.jpg' ||
|
||||
await file.length() > _playlistCoverMaxStoredBytes) {
|
||||
return true;
|
||||
final previousCoverPath = playlist.coverImagePath;
|
||||
final destPath = await _coverStore.normalize(playlistId, sourceFilePath);
|
||||
|
||||
final now = DateTime.now();
|
||||
await _db.updatePlaylistCover(
|
||||
playlistId: playlistId,
|
||||
coverImagePath: destPath,
|
||||
updatedAt: now.toIso8601String(),
|
||||
);
|
||||
_replacePlaylistById(playlistId, (playlist) {
|
||||
if (playlist.coverImagePath == destPath) return playlist;
|
||||
return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now);
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
if (previousCoverPath != null && previousCoverPath != destPath) {
|
||||
await _coverStore.deleteQuietly(previousCoverPath);
|
||||
}
|
||||
final dimensions = await FFmpegService.probeImageDimensions(path);
|
||||
return dimensions == null ||
|
||||
dimensions.width > _playlistCoverMaxDimension ||
|
||||
dimensions.height > _playlistCoverMaxDimension;
|
||||
}
|
||||
});
|
||||
|
||||
Future<void> removePlaylistCover(String playlistId) => _mutate(() async {
|
||||
final playlist = state.playlistById(playlistId);
|
||||
@@ -758,10 +678,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
|
||||
final path = playlist.coverImagePath;
|
||||
if (path != null) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
await _coverStore.delete(path);
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -794,12 +711,11 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
var path = playlist.coverImagePath;
|
||||
if (path == null || path.isEmpty) continue;
|
||||
try {
|
||||
if (await _playlistCoverNeedsNormalization(path)) {
|
||||
if (await _coverStore.needsNormalization(path)) {
|
||||
await setPlaylistCover(playlist.id, path);
|
||||
path = state.playlistById(playlist.id)?.coverImagePath ?? path;
|
||||
}
|
||||
final file = File(path);
|
||||
if (!await file.exists() || await file.length() == 0) continue;
|
||||
if (!await _coverStore.hasContent(path)) continue;
|
||||
covers[playlist.id] = {
|
||||
'ext': p.extension(path).toLowerCase(),
|
||||
'path': path,
|
||||
@@ -822,7 +738,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
Map<String, dynamic>? coverImages,
|
||||
}) => _mutate(() async {
|
||||
final normalized = Map<String, dynamic>.from(collectionsJson);
|
||||
final coversDir = await _playlistCoversDir();
|
||||
final coversDir = await _coverStore.coversDirectory();
|
||||
|
||||
final playlistsRaw = normalized['playlists'];
|
||||
if (playlistsRaw is List) {
|
||||
@@ -840,28 +756,13 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final ext = (coverEntry['ext'] as String?) ?? '.jpg';
|
||||
if ((data != null && data.isNotEmpty) ||
|
||||
(restoredPath != null && restoredPath.isNotEmpty)) {
|
||||
try {
|
||||
final sourcePath = p.join(
|
||||
coversDir.path,
|
||||
'.$id.restore${ext.startsWith('.') ? ext : '.$ext'}',
|
||||
);
|
||||
final source = File(sourcePath);
|
||||
if (restoredPath != null && restoredPath.isNotEmpty) {
|
||||
await File(restoredPath).copy(source.path);
|
||||
} else {
|
||||
await source.writeAsBytes(base64Decode(data!), flush: true);
|
||||
}
|
||||
try {
|
||||
newCoverPath = await _normalizePlaylistCoverFile(
|
||||
id,
|
||||
sourcePath,
|
||||
);
|
||||
} finally {
|
||||
await deleteFileQuietly(source);
|
||||
}
|
||||
} catch (_) {
|
||||
newCoverPath = null;
|
||||
}
|
||||
newCoverPath = await _coverStore.restoreFromBackup(
|
||||
coversDir: coversDir,
|
||||
id: id,
|
||||
ext: ext,
|
||||
restoredPath: restoredPath,
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Always replace the backup's device-specific path: either with the
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
|
||||
const _playlistCoverMaxDimension = 1024;
|
||||
const _playlistCoverMaxStoredBytes = 2 * 1024 * 1024;
|
||||
|
||||
/// File storage for user playlist covers under the app support
|
||||
/// `playlist_covers` directory. Covers are stored as `<playlistId>.jpg`,
|
||||
/// normalized to at most [_playlistCoverMaxDimension] pixels and
|
||||
/// [_playlistCoverMaxStoredBytes] bytes.
|
||||
class PlaylistCoverStore {
|
||||
const PlaylistCoverStore({
|
||||
Future<Directory> Function() supportDirectory =
|
||||
getApplicationSupportDirectory,
|
||||
}) : _supportDirectory = supportDirectory;
|
||||
|
||||
final Future<Directory> Function() _supportDirectory;
|
||||
|
||||
/// Resolves the covers directory on every call and creates it when missing.
|
||||
Future<Directory> coversDirectory() async {
|
||||
final appDir = await _supportDirectory();
|
||||
final dir = Directory(p.join(appDir.path, 'playlist_covers'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// Writes a normalized JPEG for [playlistId] from [sourceFilePath] and
|
||||
/// returns its path. Errors propagate; the temporary file is always removed.
|
||||
Future<String> normalize(String playlistId, String sourceFilePath) async {
|
||||
final coversDir = await coversDirectory();
|
||||
final destPath = p.join(coversDir.path, '$playlistId.jpg');
|
||||
final tempPath = p.join(
|
||||
coversDir.path,
|
||||
'.$playlistId.${DateTime.now().microsecondsSinceEpoch}.jpg',
|
||||
);
|
||||
try {
|
||||
final dimensions = await FFmpegService.probeImageDimensions(
|
||||
sourceFilePath,
|
||||
);
|
||||
final longestEdge = dimensions == null
|
||||
? _playlistCoverMaxDimension
|
||||
: (dimensions.width > dimensions.height
|
||||
? dimensions.width
|
||||
: dimensions.height);
|
||||
var targetDimension = longestEdge
|
||||
.clamp(64, _playlistCoverMaxDimension)
|
||||
.toInt();
|
||||
while (true) {
|
||||
final resized = await FFmpegService.resizeCoverArt(
|
||||
inputPath: sourceFilePath,
|
||||
outputPath: tempPath,
|
||||
maxDimension: targetDimension,
|
||||
);
|
||||
if (!resized) {
|
||||
throw StateError('Unable to normalize playlist cover');
|
||||
}
|
||||
if (await File(tempPath).length() <= _playlistCoverMaxStoredBytes) {
|
||||
break;
|
||||
}
|
||||
if (targetDimension <= 64) {
|
||||
throw StateError('Normalized playlist cover exceeds the size limit');
|
||||
}
|
||||
targetDimension = (targetDimension * 3 ~/ 4)
|
||||
.clamp(64, targetDimension)
|
||||
.toInt();
|
||||
}
|
||||
final destination = File(destPath);
|
||||
if (await destination.exists()) await destination.delete();
|
||||
await File(tempPath).rename(destPath);
|
||||
return destPath;
|
||||
} finally {
|
||||
await deleteFileQuietly(File(tempPath));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> needsNormalization(String path) async {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return false;
|
||||
if (p.extension(path).toLowerCase() != '.jpg' ||
|
||||
await file.length() > _playlistCoverMaxStoredBytes) {
|
||||
return true;
|
||||
}
|
||||
final dimensions = await FFmpegService.probeImageDimensions(path);
|
||||
return dimensions == null ||
|
||||
dimensions.width > _playlistCoverMaxDimension ||
|
||||
dimensions.height > _playlistCoverMaxDimension;
|
||||
}
|
||||
|
||||
/// Deletes the cover at [path] when it exists. Errors propagate.
|
||||
Future<void> delete(String path) async {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the cover at [path] when it exists and swallows any error.
|
||||
Future<void> deleteQuietly(String path) => deleteFileQuietly(File(path));
|
||||
|
||||
/// Whether [path] exists and is not empty. Errors propagate.
|
||||
Future<bool> hasContent(String path) async {
|
||||
final file = File(path);
|
||||
return await file.exists() && await file.length() != 0;
|
||||
}
|
||||
|
||||
/// Restores a backup cover for [id] into [coversDir] from either an
|
||||
/// extracted file at [restoredPath] or legacy base64 [data], then
|
||||
/// normalizes it. Returns null when any step fails.
|
||||
Future<String?> restoreFromBackup({
|
||||
required Directory coversDir,
|
||||
required String id,
|
||||
required String ext,
|
||||
String? restoredPath,
|
||||
String? data,
|
||||
}) async {
|
||||
try {
|
||||
final sourcePath = p.join(
|
||||
coversDir.path,
|
||||
'.$id.restore${ext.startsWith('.') ? ext : '.$ext'}',
|
||||
);
|
||||
final source = File(sourcePath);
|
||||
if (restoredPath != null && restoredPath.isNotEmpty) {
|
||||
await File(restoredPath).copy(source.path);
|
||||
} else {
|
||||
await source.writeAsBytes(base64Decode(data!), flush: true);
|
||||
}
|
||||
try {
|
||||
return await normalize(id, sourcePath);
|
||||
} finally {
|
||||
await deleteFileQuietly(source);
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user