feat: compact media library sqlite storage
This commit is contained in:
@@ -22,7 +22,7 @@ class MediaLibraryStore {
|
||||
);
|
||||
_database = await openDatabase(
|
||||
databasePath,
|
||||
version: 2,
|
||||
version: 3,
|
||||
onCreate: (db, _) async {
|
||||
await _createSchema(db);
|
||||
},
|
||||
@@ -198,7 +198,7 @@ class MediaLibraryStore {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> importBackup(String backupPath) async {
|
||||
Future<MediaLibraryStorageStats> importBackup(String backupPath) async {
|
||||
final db = await _db;
|
||||
await db.execute('ATTACH DATABASE ? AS imported_backup', [backupPath]);
|
||||
try {
|
||||
@@ -216,6 +216,26 @@ class MediaLibraryStore {
|
||||
} finally {
|
||||
await db.execute('DETACH DATABASE imported_backup');
|
||||
}
|
||||
return optimizeStorage();
|
||||
}
|
||||
|
||||
/// Reclaims space used by imported backdrop BLOBs. Posters remain available
|
||||
/// for offline poster walls; backdrops can always be fetched from TMDB again.
|
||||
Future<MediaLibraryStorageStats> optimizeStorage() async {
|
||||
final db = await _db;
|
||||
final before = await _databaseBytes(db.path);
|
||||
final removedBackdrops = await db.rawUpdate(
|
||||
'UPDATE media_items SET backdrop = NULL WHERE backdrop IS NOT NULL',
|
||||
);
|
||||
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
await db.execute('PRAGMA optimize');
|
||||
await db.execute('VACUUM');
|
||||
final after = await _databaseBytes(db.path);
|
||||
return MediaLibraryStorageStats(
|
||||
beforeBytes: before,
|
||||
afterBytes: after,
|
||||
removedBackdropCount: removedBackdrops,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> exportBackupTo(String destinationPath) async {
|
||||
@@ -477,6 +497,17 @@ class MediaLibraryStore {
|
||||
children_json TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_media_items_library_title '
|
||||
'ON media_items(library_id, title COLLATE NOCASE)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_media_items_tmdb_id '
|
||||
'ON media_items(tmdb_id)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_file_index_gcid ON file_index(gcid)',
|
||||
);
|
||||
}
|
||||
|
||||
MediaLibraryItem _itemFromRow(Map<String, Object?> row) {
|
||||
@@ -571,4 +602,27 @@ class MediaLibraryStore {
|
||||
'updated_at',
|
||||
];
|
||||
static String _folderID(String? folderID) => folderID ?? _rootFolderID;
|
||||
|
||||
Future<int> _databaseBytes(String databasePath) async {
|
||||
var bytes = 0;
|
||||
for (final suffix in const ['', '-wal', '-shm']) {
|
||||
final file = File('$databasePath$suffix');
|
||||
if (await file.exists()) bytes += await file.length();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
class MediaLibraryStorageStats {
|
||||
final int beforeBytes;
|
||||
final int afterBytes;
|
||||
final int removedBackdropCount;
|
||||
|
||||
const MediaLibraryStorageStats({
|
||||
required this.beforeBytes,
|
||||
required this.afterBytes,
|
||||
required this.removedBackdropCount,
|
||||
});
|
||||
|
||||
int get reclaimedBytes => (beforeBytes - afterBytes).clamp(0, beforeBytes);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,14 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
child: const Text('导入数据'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
onPressed: _backupBusy || state.isScanning
|
||||
? null
|
||||
: _optimizeScrapedStorage,
|
||||
leading: const Icon(Icons.cleaning_services_rounded, size: 16),
|
||||
child: const Text('数据库瘦身'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
onPressed: state.selectedLibrary == null || state.isScanning
|
||||
? null
|
||||
@@ -785,6 +793,15 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _optimizeScrapedStorage() async {
|
||||
setState(() => _backupBusy = true);
|
||||
try {
|
||||
await ref.read(mediaLibraryProvider.notifier).optimizeLocalStorage();
|
||||
} finally {
|
||||
if (mounted) setState(() => _backupBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchTMDB(String query) async {
|
||||
final text = query.trim();
|
||||
if (text.isEmpty || _tmdbApiKey.isEmpty) return;
|
||||
|
||||
@@ -207,7 +207,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
clearStatus: true,
|
||||
);
|
||||
try {
|
||||
await _store.importBackup(backupPath);
|
||||
final stats = await _store.importBackup(backupPath);
|
||||
final libraries = await _loadLibraries();
|
||||
final selectedID = libraries.isEmpty ? null : libraries.first.id;
|
||||
state = state.copyWith(
|
||||
@@ -215,7 +215,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
selectedLibraryID: selectedID,
|
||||
clearSelectedLibrary: selectedID == null,
|
||||
items: selectedID == null ? const [] : await _loadItems(selectedID),
|
||||
statusMessage: '刮削数据已导入',
|
||||
statusMessage: '刮削数据已导入,已回收 ${_formatBytes(stats.reclaimedBytes)}',
|
||||
);
|
||||
} catch (error) {
|
||||
state = state.copyWith(errorMessage: '导入失败:$error');
|
||||
@@ -241,6 +241,27 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> optimizeLocalStorage() async {
|
||||
if (state.isLoading || state.isScanning) return;
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
clearError: true,
|
||||
clearStatus: true,
|
||||
);
|
||||
try {
|
||||
final stats = await _store.optimizeStorage();
|
||||
state = state.copyWith(
|
||||
statusMessage: stats.removedBackdropCount == 0
|
||||
? '本地刮削数据库已是最优状态'
|
||||
: '已清理 ${stats.removedBackdropCount} 张背景图,回收 ${_formatBytes(stats.reclaimedBytes)}',
|
||||
);
|
||||
} catch (error) {
|
||||
state = state.copyWith(errorMessage: '数据库优化失败:$error');
|
||||
} finally {
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scanSelectedLibrary() async {
|
||||
final library = state.selectedLibrary;
|
||||
if (library == null || _api == null || state.isScanning) return;
|
||||
@@ -485,6 +506,15 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
|
||||
Future<void> _scanSource(
|
||||
String? rootID,
|
||||
String rootPath, {
|
||||
|
||||
Reference in New Issue
Block a user