From 576219e6946b4985ae95c74997403f0c6b27119f Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sun, 19 Jul 2026 12:16:58 +0800 Subject: [PATCH] optimize cloud index and backup controls --- lib/core/storage/file_metadata_cache.dart | 7 + lib/core/storage/media_library_store.dart | 50 +++ lib/core/storage/storage_manager.dart | 2 + lib/pages/media_library_page.dart | 188 ++++++----- lib/pages/workspace_page.dart | 233 +++++++++----- lib/providers/media_library_provider.dart | 362 ++++++++++++++++++++-- 6 files changed, 683 insertions(+), 159 deletions(-) diff --git a/lib/core/storage/file_metadata_cache.dart b/lib/core/storage/file_metadata_cache.dart index 57c539b..c0caf13 100644 --- a/lib/core/storage/file_metadata_cache.dart +++ b/lib/core/storage/file_metadata_cache.dart @@ -17,6 +17,13 @@ class FileMetadataCache { Map> folders, ) => _store.cacheFolderChildrenBatch(folders); + static Future clearFolderChildrenIndex() => + _store.clearFolderChildrenIndex(); + + static Future removeFolderChildrenSubtrees( + Iterable folderIDs, + ) => _store.removeFolderChildrenSubtrees(folderIDs); + static Future?> folderChildren(String? folderID) => _store.folderChildren(folderID); diff --git a/lib/core/storage/media_library_store.dart b/lib/core/storage/media_library_store.dart index 581d9e6..4799811 100644 --- a/lib/core/storage/media_library_store.dart +++ b/lib/core/storage/media_library_store.dart @@ -376,6 +376,56 @@ class MediaLibraryStore { }); } + /// Removes only directory listing snapshots. GCID metadata deliberately + /// remains available for permanent file-detail caching. + Future clearFolderChildrenIndex() async { + await (await _db).delete('folder_children'); + } + + /// Clears cached snapshots for removed folders and every cached descendant. + /// File/GCID metadata is retained because it can be reused by fast transfer. + Future removeFolderChildrenSubtrees(Iterable folderIDs) async { + final pending = folderIDs.where((id) => id.isNotEmpty).toList(); + if (pending.isEmpty) return; + final db = await _db; + await db.transaction((txn) async { + final visited = {}; + for (var index = 0; index < pending.length; index++) { + final folderID = pending[index]; + if (!visited.add(folderID)) continue; + final rows = await txn.query( + 'folder_children', + columns: const ['children_json'], + where: 'folder_id = ?', + whereArgs: [_folderID(folderID)], + limit: 1, + ); + if (rows.isNotEmpty) { + try { + final raw = jsonDecode( + rows.first['children_json']?.toString() ?? '[]', + ); + if (raw is List) { + for (final value in raw.whereType()) { + final child = CloudFile.fromJson( + Map.from(value), + ); + if (child.isDirectory) pending.add(child.id); + } + } + } catch (_) { + // A malformed stale snapshot can simply be discarded. + } + } + await txn.delete( + 'folder_children', + where: 'folder_id = ?', + whereArgs: [_folderID(folderID)], + ); + } + }); + } + Future?> folderChildren(String? folderID) async { final rows = await (await _db).query( 'folder_children', diff --git a/lib/core/storage/storage_manager.dart b/lib/core/storage/storage_manager.dart index f9e0832..ecd6be7 100644 --- a/lib/core/storage/storage_manager.dart +++ b/lib/core/storage/storage_manager.dart @@ -34,6 +34,8 @@ class StorageKeys { 'guangya.cloudIndexRefreshMinutes'; static const String cloudIndexLastUpdatedAt = 'guangya.cloudIndexLastUpdatedAt'; + static const String cloudScrapedBackupFolderID = + 'guangya.cloudScrapedBackupFolderID'; } class StorageManager { diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 15892e0..df48085 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -81,6 +81,109 @@ class MediaLibraryPage extends ConsumerStatefulWidget { ConsumerState createState() => _MediaLibraryPageState(); } +class _BackupActionsMenu extends StatefulWidget { + final bool compact; + final bool disabled; + final CloudBackupSyncProgress? progress; + final VoidCallback onExport; + final VoidCallback onImport; + final VoidCallback onSyncToCloud; + final VoidCallback onRestoreFromCloud; + + const _BackupActionsMenu({ + required this.compact, + required this.disabled, + required this.progress, + required this.onExport, + required this.onImport, + required this.onSyncToCloud, + required this.onRestoreFromCloud, + }); + + @override + State<_BackupActionsMenu> createState() => _BackupActionsMenuState(); +} + +class _BackupActionsMenuState extends State<_BackupActionsMenu> { + final _controller = ShadPopoverController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final progress = widget.progress; + final active = progress?.isActive == true; + final percentage = ((progress?.fraction ?? 0) * 100).round(); + final label = active + ? '${progress!.phase} $percentage%' + : progress?.error != null + ? '数据备份失败' + : '数据备份'; + return ShadPopover( + controller: _controller, + popover: (_) => SizedBox( + width: 220, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _item( + icon: Icons.save_alt_rounded, + label: '导出数据', + onPressed: widget.onExport, + ), + _item( + icon: Icons.upload_file_rounded, + label: '导入数据', + onPressed: widget.onImport, + ), + _item( + icon: Icons.cloud_upload_rounded, + label: '同步到云盘', + onPressed: widget.onSyncToCloud, + ), + _item( + icon: Icons.cloud_download_rounded, + label: '从云盘恢复', + onPressed: widget.onRestoreFromCloud, + ), + ], + ), + ), + child: ShadButton.outline( + size: widget.compact ? ShadButtonSize.sm : null, + onPressed: widget.disabled || active ? null : _controller.toggle, + leading: active + ? SizedBox( + width: 44, + child: ShadProgress(value: progress!.fraction, minHeight: 5), + ) + : const Icon(Icons.storage_rounded, size: 16), + trailing: const Icon(Icons.keyboard_arrow_down_rounded, size: 16), + child: Text(label), + ), + ); + } + + Widget _item({ + required IconData icon, + required String label, + required VoidCallback onPressed, + }) => ShadButton.ghost( + width: double.infinity, + mainAxisAlignment: MainAxisAlignment.start, + leading: Icon(icon, size: 16), + onPressed: () { + _controller.hide(); + onPressed(); + }, + child: Text(label), + ); +} + class _MediaLibraryPageState extends ConsumerState { final String _tmdbApiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; @@ -379,38 +482,7 @@ class _MediaLibraryPageState extends ConsumerState { leading: const Icon(Icons.add_rounded, size: 16), child: const Text('媒体库'), ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _backupBusy || state.isScanning - ? null - : _exportScrapedData, - leading: const Icon(Icons.save_alt_rounded, size: 16), - child: const Text('导出'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _backupBusy || state.isScanning - ? null - : _importScrapedData, - leading: const Icon(Icons.upload_file_rounded, size: 16), - child: const Text('导入'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _backupBusy || state.isScanning - ? null - : _syncScrapedDataToCloud, - leading: const Icon(Icons.cloud_upload_rounded, size: 16), - child: const Text('上传云盘'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _backupBusy || state.isScanning - ? null - : _syncScrapedDataFromCloud, - leading: const Icon(Icons.cloud_download_rounded, size: 16), - child: const Text('云盘恢复'), - ), + _backupActionsMenu(state, compact: true), ShadButton.outline( size: ShadButtonSize.sm, onPressed: state.selectedLibrary == null || state.isScanning @@ -492,37 +564,7 @@ class _MediaLibraryPageState extends ConsumerState { child: const Text('媒体库'), ), const SizedBox(width: 8), - ShadButton.outline( - onPressed: _backupBusy || state.isScanning - ? null - : _exportScrapedData, - leading: const Icon(Icons.save_alt_rounded, size: 16), - child: const Text('导出数据'), - ), - const SizedBox(width: 8), - ShadButton.outline( - onPressed: _backupBusy || state.isScanning - ? null - : _importScrapedData, - leading: const Icon(Icons.upload_file_rounded, size: 16), - child: const Text('导入数据'), - ), - const SizedBox(width: 8), - ShadButton.outline( - onPressed: _backupBusy || state.isScanning - ? null - : _syncScrapedDataToCloud, - leading: const Icon(Icons.cloud_upload_rounded, size: 16), - child: const Text('同步云盘'), - ), - const SizedBox(width: 8), - ShadButton.outline( - onPressed: _backupBusy || state.isScanning - ? null - : _syncScrapedDataFromCloud, - leading: const Icon(Icons.cloud_download_rounded, size: 16), - child: const Text('云盘恢复'), - ), + _backupActionsMenu(state), const SizedBox(width: 8), ShadButton.outline( onPressed: state.selectedLibrary == null || state.isScanning @@ -1130,20 +1172,26 @@ class _MediaLibraryPageState extends ConsumerState { } Future _syncScrapedDataToCloud() async { - final fileState = ref.read(fileProvider); - final parentID = fileState.folderPath.isEmpty - ? null - : fileState.folderPath.last.id; setState(() => _backupBusy = true); try { - await ref - .read(mediaLibraryProvider.notifier) - .exportScrapedDataToCloud(parentID: parentID); + await ref.read(mediaLibraryProvider.notifier).exportScrapedDataToCloud(); } finally { if (mounted) setState(() => _backupBusy = false); } } + Widget _backupActionsMenu(MediaLibraryState state, {bool compact = false}) { + return _BackupActionsMenu( + compact: compact, + disabled: _backupBusy || state.isScanning, + progress: state.cloudBackupSync, + onExport: _exportScrapedData, + onImport: _importScrapedData, + onSyncToCloud: _syncScrapedDataToCloud, + onRestoreFromCloud: _syncScrapedDataFromCloud, + ); + } + Future _syncScrapedDataFromCloud() async { final notifier = ref.read(mediaLibraryProvider.notifier); setState(() => _backupBusy = true); diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index e4e0d6d..5a9556c 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -238,7 +238,6 @@ class _WorkspacePageState extends ConsumerState { @override Widget build(BuildContext context) { final fp = ref.watch(fileProvider); - final auth = ref.watch(authProvider); return Scaffold( backgroundColor: Colors.transparent, @@ -257,7 +256,8 @@ class _WorkspacePageState extends ConsumerState { onSearch: _submitSearch, onToggleSearch: _toggleSearch, onSettings: () => _showSettings(context), - onOpenMenu: () => _showMobileMenu(context, auth), + onOpenMenu: () => _showMobileMenu(context), + uploadProgress: fp.uploadProgress, ); final content = IndexedStack( index: _mode == WorkspaceMode.cloud ? 0 : 1, @@ -265,7 +265,7 @@ class _WorkspacePageState extends ConsumerState { ); if (compact) { return _MobileDrawerSwipeArea( - onOpen: () => _showMobileMenu(context, auth), + onOpen: () => _showMobileMenu(context), child: Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), child: Column( @@ -373,46 +373,88 @@ class _WorkspacePageState extends ConsumerState { ); } - void _showMobileMenu(BuildContext context, AuthState auth) { + void _showMobileMenu(BuildContext context) { + final width = (MediaQuery.sizeOf(context).width * 0.86) + .clamp(280.0, 340.0) + .toDouble(); showShadSheet( context: context, side: ShadSheetSide.left, - builder: (context) => _MobileWorkspaceMenu( - mode: _mode, - userName: auth.userName, - memberLevel: auth.memberLevel, - capacityText: auth.capacityText, - onSection: (section) { - Navigator.of(context).pop(); - if (section == WorkspaceSection.mediaLibrary) { - _changeMode(WorkspaceMode.media); - return; - } - if (_mode != WorkspaceMode.cloud) { - _changeMode(WorkspaceMode.cloud); - } - ref.read(fileProvider.notifier).setSection(section); - }, - onTool: (tool) { - Navigator.of(context).pop(); - _openTool(tool); - }, - onCreateLibrary: () { - Navigator.of(context).pop(); - MediaLibraryPage.showCreateDialog(this.context, ref); - }, - onSettings: () { - Navigator.of(context).pop(); - _showSettings(this.context); - }, - onSearch: () { - Navigator.of(context).pop(); - if (!_searchOpen) _toggleSearch(); - }, - onSignOut: () { - Navigator.of(context).pop(); - ref.read(authProvider.notifier).signOut(); - }, + builder: (sheetContext) => ShadSheet( + constraints: BoxConstraints.tightFor(width: width), + padding: EdgeInsets.zero, + scrollable: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Row( + children: [ + Expanded( + child: _SegmentButton( + icon: Icons.folder_rounded, + label: '光鸭云盘', + compact: false, + selected: _mode == WorkspaceMode.cloud, + onTap: () { + Navigator.of(sheetContext).pop(); + _changeMode(WorkspaceMode.cloud); + }, + ), + ), + const SizedBox(width: 6), + Expanded( + child: _SegmentButton( + icon: Icons.movie_rounded, + label: '光鸭影视', + compact: false, + selected: _mode == WorkspaceMode.media, + onTap: () { + Navigator.of(sheetContext).pop(); + _changeMode(WorkspaceMode.media); + }, + ), + ), + ], + ), + ), + const ShadSeparator.horizontal(), + Expanded( + child: _mode == WorkspaceMode.cloud + ? _CloudSidebar( + state: ref.read(fileProvider), + width: width, + onSection: (section) { + Navigator.of(sheetContext).pop(); + ref.read(fileProvider.notifier).setSection(section); + }, + onSettings: () { + Navigator.of(sheetContext).pop(); + _showSettings(context); + }, + onSignOut: () { + Navigator.of(sheetContext).pop(); + ref.read(authProvider.notifier).signOut(); + }, + onTool: (tool) { + Navigator.of(sheetContext).pop(); + _openTool(tool); + }, + ) + : _MediaSidebar( + width: width, + onCreate: () { + Navigator.of(sheetContext).pop(); + MediaLibraryPage.showCreateDialog(context, ref); + }, + onTool: (tool) { + Navigator.of(sheetContext).pop(); + _openTool(tool); + }, + ), + ), + ], + ), ), ); } @@ -516,6 +558,7 @@ class _TopBar extends StatelessWidget { final VoidCallback onToggleSearch; final VoidCallback onSettings; final VoidCallback onOpenMenu; + final UploadProgress? uploadProgress; const _TopBar({ required this.mode, @@ -528,6 +571,7 @@ class _TopBar extends StatelessWidget { required this.onToggleSearch, required this.onSettings, required this.onOpenMenu, + required this.uploadProgress, }); @override @@ -546,20 +590,6 @@ class _TopBar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _SegmentButton( - icon: Icons.folder_rounded, - label: '光鸭云盘', - compact: true, - selected: mode == WorkspaceMode.cloud, - onTap: () => onModeChanged(WorkspaceMode.cloud), - ), - _SegmentButton( - icon: Icons.movie_rounded, - label: '光鸭影视', - compact: true, - selected: mode == WorkspaceMode.media, - onTap: () => onModeChanged(WorkspaceMode.media), - ), _TopBarIconButton( tooltip: '打开菜单', icon: Icons.menu_rounded, @@ -607,6 +637,10 @@ class _TopBar extends StatelessWidget { ), ), const Expanded(child: DragToMoveArea(child: SizedBox.expand())), + if (mode == WorkspaceMode.cloud) ...[ + _UploadListTopButton(progress: uploadProgress), + const SizedBox(width: 8), + ], AnimatedContainer( duration: const Duration(milliseconds: 160), width: searchOpen ? 280 : 42, @@ -689,13 +723,13 @@ class _SegmentButton extends StatelessWidget { button: true, selected: selected, label: label, - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onTap, + child: ShadButton.ghost( + width: compact ? 38 : 120, + height: compact ? 38 : 32, + padding: EdgeInsets.zero, + onPressed: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 160), - width: compact ? 38 : 126, - height: compact ? 38 : 32, decoration: BoxDecoration( color: selected ? cs.secondary : Colors.transparent, borderRadius: BorderRadius.circular(10), @@ -753,19 +787,47 @@ class _TopBarIconButton extends StatelessWidget { final cs = ShadTheme.of(context).colorScheme; return ShadTooltip( builder: (_) => Text(tooltip), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onTap, - child: SizedBox( - width: 38, - height: 38, - child: Icon(icon, size: 18, color: cs.mutedForeground), - ), + child: ShadButton.ghost( + width: 38, + height: 38, + padding: EdgeInsets.zero, + onPressed: onTap, + child: Icon(icon, size: 18, color: cs.mutedForeground), ), ); } } +class _UploadListTopButton extends StatefulWidget { + final UploadProgress? progress; + + const _UploadListTopButton({required this.progress}); + + @override + State<_UploadListTopButton> createState() => _UploadListTopButtonState(); +} + +class _UploadListTopButtonState extends State<_UploadListTopButton> { + final _controller = ShadPopoverController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ShadPopover( + controller: _controller, + popover: (_) => _UploadProgressPopover(progress: widget.progress), + child: _TopBarIconButton( + tooltip: '上传列表', + icon: Icons.upload_file_rounded, + onTap: _controller.toggle, + ), + ); +} + class _MobileFloatingSearch extends StatelessWidget { final WorkspaceMode mode; final TextEditingController searchController; @@ -886,6 +948,7 @@ class _MobileDrawerSwipeAreaState extends State<_MobileDrawerSwipeArea> { ); } +// ignore: unused_element class _MobileWorkspaceMenu extends StatelessWidget { final WorkspaceMode mode; final String userName; @@ -1153,6 +1216,7 @@ class _MobileMenuRow extends StatelessWidget { class _CloudSidebar extends StatelessWidget { final FileState state; + final double width; final ValueChanged onSection; final VoidCallback onSettings; final VoidCallback onSignOut; @@ -1160,6 +1224,7 @@ class _CloudSidebar extends StatelessWidget { const _CloudSidebar({ required this.state, + this.width = 250, required this.onSection, required this.onSettings, required this.onSignOut, @@ -1172,7 +1237,7 @@ class _CloudSidebar extends StatelessWidget { .where((section) => section != WorkspaceSection.mediaLibrary) .toList(); return SizedBox( - width: 250, + width: width, child: OS26Glass( radius: 24, opacity: 0.56, @@ -1266,17 +1331,22 @@ class _CloudSidebar extends StatelessWidget { } class _MediaSidebar extends ConsumerWidget { + final double width; final VoidCallback onCreate; final ValueChanged onTool; - const _MediaSidebar({required this.onCreate, required this.onTool}); + const _MediaSidebar({ + this.width = 250, + required this.onCreate, + required this.onTool, + }); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(mediaLibraryProvider); final notifier = ref.read(mediaLibraryProvider.notifier); return SizedBox( - width: 250, + width: width, child: OS26Glass( radius: 24, opacity: 0.56, @@ -1942,8 +2012,29 @@ class _UploadProgressPopover extends StatelessWidget { @override Widget build(BuildContext context) { final value = progress; - if (value == null) return const SizedBox.shrink(); final cs = ShadTheme.of(context).colorScheme; + if (value == null) { + return SizedBox( + width: 260, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon( + Icons.cloud_done_rounded, + size: 17, + color: cs.mutedForeground, + ), + const SizedBox(width: 8), + Text( + '暂无上传任务', + style: TextStyle(fontSize: 13, color: cs.mutedForeground), + ), + ], + ), + ), + ); + } final percentage = (value.fraction * 100).round(); return Semantics( label: '上传进度', diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index c573c37..14c76d2 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -36,6 +36,27 @@ class MediaTMDBMatchRequest { const MediaTMDBMatchRequest({required this.items, required this.candidates}); } +class CloudBackupSyncProgress { + final String phase; + final String destination; + final int transferredBytes; + final int totalBytes; + final bool isActive; + final String? error; + + const CloudBackupSyncProgress({ + required this.phase, + required this.destination, + required this.transferredBytes, + required this.totalBytes, + required this.isActive, + this.error, + }); + + double get fraction => + totalBytes <= 0 ? 0 : (transferredBytes / totalBytes).clamp(0.0, 1.0); +} + class MediaLibraryState { final List libraries; final String? selectedLibraryID; @@ -43,6 +64,7 @@ class MediaLibraryState { final bool isLoading; final bool isScanning; final bool isRefreshingCloudIndex; + final CloudBackupSyncProgress? cloudBackupSync; final MediaLibraryScanProgress progress; final List scanLogs; final String searchQuery; @@ -56,6 +78,7 @@ class MediaLibraryState { this.isLoading = false, this.isScanning = false, this.isRefreshingCloudIndex = false, + this.cloudBackupSync, this.progress = const MediaLibraryScanProgress(), this.scanLogs = const [], this.searchQuery = '', @@ -91,6 +114,8 @@ class MediaLibraryState { bool? isLoading, bool? isScanning, bool? isRefreshingCloudIndex, + CloudBackupSyncProgress? cloudBackupSync, + bool clearCloudBackupSync = false, MediaLibraryScanProgress? progress, List? scanLogs, String? searchQuery, @@ -109,6 +134,9 @@ class MediaLibraryState { isScanning: isScanning ?? this.isScanning, isRefreshingCloudIndex: isRefreshingCloudIndex ?? this.isRefreshingCloudIndex, + cloudBackupSync: clearCloudBackupSync + ? null + : (cloudBackupSync ?? this.cloudBackupSync), progress: progress ?? this.progress, scanLogs: scanLogs ?? this.scanLogs, searchQuery: searchQuery ?? this.searchQuery, @@ -284,36 +312,123 @@ class MediaLibraryNotifier extends StateNotifier { } } - Future exportScrapedDataToCloud({String? parentID}) async { + Future exportScrapedDataToCloud() async { if (_api == null || state.isLoading || state.isScanning) return; state = state.copyWith( - isLoading: true, clearError: true, clearStatus: true, + cloudBackupSync: const CloudBackupSyncProgress( + phase: '正在准备备份', + destination: '云盘根目录/小黄鸭备份', + transferredBytes: 0, + totalBytes: 0, + isActive: true, + ), ); Directory? temporaryDirectory; try { + final destination = await _resolveCloudBackupDestination(); + state = state.copyWith( + cloudBackupSync: CloudBackupSyncProgress( + phase: '正在导出本地数据库', + destination: destination.path, + transferredBytes: 0, + totalBytes: 0, + isActive: true, + ), + ); temporaryDirectory = await Directory.systemTemp.createTemp( 'guangya-media-', ); - final backup = File('${temporaryDirectory.path}/media-library.sqlite3'); + final backup = File( + '${temporaryDirectory.path}/media-library-${DateTime.now().millisecondsSinceEpoch}.sqlite3', + ); await _store.exportBackupTo(backup.path); + final size = await backup.length(); + state = state.copyWith( + cloudBackupSync: CloudBackupSyncProgress( + phase: '正在上传备份', + destination: '${destination.path}/${backup.uri.pathSegments.last}', + transferredBytes: 0, + totalBytes: size, + isActive: true, + ), + ); await _api!.fileUpload( backup, - parentID: parentID, + parentID: destination.id, contentType: 'application/vnd.sqlite3', + onProgress: (sent, total) { + state = state.copyWith( + cloudBackupSync: CloudBackupSyncProgress( + phase: '正在上传备份', + destination: + '${destination.path}/${backup.uri.pathSegments.last}', + transferredBytes: sent, + totalBytes: total, + isActive: true, + ), + ); + }, + ); + final uploadedPath = + '${destination.path}/${backup.uri.pathSegments.last}'; + state = state.copyWith( + statusMessage: '刮削数据已同步到:$uploadedPath', + cloudBackupSync: CloudBackupSyncProgress( + phase: '同步完成', + destination: uploadedPath, + transferredBytes: size, + totalBytes: size, + isActive: false, + ), ); - state = state.copyWith(statusMessage: '刮削数据已同步到云盘'); } catch (error) { - state = state.copyWith(errorMessage: '同步到云盘失败:$error'); + final destination = state.cloudBackupSync?.destination ?? '云盘根目录/小黄鸭备份'; + state = state.copyWith( + errorMessage: '同步到云盘失败:$error', + cloudBackupSync: CloudBackupSyncProgress( + phase: '同步失败', + destination: destination, + transferredBytes: 0, + totalBytes: 0, + isActive: false, + error: '$error', + ), + ); } finally { if (temporaryDirectory != null && await temporaryDirectory.exists()) { await temporaryDirectory.delete(recursive: true); } - state = state.copyWith(isLoading: false); } } + Future<_CloudBackupDestination> _resolveCloudBackupDestination() async { + const folderName = '小黄鸭备份'; + final storedID = StorageManager.get( + StorageKeys.cloudScrapedBackupFolderID, + )?.trim(); + if (storedID != null && storedID.isNotEmpty) { + return _CloudBackupDestination(id: storedID, path: '云盘根目录/小黄鸭备份'); + } + final root = await _api!.fsFiles(parentID: null, pageSize: 1000); + final existing = _extractFiles( + root, + ).where((file) => file.isDirectory && file.name == folderName); + final folder = existing.isEmpty ? null : existing.first; + final folderID = + folder?.id ?? + _findStringDeep( + await _api!.fsCreateDir(folderName, parentID: null), + const ['fileId', 'file_id', 'resId', 'res_id', 'id'], + ); + if (folderID == null || folderID.isEmpty) { + throw Exception('无法创建云盘备份目录「$folderName」'); + } + await StorageManager.set(StorageKeys.cloudScrapedBackupFolderID, folderID); + return _CloudBackupDestination(id: folderID, path: '云盘根目录/$folderName'); + } + Future> cloudScrapedBackups() async { if (_api == null) return const []; final response = await _api!.searchFiles( @@ -649,7 +764,6 @@ class MediaLibraryNotifier extends StateNotifier { AppLogger.debug('CloudIndex', '全盘文件索引正在刷新,本次请求已合并'); return; } - AppLogger.info('CloudIndex', force ? '正在强制刷新全盘文件索引' : '正在检查全盘文件索引缓存'); final minutes = (int.tryParse( StorageManager.get( @@ -662,7 +776,10 @@ class MediaLibraryNotifier extends StateNotifier { final lastUpdated = int.tryParse( StorageManager.get(StorageKeys.cloudIndexLastUpdatedAt) ?? '', ); - if (!force && lastUpdated != null) { + final rootSnapshot = await FileMetadataCache.folderChildren(null); + final needsFullRebuild = + force || lastUpdated == null || rootSnapshot == null; + if (!force && !needsFullRebuild) { final elapsed = DateTime.now().difference( DateTime.fromMillisecondsSinceEpoch(lastUpdated), ); @@ -679,19 +796,32 @@ class MediaLibraryNotifier extends StateNotifier { _refreshingCloudIndex = true; state = state.copyWith(isRefreshingCloudIndex: true); try { - AppLogger.info('CloudIndex', '开始刷新全盘文件索引'); - final files = await _allGlobalRemoteFiles(); - final folders = >{}; - for (final file in files) { - (folders[file.parentID] ??= []).add(file); + late _CloudIndexRefreshResult result; + if (needsFullRebuild) { + final reason = force ? '用户手动触发' : '本地缓存为空'; + AppLogger.info('CloudIndex', '开始建立全盘文件索引:$reason'); + result = await _rebuildGlobalCloudIndex(); + AppLogger.info( + 'CloudIndex', + '全盘文件索引建立完成:${result.updatedFolders} 个目录,${result.updatedEntries} 项', + ); + } else { + AppLogger.info('CloudIndex', '开始按目录修改时间增量检查全盘索引'); + result = await _refreshChangedCloudIndex(); + AppLogger.info( + 'CloudIndex', + '全盘索引增量检查完成:检查 ${result.checkedFolders} 个目录,更新 ${result.updatedFolders} 个目录、${result.updatedEntries} 项', + ); } - await FileMetadataCache.cacheFolderChildrenBatch(folders); await StorageManager.set( StorageKeys.cloudIndexLastUpdatedAt, DateTime.now().millisecondsSinceEpoch.toString(), ); - AppLogger.info('CloudIndex', '全盘文件索引刷新完成,共 ${files.length} 项'); - _appendScanLog('[云盘索引] 已刷新 ${files.length} 个文件与目录缓存'); + _appendScanLog( + needsFullRebuild + ? '[云盘索引] 已建立 ${result.updatedFolders} 个目录、${result.updatedEntries} 项缓存' + : '[云盘索引] 已检查 ${result.checkedFolders} 个目录,更新 ${result.updatedFolders} 个目录', + ); } catch (error) { AppLogger.error('CloudIndex', '刷新全盘文件索引失败', error: error); _appendScanLog('[云盘索引] 刷新失败:$error', isError: true); @@ -705,7 +835,7 @@ class MediaLibraryNotifier extends StateNotifier { void _scheduleCloudIndexRefresh(int minutes) { _cloudIndexTimer?.cancel(); _cloudIndexTimer = Timer(Duration(minutes: minutes), () { - unawaited(refreshGlobalCloudIndex(force: true)); + unawaited(refreshGlobalCloudIndex()); }); } @@ -722,6 +852,176 @@ class MediaLibraryNotifier extends StateNotifier { _scheduleCloudIndexRefresh(minutes); } + Future<_CloudIndexRefreshResult> _rebuildGlobalCloudIndex() async { + await FileMetadataCache.clearFolderChildrenIndex(); + final folders = <_CloudIndexFolder>[const _CloudIndexFolder(null, '根目录')]; + final visited = {}; + var updatedFolders = 0; + var updatedEntries = 0; + + for (var index = 0; index < folders.length; index++) { + final folder = folders[index]; + final key = folder.id ?? '@root'; + if (!visited.add(key)) continue; + final snapshot = await _loadCloudIndexFolder(folder); + await FileMetadataCache.cacheFolderChildren(folder.id, snapshot); + updatedFolders += 1; + updatedEntries += snapshot.length; + for (final child in snapshot.where((file) => file.isDirectory)) { + folders.add(_CloudIndexFolder(child.id, child.name)); + } + } + return _CloudIndexRefreshResult( + checkedFolders: updatedFolders, + updatedFolders: updatedFolders, + updatedEntries: updatedEntries, + ); + } + + Future<_CloudIndexRefreshResult> _refreshChangedCloudIndex() async { + final root = const _CloudIndexFolder(null, '根目录'); + final rootCached = + await FileMetadataCache.folderChildren(root.id) ?? const []; + final rootRemote = await _loadCloudIndexFolder(root); + final folders = <_CloudIndexFolder>[]; + final queued = {}; + var checkedFolders = 1; + var updatedFolders = 0; + var updatedEntries = 0; + + final rootUpdate = await _replaceCloudIndexFolder( + folderID: root.id, + previous: rootCached, + current: rootRemote, + ); + if (rootUpdate.changed) { + updatedFolders += 1; + updatedEntries += rootRemote.length; + } + _queueChangedIndexFolders( + folders: folders, + queued: queued, + previous: rootCached, + current: rootRemote, + ); + + for (var index = 0; index < folders.length; index++) { + final folder = folders[index]; + final previous = + await FileMetadataCache.folderChildren(folder.id) ?? + const []; + final current = await _loadCloudIndexFolder(folder); + checkedFolders += 1; + final update = await _replaceCloudIndexFolder( + folderID: folder.id, + previous: previous, + current: current, + ); + if (update.changed) { + updatedFolders += 1; + updatedEntries += current.length; + } + _queueChangedIndexFolders( + folders: folders, + queued: queued, + previous: previous, + current: current, + ); + } + return _CloudIndexRefreshResult( + checkedFolders: checkedFolders, + updatedFolders: updatedFolders, + updatedEntries: updatedEntries, + ); + } + + Future> _loadCloudIndexFolder( + _CloudIndexFolder folder, + ) async { + const pageSize = 1000; + final values = []; + final ids = {}; + for (var page = 0; ; page++) { + AppLogger.info('CloudIndex', '正在检查目录「${folder.label}」第 ${page + 1} 页'); + final response = await _api!.fsFiles( + parentID: folder.id, + page: page, + pageSize: pageSize, + orderBy: 0, + sortType: 0, + ); + final batch = _extractFiles(response); + final added = batch.where((file) => ids.add(file.id)).toList(); + values.addAll(added); + AppLogger.info( + 'CloudIndex', + '目录「${folder.label}」第 ${page + 1} 页检查完成,获取 ${batch.length} 项', + ); + if (batch.length < pageSize || added.isEmpty) break; + } + return values; + } + + Future<({bool changed})> _replaceCloudIndexFolder({ + required String? folderID, + required List previous, + required List current, + }) async { + if (_sameCloudIndexSnapshot(previous, current)) { + return (changed: false); + } + final currentIDs = current.map((file) => file.id).toSet(); + final removedFolders = previous + .where((file) => file.isDirectory && !currentIDs.contains(file.id)) + .map((file) => file.id) + .toList(); + await FileMetadataCache.cacheFolderChildren(folderID, current); + if (removedFolders.isNotEmpty) { + await FileMetadataCache.removeFolderChildrenSubtrees(removedFolders); + } + return (changed: true); + } + + void _queueChangedIndexFolders({ + required List<_CloudIndexFolder> folders, + required Set queued, + required List previous, + required List current, + }) { + final previousByID = {for (final file in previous) file.id: file}; + for (final folder in current.where((file) => file.isDirectory)) { + final prior = previousByID[folder.id]; + if (prior == null || _cloudIndexEntryChanged(prior, folder)) { + if (queued.add(folder.id)) { + folders.add(_CloudIndexFolder(folder.id, folder.name)); + } + } + } + } + + bool _sameCloudIndexSnapshot( + List previous, + List current, + ) { + if (previous.length != current.length) return false; + final previousByID = {for (final file in previous) file.id: file}; + if (previousByID.length != current.length) return false; + return current.every( + (file) => + previousByID[file.id] != null && + !_cloudIndexEntryChanged(previousByID[file.id]!, file), + ); + } + + bool _cloudIndexEntryChanged(CloudFile previous, CloudFile current) => + previous.name != current.name || + previous.isDirectory != current.isDirectory || + previous.size != current.size || + previous.gcid != current.gcid || + previous.modifiedAt != current.modifiedAt || + previous.subDirectoryCount != current.subDirectoryCount || + previous.subFileCount != current.subFileCount; + @override void dispose() { _cloudIndexTimer?.cancel(); @@ -2510,6 +2810,32 @@ class _ScanFolder { const _ScanFolder(this.id, this.path); } +class _CloudIndexFolder { + final String? id; + final String label; + + const _CloudIndexFolder(this.id, this.label); +} + +class _CloudIndexRefreshResult { + final int checkedFolders; + final int updatedFolders; + final int updatedEntries; + + const _CloudIndexRefreshResult({ + required this.checkedFolders, + required this.updatedFolders, + required this.updatedEntries, + }); +} + +class _CloudBackupDestination { + final String id; + final String path; + + const _CloudBackupDestination({required this.id, required this.path}); +} + class _SyncedMediaItem { final MediaLibraryItem original; final MediaLibraryItem fallback;