optimize cloud index and backup controls

This commit is contained in:
ngfchl
2026-07-19 12:16:58 +08:00
parent fc912113f6
commit 576219e694
6 changed files with 683 additions and 159 deletions
@@ -17,6 +17,13 @@ class FileMetadataCache {
Map<String?, List<CloudFile>> folders,
) => _store.cacheFolderChildrenBatch(folders);
static Future<void> clearFolderChildrenIndex() =>
_store.clearFolderChildrenIndex();
static Future<void> removeFolderChildrenSubtrees(
Iterable<String> folderIDs,
) => _store.removeFolderChildrenSubtrees(folderIDs);
static Future<List<CloudFile>?> folderChildren(String? folderID) =>
_store.folderChildren(folderID);
+50
View File
@@ -376,6 +376,56 @@ class MediaLibraryStore {
});
}
/// Removes only directory listing snapshots. GCID metadata deliberately
/// remains available for permanent file-detail caching.
Future<void> 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<void> removeFolderChildrenSubtrees(Iterable<String> 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 = <String>{};
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<Map>()) {
final child = CloudFile.fromJson(
Map<String, dynamic>.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<List<CloudFile>?> folderChildren(String? folderID) async {
final rows = await (await _db).query(
'folder_children',
+2
View File
@@ -34,6 +34,8 @@ class StorageKeys {
'guangya.cloudIndexRefreshMinutes';
static const String cloudIndexLastUpdatedAt =
'guangya.cloudIndexLastUpdatedAt';
static const String cloudScrapedBackupFolderID =
'guangya.cloudScrapedBackupFolderID';
}
class StorageManager {
+118 -70
View File
@@ -81,6 +81,109 @@ class MediaLibraryPage extends ConsumerStatefulWidget {
ConsumerState<MediaLibraryPage> 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<MediaLibraryPage> {
final String _tmdbApiKey =
StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
@@ -379,38 +482,7 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
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<MediaLibraryPage> {
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<MediaLibraryPage> {
}
Future<void> _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<void> _syncScrapedDataFromCloud() async {
final notifier = ref.read(mediaLibraryProvider.notifier);
setState(() => _backupBusy = true);
+162 -71
View File
@@ -238,7 +238,6 @@ class _WorkspacePageState extends ConsumerState<WorkspacePage> {
@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<WorkspacePage> {
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<WorkspacePage> {
);
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<WorkspacePage> {
);
}
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<WorkspaceSection> 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<WorkspaceTool> 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: '上传进度',
+344 -18
View File
@@ -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<MediaLibraryDefinition> 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<MediaLibraryScanLog> 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<MediaLibraryScanLog>? 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<MediaLibraryState> {
}
}
Future<void> exportScrapedDataToCloud({String? parentID}) async {
Future<void> 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<String>(
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<List<CloudFile>> cloudScrapedBackups() async {
if (_api == null) return const [];
final response = await _api!.searchFiles(
@@ -649,7 +764,6 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
AppLogger.debug('CloudIndex', '全盘文件索引正在刷新,本次请求已合并');
return;
}
AppLogger.info('CloudIndex', force ? '正在强制刷新全盘文件索引' : '正在检查全盘文件索引缓存');
final minutes =
(int.tryParse(
StorageManager.get<String>(
@@ -662,7 +776,10 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
final lastUpdated = int.tryParse(
StorageManager.get<String>(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<MediaLibraryState> {
_refreshingCloudIndex = true;
state = state.copyWith(isRefreshingCloudIndex: true);
try {
AppLogger.info('CloudIndex', '开始刷新全盘文件索引');
final files = await _allGlobalRemoteFiles();
final folders = <String?, List<CloudFile>>{};
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<MediaLibraryState> {
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<MediaLibraryState> {
_scheduleCloudIndexRefresh(minutes);
}
Future<_CloudIndexRefreshResult> _rebuildGlobalCloudIndex() async {
await FileMetadataCache.clearFolderChildrenIndex();
final folders = <_CloudIndexFolder>[const _CloudIndexFolder(null, '根目录')];
final visited = <String>{};
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 <CloudFile>[];
final rootRemote = await _loadCloudIndexFolder(root);
final folders = <_CloudIndexFolder>[];
final queued = <String>{};
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 <CloudFile>[];
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<List<CloudFile>> _loadCloudIndexFolder(
_CloudIndexFolder folder,
) async {
const pageSize = 1000;
final values = <CloudFile>[];
final ids = <String>{};
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<CloudFile> previous,
required List<CloudFile> 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<String> queued,
required List<CloudFile> previous,
required List<CloudFile> 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<CloudFile> previous,
List<CloudFile> 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;