diff --git a/lib/core/storage/media_library_store.dart b/lib/core/storage/media_library_store.dart index cae345e..b877c51 100644 --- a/lib/core/storage/media_library_store.dart +++ b/lib/core/storage/media_library_store.dart @@ -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 importBackup(String backupPath) async { + Future 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 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 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 row) { @@ -571,4 +602,27 @@ class MediaLibraryStore { 'updated_at', ]; static String _folderID(String? folderID) => folderID ?? _rootFolderID; + + Future _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); } diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 47cad80..8d30ca1 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -249,6 +249,14 @@ class _MediaLibraryPageState extends ConsumerState { 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 { } } + Future _optimizeScrapedStorage() async { + setState(() => _backupBusy = true); + try { + await ref.read(mediaLibraryProvider.notifier).optimizeLocalStorage(); + } finally { + if (mounted) setState(() => _backupBusy = false); + } + } + Future _searchTMDB(String query) async { final text = query.trim(); if (text.isEmpty || _tmdbApiKey.isEmpty) return; diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 998ec6b..bcdde44 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -207,7 +207,7 @@ class MediaLibraryNotifier extends StateNotifier { 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 { 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 { } } + Future 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 scanSelectedLibrary() async { final library = state.selectedLibrary; if (library == null || _api == null || state.isScanning) return; @@ -485,6 +506,15 @@ class MediaLibraryNotifier extends StateNotifier { 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 _scanSource( String? rootID, String rootPath, {