optimize cloud index and backup controls
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -34,6 +34,8 @@ class StorageKeys {
|
||||
'guangya.cloudIndexRefreshMinutes';
|
||||
static const String cloudIndexLastUpdatedAt =
|
||||
'guangya.cloudIndexLastUpdatedAt';
|
||||
static const String cloudScrapedBackupFolderID =
|
||||
'guangya.cloudScrapedBackupFolderID';
|
||||
}
|
||||
|
||||
class StorageManager {
|
||||
|
||||
@@ -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
@@ -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: '上传进度',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user