feat: compact media library sqlite storage

This commit is contained in:
ngfchl
2026-07-18 15:48:05 +08:00
parent 6f0e3b8523
commit 836ea0e3d3
3 changed files with 105 additions and 4 deletions
+56 -2
View File
@@ -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);
}
+17
View File
@@ -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;
+32 -2
View File
@@ -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, {