import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/legacy.dart'; import '../api/guangya_api.dart'; import '../core/logging/app_logger.dart'; import '../core/storage/file_metadata_cache.dart'; import '../core/storage/media_library_store.dart'; import '../core/storage/storage_manager.dart'; import '../models/cloud_file.dart'; import '../models/media_library.dart'; /// Blu-ray and DVD folders contain transport streams rather than standalone /// media. They must be handled as a disc structure, never as individual files. bool isMediaScanDiscInternalPath(String path) { return path .split(RegExp(r'[/\\]+')) .map((component) => component.trim().toUpperCase()) .any((component) => component == 'BDMV' || component == 'VIDEO_TS'); } bool isMediaScanDiscLayout(Iterable children) { final directoryNames = children .where((file) => file.isDirectory) .map((file) => file.name.trim().toUpperCase()) .toSet(); return directoryNames.contains('BDMV') || directoryNames.contains('VIDEO_TS'); } class MediaTMDBMatchRequest { final List items; final List> candidates; 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; final List items; final bool isLoading; final bool isScanning; final bool isRefreshingCloudIndex; final CloudBackupSyncProgress? cloudBackupSync; final MediaLibraryScanProgress progress; final List scanLogs; final String searchQuery; final String? errorMessage; final String? statusMessage; const MediaLibraryState({ this.libraries = const [], this.selectedLibraryID, this.items = const [], this.isLoading = false, this.isScanning = false, this.isRefreshingCloudIndex = false, this.cloudBackupSync, this.progress = const MediaLibraryScanProgress(), this.scanLogs = const [], this.searchQuery = '', this.errorMessage, this.statusMessage, }); MediaLibraryDefinition? get selectedLibrary { for (final library in libraries) { if (library.id == selectedLibraryID) return library; } return libraries.isEmpty ? null : libraries.first; } List get visibleItems { final query = searchQuery.trim().toLowerCase(); if (query.isEmpty) return items; return items.where((item) { return item.title.toLowerCase().contains(query) || item.file.name.toLowerCase().contains(query) || item.file.cloudPath.toLowerCase().contains(query); }).toList(); } MediaLibraryStatistics get statistics => MediaLibraryStatistics.fromItems(items); MediaLibraryState copyWith({ List? libraries, String? selectedLibraryID, bool clearSelectedLibrary = false, List? items, bool? isLoading, bool? isScanning, bool? isRefreshingCloudIndex, CloudBackupSyncProgress? cloudBackupSync, bool clearCloudBackupSync = false, MediaLibraryScanProgress? progress, List? scanLogs, String? searchQuery, String? errorMessage, bool clearError = false, String? statusMessage, bool clearStatus = false, }) { return MediaLibraryState( libraries: libraries ?? this.libraries, selectedLibraryID: clearSelectedLibrary ? null : (selectedLibraryID ?? this.selectedLibraryID), items: items ?? this.items, isLoading: isLoading ?? this.isLoading, 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, errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage), ); } } class MediaLibraryNotifier extends StateNotifier { GuangyaAPI? _api; final _store = MediaLibraryStore(); final _tmdbDetailsRequests = >>{}; final _searchResultsCache = >{}; final _artworkHydrationLibraries = {}; bool _loaded = false; bool _cancelScan = false; bool _cancelDetailSync = false; bool _refreshingCloudIndex = false; Timer? _cloudIndexTimer; MediaLibraryNotifier() : super(const MediaLibraryState()); set api(GuangyaAPI value) => _api = value; Future load() async { if (_loaded) return; _loaded = true; state = state.copyWith(isLoading: true, clearError: true); try { await _store.initialize(); await _migrateLegacyHiveIfNeeded(); final removedDiscStreams = await _removeDiscInternalItems(); final libraries = await _loadLibraries(); final selectedID = libraries.isEmpty ? null : libraries.first.id; final items = selectedID == null ? [] : await _loadItems(selectedID); final logs = _loadScanHistory(); state = state.copyWith( libraries: libraries, selectedLibraryID: selectedID, items: items, scanLogs: logs, statusMessage: removedDiscStreams == 0 ? null : '已清理 $removedDiscStreams 个光盘目录内部文件', ); if (selectedID != null) { unawaited(_hydrateMissingArtwork(selectedID, items)); } unawaited(refreshGlobalCloudIndex()); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } finally { state = state.copyWith(isLoading: false); } } Future selectLibrary(String id) async { final items = await _loadItems(id); state = state.copyWith( selectedLibraryID: id, items: items, searchQuery: '', clearError: true, ); unawaited(_hydrateMissingArtwork(id, items)); } Future createLibrary({ required String name, required String? rootID, required String rootPath, List? sources, MediaLibraryKind kind = MediaLibraryKind.mixed, bool recursive = true, int minimumSizeMB = 50, }) async { final trimmed = name.trim(); if (trimmed.isEmpty) return; final now = DateTime.now(); final id = now.microsecondsSinceEpoch.toString(); final library = MediaLibraryDefinition( id: id, name: trimmed, sources: sources ?? [ MediaLibrarySource( id: '$id-source-0', rootID: rootID, path: rootPath, ), ], kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB, updatedAt: now, ); final libraries = [...state.libraries, library]; await _saveLibraries(libraries); state = state.copyWith( libraries: libraries, selectedLibraryID: library.id, items: const [], statusMessage: '已创建媒体库「${library.name}」', ); } Future deleteLibrary(String id) async { final libraries = state.libraries .where((library) => library.id != id) .toList(); final allItems = await _loadAllItems() ..removeWhere((item) => item.libraryID == id); await _saveLibraries(libraries); await _saveAllItems(allItems); final selectedID = libraries.isEmpty ? null : libraries.first.id; state = state.copyWith( libraries: libraries, selectedLibraryID: selectedID, clearSelectedLibrary: selectedID == null, items: selectedID == null ? const [] : await _loadItems(selectedID), statusMessage: '媒体库已删除', ); } Future updateLibrary(MediaLibraryDefinition library) async { if (library.name.trim().isEmpty || library.sources.isEmpty) return; final libraries = state.libraries .map((item) => item.id == library.id ? library : item) .toList(); await _saveLibraries(libraries); state = state.copyWith( libraries: libraries, selectedLibraryID: library.id, items: await _loadItems(library.id), statusMessage: '媒体库「${library.name}」已更新', ); } Future importScrapedData(String backupPath) async { if (state.isLoading || state.isScanning) return; state = state.copyWith( isLoading: true, clearError: true, clearStatus: true, ); try { await _applyImportedBackup(backupPath); } catch (error) { state = state.copyWith(errorMessage: '导入失败:$error'); } finally { state = state.copyWith(isLoading: false); } } Future exportScrapedData(String destinationPath) async { if (state.isLoading || state.isScanning) return; state = state.copyWith( isLoading: true, clearError: true, clearStatus: true, ); try { await _store.exportBackupTo(destinationPath); state = state.copyWith(statusMessage: '刮削数据已导出'); } catch (error) { state = state.copyWith(errorMessage: '导出失败:$error'); } finally { state = state.copyWith(isLoading: false); } } Future exportScrapedDataToCloud() async { if (_api == null || state.isLoading || state.isScanning) return; _appendBackupLog('开始同步刮削数据到云盘'); state = state.copyWith( clearError: true, clearStatus: true, cloudBackupSync: const CloudBackupSyncProgress( phase: '正在准备备份', destination: '云盘根目录/小黄鸭备份', transferredBytes: 0, totalBytes: 0, isActive: true, ), ); Directory? temporaryDirectory; try { _appendBackupLog('正在确认云盘备份目录'); final destination = await _resolveCloudBackupDestination(); state = state.copyWith( cloudBackupSync: CloudBackupSyncProgress( phase: '正在导出本地数据库', destination: destination.path, transferredBytes: 0, totalBytes: 0, isActive: true, ), ); _appendBackupLog('备份目录已就绪:${destination.path}'); temporaryDirectory = await Directory.systemTemp.createTemp( 'guangya-media-', ); final backup = File( '${temporaryDirectory.path}/media-library-${DateTime.now().millisecondsSinceEpoch}.sqlite3', ); _appendBackupLog('正在导出本地刮削数据库'); await _store.exportBackupTo(backup.path); final size = await backup.length(); _appendBackupLog('本地数据库导出完成,大小 ${_formatBytes(size)}'); state = state.copyWith( cloudBackupSync: CloudBackupSyncProgress( phase: '正在上传备份', destination: '${destination.path}/${backup.uri.pathSegments.last}', transferredBytes: 0, totalBytes: size, isActive: true, ), ); _appendBackupLog('正在上传备份到 ${destination.path}'); await _api!.fileUpload( backup, 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, ), ); _appendBackupLog('云盘备份同步完成:$uploadedPath'); } catch (error) { final destination = state.cloudBackupSync?.destination ?? '云盘根目录/小黄鸭备份'; final reason = _backupFailureReason(error); _appendBackupLog('同步到云盘失败:$reason', isError: true, error: error); state = state.copyWith( errorMessage: '同步到云盘失败:$reason', cloudBackupSync: CloudBackupSyncProgress( phase: '同步失败', destination: destination, transferredBytes: 0, totalBytes: 0, isActive: false, error: reason, ), ); } finally { if (temporaryDirectory != null && await temporaryDirectory.exists()) { await temporaryDirectory.delete(recursive: true); } } } 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 []; _appendBackupLog('正在查找云盘中的 SQLite 备份'); state = state.copyWith( clearError: true, clearStatus: true, cloudBackupSync: const CloudBackupSyncProgress( phase: '正在查找云盘备份', destination: '云盘根目录/小黄鸭备份', transferredBytes: 0, totalBytes: 0, isActive: true, ), ); try { final response = await _api!.searchFiles( 'media-library.sqlite3', pageSize: 100, ); final backups = _extractFiles(response) .where( (file) => !file.isDirectory && file.name.toLowerCase().endsWith('.sqlite3'), ) .toList(); _appendBackupLog('云盘备份查找完成,找到 ${backups.length} 个文件'); state = state.copyWith( cloudBackupSync: const CloudBackupSyncProgress( phase: '请选择要恢复的备份', destination: '云盘根目录/小黄鸭备份', transferredBytes: 0, totalBytes: 0, isActive: false, ), ); return backups; } catch (error) { final reason = _backupFailureReason(error); _appendBackupLog('查找云盘备份失败:$reason', isError: true, error: error); state = state.copyWith( errorMessage: '查找云盘备份失败:$reason', cloudBackupSync: CloudBackupSyncProgress( phase: '恢复失败', destination: '云盘根目录/小黄鸭备份', transferredBytes: 0, totalBytes: 0, isActive: false, error: reason, ), ); rethrow; } } Future importScrapedDataFromCloud(CloudFile backup) async { if (_api == null || state.isLoading || state.isScanning) return; _appendBackupLog('开始从云盘恢复:${backup.name}'); state = state.copyWith( isLoading: true, clearError: true, clearStatus: true, cloudBackupSync: CloudBackupSyncProgress( phase: '正在获取备份下载地址', destination: backup.name, transferredBytes: 0, totalBytes: backup.size ?? 0, isActive: true, ), ); Directory? temporaryDirectory; try { final download = await _api!.downloadURL(backup.id); final url = _findStringDeep(download, const [ 'url', 'downloadUrl', 'download_url', 'dlink', ]); if (url == null || Uri.tryParse(url)?.hasScheme != true) { throw Exception('云盘未返回有效下载地址'); } temporaryDirectory = await Directory.systemTemp.createTemp( 'guangya-media-', ); final localBackup = File( '${temporaryDirectory.path}/media-library.sqlite3', ); _appendBackupLog('正在下载云盘备份:${backup.name}'); await Dio().download( url, localBackup.path, onReceiveProgress: (received, total) { state = state.copyWith( cloudBackupSync: CloudBackupSyncProgress( phase: '正在下载云盘备份', destination: backup.name, transferredBytes: received, totalBytes: total > 0 ? total : (backup.size ?? 0), isActive: true, ), ); }, ); _appendBackupLog('备份下载完成,正在导入本地数据库'); state = state.copyWith( cloudBackupSync: CloudBackupSyncProgress( phase: '正在导入刮削数据', destination: backup.name, transferredBytes: backup.size ?? 0, totalBytes: backup.size ?? 0, isActive: true, ), ); await _applyImportedBackup(localBackup.path); _appendBackupLog('云盘备份恢复完成:${backup.name}'); state = state.copyWith( cloudBackupSync: CloudBackupSyncProgress( phase: '恢复完成', destination: backup.name, transferredBytes: backup.size ?? 0, totalBytes: backup.size ?? 0, isActive: false, ), ); } catch (error) { final reason = _backupFailureReason(error); _appendBackupLog('从云盘恢复失败:$reason', isError: true, error: error); state = state.copyWith( errorMessage: '从云盘恢复失败:$reason', cloudBackupSync: CloudBackupSyncProgress( phase: '恢复失败', destination: backup.name, transferredBytes: 0, totalBytes: backup.size ?? 0, isActive: false, error: reason, ), ); } finally { if (temporaryDirectory != null && await temporaryDirectory.exists()) { await temporaryDirectory.delete(recursive: true); } state = state.copyWith(isLoading: false); } } Future _applyImportedBackup(String backupPath) async { final stats = await _store.importBackup(backupPath); final libraries = await _loadLibraries(); final selectedID = libraries.isEmpty ? null : libraries.first.id; state = state.copyWith( libraries: libraries, selectedLibraryID: selectedID, clearSelectedLibrary: selectedID == null, items: selectedID == null ? const [] : await _loadItems(selectedID), statusMessage: '刮削数据已导入,已回收 ${_formatBytes(stats.reclaimedBytes)}', ); if (selectedID != null) { unawaited(_hydrateMissingArtwork(selectedID, state.items)); } } Future optimizeLocalStorage() async { if (state.isLoading || state.isScanning) return; state = state.copyWith( isLoading: true, clearError: true, clearStatus: true, ); try { final stats = await _store.optimizeStorage(); state = state.copyWith( statusMessage: stats.removedArtworkCount == 0 ? '本地刮削数据库已是最优状态' : '已清理 ${stats.removedArtworkCount} 条本地图片缓存,回收 ${_formatBytes(stats.reclaimedBytes)}', ); } catch (error) { state = state.copyWith(errorMessage: '数据库优化失败:$error'); } finally { state = state.copyWith(isLoading: false); } } Future scanSelectedLibrary({bool forceRemote = false}) async { final library = state.selectedLibrary; if (library == null || _api == null || state.isScanning) return; _cancelScan = false; state = state.copyWith( isScanning: true, progress: const MediaLibraryScanProgress(phase: '准备扫描'), scanLogs: [ MediaLibraryScanLog( createdAt: DateTime.now(), message: forceRemote ? '任务已创建,正在重新扫描「${library.name}」' : '任务已创建,开始扫描「${library.name}」', ), ], clearError: true, clearStatus: true, ); try { final removedDiscStreams = await _removeDiscInternalItems(); if (removedDiscStreams > 0) { _appendScanLog('已清理 $removedDiscStreams 个已入库的光盘目录内部文件'); } final previousLibraryItems = await _loadItems(library.id); final existingByID = { for (final item in previousLibraryItems) item.id: item, }; final existingByGCID = { for (final item in previousLibraryItems) if (item.file.gcid?.isNotEmpty == true) item.file.gcid!: item, }; final initialItems = await _loadAllItems() ..removeWhere((item) => item.libraryID == library.id); final unique = { if (!forceRemote) for (final item in previousLibraryItems) item.file.id: item, }; state = state.copyWith(items: unique.values.toList()); final tmdbApiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; final tmdbProxyHost = StorageManager.get(StorageKeys.tmdbProxyHost) ?? ''; final tmdbProxyPort = StorageManager.get(StorageKeys.tmdbProxyPort) ?? ''; var completed = 0; Future pendingPersistence = Future.value(); Future indexBatch(List files) async { final pending = files.toList(); if (pending.isEmpty || _cancelScan) return; var next = 0; Future worker() async { while (!_cancelScan && next < pending.length) { final file = pending[next++]; final fallback = MediaLibraryItem.fromFile( library.id, file, directoryName: _parentDirectoryName(file.cloudPath), ); final existing = unique[file.id] ?? existingByID[file.id] ?? (file.gcid?.isNotEmpty == true ? existingByGCID[file.gcid!] : null); final sameCloudResource = existing != null && (existing.id == file.id || (file.gcid?.isNotEmpty == true && file.gcid == existing.file.gcid)); // Keep complete entries during a rescan. New resources and rows // missing scraped metadata follow the same automatic recognition, // detail hydration and canonical naming path as detail-page media // recognition. final shouldRecognize = existing == null || !sameCloudResource || _needsMediaRefresh(existing); var item = shouldRecognize ? await _recognizeMediaItem( fallback, tmdbApiKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort, ) : existing.copyWith(file: file, updatedAt: DateTime.now()); // Do not lose an existing match solely because a transient TMDB // lookup failed while filling an incomplete record. if (item.tmdbID == null && existing?.tmdbID != null) { item = existing!.copyWith(file: file, updatedAt: DateTime.now()); } item = await _renameMatchedMediaFile(item); unique[file.id] = item; completed += 1; final visible = unique.values.toList() ..sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); pendingPersistence = pendingPersistence.then( (_) => _upsertItems([item]), ); await pendingPersistence; state = state.copyWith( items: visible, progress: MediaLibraryScanProgress( phase: tmdbApiKey.isEmpty ? '正在建立本地索引' : '正在识别 ${file.name}', completed: completed, ), ); _appendScanLog( item.tmdbID == null ? '已入库:${file.name}(未匹配 TMDB)' : item.file.name == file.name ? '已识别并入库:${file.name} → ${item.title}' : '已识别并重命名:${file.name} → ${item.file.name}', ); } } final concurrency = (int.tryParse( StorageManager.get( StorageKeys.mediaScanConcurrency, ) ?? '3', ) ?? 3) .clamp(1, 20); await Future.wait(List.generate(concurrency, (_) => worker())); } if (forceRemote) { _appendScanLog('正在获取云盘全量文件索引…'); await refreshGlobalCloudIndex(force: true); final globalFiles = await _cachedGlobalFiles(); final seen = {}; final mediaFiles = []; for (final source in library.sources) { for (final file in globalFiles) { if (!file.isVideo || (file.size ?? 0) < library.minimumSizeMB * 1024 * 1024 || !_isWithinLibrarySource(file, source) || !seen.add(file.id)) { continue; } final path = _globalCloudPath(file, globalFiles, source.path); if (!isMediaScanDiscInternalPath(path)) { mediaFiles.add(_withPath(file, path)); } } } _appendScanLog('全量索引完成,发现 ${mediaFiles.length} 个媒体文件'); await indexBatch(mediaFiles); } else { for (final source in library.sources) { if (_cancelScan) break; _appendScanLog('扫描目录:${source.path}'); state = state.copyWith( progress: MediaLibraryScanProgress( phase: '扫描 ${source.path}', completed: completed, ), ); await _scanSource( source.rootID, source.path, recursive: library.recursive, minimumSizeBytes: library.minimumSizeMB * 1024 * 1024, forceRemote: false, onMediaFiles: indexBatch, ); } } await pendingPersistence; var items = unique.values.toList() ..sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); // A cancelled full rescan has only seen a subset of the cloud tree. Keep // unseen rows rather than incorrectly treating them as deleted. if (_cancelScan && forceRemote) { final merged = { for (final item in previousLibraryItems) item.id: item, for (final item in items) item.id: item, }; items = merged.values.toList() ..sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); } final allItems = [...initialItems, ...items]; final discoveredIDs = items.map((item) => item.id).toSet(); final removedMissing = _cancelScan || !forceRemote ? 0 : previousLibraryItems .where((item) => !discoveredIDs.contains(item.id)) .length; final updatedLibrary = library.copyWith(updatedAt: DateTime.now()); final libraries = state.libraries .map((item) => item.id == library.id ? updatedLibrary : item) .toList(); await _saveAllItems(allItems); await _saveLibraries(libraries); state = state.copyWith( libraries: libraries, items: items, statusMessage: _cancelScan ? '扫描已停止,已保留 ${items.length} 个项目' : removedMissing == 0 ? '扫描完成:${items.length} 个视频文件' : '重新扫描完成:${items.length} 个视频文件,已移除 $removedMissing 个不存在的条目', ); _appendScanLog( _cancelScan ? '扫描已停止,已保留 ${items.length} 个条目' : removedMissing == 0 ? '扫描完成,共入库 ${items.length} 个条目' : '重新扫描完成,共入库 ${items.length} 个条目,已清理 $removedMissing 个失效条目', ); } catch (e) { state = state.copyWith(errorMessage: e.toString()); _appendScanLog('扫描失败:$e', isError: true); } finally { state = state.copyWith( isScanning: false, progress: const MediaLibraryScanProgress(), ); unawaited(_persistScanHistory()); } } void cancelScan() { _cancelScan = true; state = state.copyWith( progress: const MediaLibraryScanProgress(phase: '正在停止扫描'), ); _appendScanLog('正在请求停止扫描…'); } Future rescanSelectedLibrary() => scanSelectedLibrary(forceRemote: true); void cancelDetailSync() { _cancelDetailSync = true; _appendScanLog('[同步识别][调试] 已请求取消同步识别'); } Future refreshGlobalCloudIndex({bool force = false}) async { if (_api == null) { AppLogger.warning('CloudIndex', '无法刷新全盘文件索引:云盘接口尚未初始化'); return; } if (_refreshingCloudIndex) { AppLogger.debug('CloudIndex', '全盘文件索引正在刷新,本次请求已合并'); return; } final minutes = (int.tryParse( StorageManager.get( StorageKeys.cloudIndexRefreshMinutes, ) ?? '30', ) ?? 30) .clamp(5, 1440); final lastUpdated = int.tryParse( StorageManager.get(StorageKeys.cloudIndexLastUpdatedAt) ?? '', ); final rootSnapshot = await FileMetadataCache.folderChildren(null); final needsFullRebuild = force || lastUpdated == null || rootSnapshot == null; if (!force && !needsFullRebuild) { final elapsed = DateTime.now().difference( DateTime.fromMillisecondsSinceEpoch(lastUpdated), ); if (elapsed < Duration(minutes: minutes)) { final remaining = Duration(minutes: minutes) - elapsed; AppLogger.info( 'CloudIndex', '全盘文件索引缓存仍有效,已跳过本次刷新;约 ${remaining.inMinutes + 1} 分钟后自动更新', ); _scheduleCloudIndexRefresh(minutes); return; } } _refreshingCloudIndex = true; state = state.copyWith(isRefreshingCloudIndex: true); try { 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 StorageManager.set( StorageKeys.cloudIndexLastUpdatedAt, DateTime.now().millisecondsSinceEpoch.toString(), ); _appendScanLog( needsFullRebuild ? '[云盘索引] 已建立 ${result.updatedFolders} 个目录、${result.updatedEntries} 项缓存' : '[云盘索引] 已检查 ${result.checkedFolders} 个目录,更新 ${result.updatedFolders} 个目录', ); } catch (error) { AppLogger.error('CloudIndex', '刷新全盘文件索引失败', error: error); _appendScanLog('[云盘索引] 刷新失败:$error', isError: true); } finally { _refreshingCloudIndex = false; state = state.copyWith(isRefreshingCloudIndex: false); _scheduleCloudIndexRefresh(minutes); } } void _scheduleCloudIndexRefresh(int minutes) { _cloudIndexTimer?.cancel(); _cloudIndexTimer = Timer(Duration(minutes: minutes), () { unawaited(refreshGlobalCloudIndex()); }); } void updateCloudIndexRefreshSchedule() { final minutes = (int.tryParse( StorageManager.get( StorageKeys.cloudIndexRefreshMinutes, ) ?? '30', ) ?? 30) .clamp(5, 1440); _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(); super.dispose(); } void _appendScanLog(String message, {bool isError = false}) { if (isError) { AppLogger.error('Media', message); } else { AppLogger.info('Media', message); } final logs = [ ...state.scanLogs, MediaLibraryScanLog( createdAt: DateTime.now(), message: message, isError: isError, ), ]; if (logs.length > 120) logs.removeRange(0, logs.length - 120); state = state.copyWith(scanLogs: logs); } void _appendBackupLog(String message, {bool isError = false, Object? error}) { if (isError) { AppLogger.error('Backup', message, error: error); } else { AppLogger.info('Backup', message); } final logs = [ ...state.scanLogs, MediaLibraryScanLog( createdAt: DateTime.now(), message: '【数据备份】$message', isError: isError, ), ]; if (logs.length > 120) logs.removeRange(0, logs.length - 120); state = state.copyWith(scanLogs: logs); unawaited(_persistScanHistory()); } String _backupFailureReason(Object error) { final value = error.toString().trim(); return value .replaceFirst(RegExp(r'^Exception:\s*'), '') .replaceFirst(RegExp(r'^DioException \[[^\]]+\]:\s*'), '网络请求失败:'); } List _loadScanHistory() { final raw = StorageManager.get(StorageKeys.mediaScanHistory); if (raw is! List) return const []; return raw .whereType() .map( (entry) => MediaLibraryScanLog.fromJson(Map.from(entry)), ) .where((entry) => entry.message.isNotEmpty) .take(120) .toList(); } Future _persistScanHistory() => StorageManager.set( StorageKeys.mediaScanHistory, state.scanLogs.map((entry) => entry.toJson()).toList(), ); void setSearchQuery(String query) { state = state.copyWith(searchQuery: query); } Future> searchAllItems(String query) async { final normalized = query.trim().toLowerCase(); if (normalized.isEmpty) return const []; final cached = _searchResultsCache[normalized]; if (cached != null) return cached; final results = (await _loadAllItems()).where((item) { return item.title.toLowerCase().contains(normalized) || item.file.name.toLowerCase().contains(normalized) || item.file.cloudPath.toLowerCase().contains(normalized); }).toList()..sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); _searchResultsCache[normalized] = results; return results; } Future recognizeItems(Iterable values) async { final apiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; if (_api == null || apiKey.trim().isEmpty) return; final groups = >{}; for (final item in values) { final parsed = ParsedMediaName.parse( item.file.name, directoryName: _parentDirectoryName(item.file.cloudPath), ); final key = '${item.mediaKind?.name ?? 'automatic'}:${parsed.title.toLowerCase()}'; groups.putIfAbsent(key, () => []).add(item); } final updates = []; for (final group in groups.values) { final prototype = group.first; final fallback = MediaLibraryItem.fromFile( prototype.libraryID, prototype.file, directoryName: _parentDirectoryName(prototype.file.cloudPath), ); final recognized = await _recognizeMediaItem( fallback, apiKey, proxyHost: StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', proxyPort: StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', ); final resolved = recognized.tmdbID == null && prototype.tmdbID != null ? prototype : recognized; updates.addAll(group.map((item) => resolved.copyWith(file: item.file))); } if (updates.isEmpty) return; await _store.upsertItems(updates); _searchResultsCache.clear(); final byID = {for (final item in updates) item.id: item}; state = state.copyWith( items: state.items.map((item) => byID[item.id] ?? item).toList(), statusMessage: '已识别 ${updates.length} 个资源(${groups.length} 个作品)', ); } /// Pulls current file metadata from the cloud before parsing and matching it. /// This is required after a file was renamed outside the media scanner. Future> refreshAndRecognizeItems( Iterable values, ) async { if (_api == null) return const []; final originals = values.toList(); if (originals.isEmpty) return const []; _cancelDetailSync = false; final apiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; state = state.copyWith(statusMessage: '正在同步云盘文件信息…', clearError: true); final synced = <_SyncedMediaItem>[]; final failures = []; for (final original in originals) { if (_cancelDetailSync) break; try { _appendScanLog( '[同步识别][调试] 开始:${original.file.name} ' '(fileId=${original.id}, gcid=${original.file.gcid ?? '-'})', ); final latestFile = await _resolveCurrentCloudFile( original.file, libraryID: original.libraryID, ); if (latestFile == null) { throw StateError('云盘中未找到该文件'); } _appendScanLog( '[同步识别][调试] 云盘已同步:文件 ID ${original.id} -> ' '${latestFile.id},名称=${latestFile.name},gcid=${latestFile.gcid ?? '-'}', ); synced.add( _SyncedMediaItem( original: original, fallback: MediaLibraryItem.fromFile( original.libraryID, latestFile, directoryName: _parentDirectoryName(latestFile.cloudPath), ), ), ); } catch (error) { failures.add('${original.file.name}: $error'); _appendScanLog( '[同步识别][调试] 同步失败:${original.file.name},$error', isError: true, ); } } if (synced.isEmpty) { state = state.copyWith( errorMessage: _cancelDetailSync ? null : failures.isEmpty ? '未能同步云盘文件信息' : '同步失败:${failures.first}', statusMessage: _cancelDetailSync ? '同步识别已取消' : '未能同步云盘文件信息', ); return const []; } state = state.copyWith(statusMessage: '正在自动识别并规范命名…'); final updates = []; final replacements = {}; final pendingMatches = []; var recognizedCount = 0; var renamedCount = 0; for (final entry in synced) { if (_cancelDetailSync) break; final original = entry.original; try { final fallback = entry.fallback; final parsed = ParsedMediaName.parse( fallback.file.name, directoryName: _parentDirectoryName(fallback.file.cloudPath), ); _appendScanLog( '[同步识别][调试] 解析:${fallback.file.name} -> ' '标题=${parsed.title},年份=${parsed.year ?? '-'},' '类型=${fallback.mediaKind?.name ?? 'automatic'}', ); var updated = fallback; if (apiKey.trim().isNotEmpty) { final candidates = await _tmdbCandidatesForItem( fallback, apiKey, proxyHost: StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', proxyPort: StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', ); if (candidates.length == 1) { updated = await _applyTMDBCandidateAndDetails( fallback, candidates.single, apiKey, proxyHost: StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', proxyPort: StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', ); } else if (candidates.length > 1 && original.tmdbID == null) { pendingMatches.add( MediaTMDBMatchRequest(items: [fallback], candidates: candidates), ); _appendScanLog( '[同步识别][调试] 存在 ${candidates.length} 个 TMDB 候选,等待用户选择', ); } } if (updated.tmdbID != null) { recognizedCount++; _appendScanLog( '[同步识别][调试] TMDB 命中:${updated.title} ' '(tmdbId=${updated.tmdbID}, ${updated.year.isEmpty ? '年份未知' : updated.year})', ); } else { _appendScanLog( '[同步识别][调试] TMDB 未命中:${parsed.title} ' '(年份=${parsed.year ?? '-'})', ); } // A temporary TMDB or network failure must not erase a previously // scraped item just because its refreshed file metadata was available. if (updated.tmdbID == null && original.tmdbID != null) { updated = original.copyWith( file: fallback.file, updatedAt: DateTime.now(), ); } final beforeName = updated.file.name; updated = await _renameMatchedMediaFile(updated); if (updated.file.name != beforeName) { renamedCount++; _appendScanLog( '[同步识别][调试] 已规范命名:$beforeName -> ${updated.file.name}', ); } else if (updated.tmdbID != null) { _appendScanLog('[同步识别][调试] 跳过规范命名:未解析到分辨率或名称已符合规则'); } updates.add(updated); replacements['${original.libraryID}:${original.id}'] = updated; } catch (error) { failures.add('${original.file.name}: $error'); _appendScanLog( '[同步识别][调试] 识别失败:${original.file.name},$error', isError: true, ); } } if (updates.isNotEmpty) { await _replaceItemsByPreviousIDs(replacements); _searchResultsCache.clear(); final byID = {for (final item in updates) item.id: item}; state = state.copyWith( items: state.items .map( (item) => replacements['${item.libraryID}:${item.id}'] ?? byID[item.id] ?? item, ) .toList(), statusMessage: apiKey.trim().isEmpty ? '已同步 ${updates.length} 个资源,未配置 TMDB,未执行自动识别' : _cancelDetailSync ? '同步识别已取消,已处理 ${updates.length} 个资源' : failures.isEmpty ? '已同步 ${updates.length} 个资源,自动识别 $recognizedCount 个,规范命名 $renamedCount 个' : '已同步 ${updates.length} 个资源,识别 $recognizedCount 个,规范命名 $renamedCount 个,${failures.length} 个失败', errorMessage: failures.isEmpty ? null : failures.first, ); } return pendingMatches; } /// Keeps SQLite media records aligned with file-manager rename operations /// without re-scraping or overriding a user-initiated name change. Future synchronizeRenamedFiles(Iterable values) async { if (_api == null) return; final renamed = {for (final file in values) file.id: file}.values.toList(); if (renamed.isEmpty) return; final allItems = await _loadAllItems(); final replacements = {}; for (final renamedFile in renamed) { final oldPath = renamedFile.cloudPath; final newPath = _cloudPathWithName(oldPath, renamedFile.name); if (renamedFile.isDirectory) { for (final item in allItems) { final path = item.file.cloudPath; if (path != oldPath && !path.startsWith('$oldPath/')) continue; final suffix = path.substring(oldPath.length); final updated = item.copyWith( file: item.file.copyWith(cloudPath: '$newPath$suffix'), updatedAt: DateTime.now(), ); replacements['${item.libraryID}:${item.id}'] = updated; } final resolved = await _resolveCurrentCloudFile(renamedFile); if (resolved != null && resolved.id != renamedFile.id) { await FileMetadataCache.updateFolderChildren( renamedFile.id, invalidate: true, ); } continue; } for (final item in allItems.where((item) => item.id == renamedFile.id)) { final known = item.file.copyWith( name: renamedFile.name, cloudPath: newPath, parentID: renamedFile.parentID, fullParentIDs: renamedFile.fullParentIDs, ); final resolved = await _resolveCurrentCloudFile(known, libraryID: item.libraryID) ?? known; replacements['${item.libraryID}:${item.id}'] = item.copyWith( file: resolved, updatedAt: DateTime.now(), ); } } if (replacements.isEmpty) return; await _replaceItemsByPreviousIDs(replacements); _searchResultsCache.clear(); state = state.copyWith( items: state.items .map((item) => replacements['${item.libraryID}:${item.id}'] ?? item) .toList(), ); } Future applyTMDBMatch( MediaLibraryItem item, Map candidate, ) async { final apiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; final manualSeason = _toInt(candidate['_manualSeason']); final manualEpisode = _toInt(candidate['_manualEpisode']); var updated = _itemFromTMDBCandidate(item, candidate); if (updated.tmdbID != null && apiKey.isNotEmpty) { try { final details = await _tmdbDetails( updated.tmdbID!, updated.mediaKind, apiKey: apiKey, proxyHost: StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', proxyPort: StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', ); updated = _itemFromTMDBDetails(updated, details); } catch (_) { // A selected candidate is still valid if its detail request fails. } } updated = await _renameMatchedMediaFile( updated, manualSeason: manualSeason, manualEpisode: manualEpisode, ); await _replaceItemsByPreviousIDs({'${item.libraryID}:${item.id}': updated}); state = state.copyWith( items: state.items .map( (current) => current.libraryID == item.libraryID && current.id == item.id ? updated : current, ) .toList(), statusMessage: updated.file.name == item.file.name ? '已匹配《${updated.title}》' : '已匹配并规范命名《${updated.title}》', clearError: true, ); return updated; } /// Refresh legacy unmatched records with the current filename parser before /// showing a manual TMDB search. Matched items retain TMDB's canonical title. Future> refreshParsedTitles( Iterable values, ) async { final updates = []; for (final item in values) { if (item.tmdbID != null) { updates.add(item); continue; } final parsed = ParsedMediaName.parse( item.file.name, directoryName: _parentDirectoryName(item.file.cloudPath), ); final title = parsed.title.trim(); if (title.isEmpty) { updates.add(item); continue; } updates.add( item.copyWith( title: title, originalTitle: title, releaseDate: parsed.year == null ? item.releaseDate : '${parsed.year}-01-01', updatedAt: DateTime.now(), ), ); } if (updates.isEmpty) return const []; final replacements = { for (final item in updates) '${item.libraryID}:${item.id}': item, }; await _replaceItemsByPreviousIDs(replacements); _searchResultsCache.clear(); state = state.copyWith( items: state.items .map((item) => replacements['${item.libraryID}:${item.id}'] ?? item) .toList(), ); return updates; } /// Old scrape backups stored artwork as BLOBs. The compact SQLite schema /// deliberately drops those images, so restore the durable TMDB paths from /// their retained IDs in the background after an import or first load. Future _hydrateMissingArtwork( String libraryID, List items, ) async { if (_api == null || _artworkHydrationLibraries.contains(libraryID)) return; final apiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; if (apiKey.trim().isEmpty) return; final missingByTMDB = >{}; for (final item in items) { final id = item.tmdbID; final kind = item.mediaKind; final missingPoster = item.posterPath == null || item.posterPath!.isEmpty; final missingBackdrop = item.backdropPath == null || item.backdropPath!.isEmpty; if (id == null || kind == null || (!missingPoster && !missingBackdrop)) { continue; } missingByTMDB.putIfAbsent('${kind.name}:$id', () => []).add(item); } if (missingByTMDB.isEmpty) return; _artworkHydrationLibraries.add(libraryID); final proxyHost = StorageManager.get(StorageKeys.tmdbProxyHost) ?? ''; final proxyPort = StorageManager.get(StorageKeys.tmdbProxyPort) ?? ''; final concurrency = (int.tryParse( StorageManager.get( StorageKeys.mediaScanConcurrency, ) ?? '3', ) ?? 3) .clamp(1, 12); final groups = missingByTMDB.entries.toList(); var completed = 0; try { for (var start = 0; start < groups.length; start += concurrency) { final batch = groups.sublist( start, (start + concurrency).clamp(0, groups.length), ); final results = await Future.wait( batch.map((entry) async { final prototype = entry.value.first; try { final details = await _tmdbDetails( prototype.tmdbID!, prototype.mediaKind, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort, ); return entry.value .map((item) => _itemFromTMDBDetails(item, details)) .toList(); } catch (_) { return const []; } }), ); final updates = results.expand((items) => items).toList(); completed += batch.length; if (updates.isNotEmpty) await _store.upsertItems(updates); if (state.selectedLibraryID == libraryID) { final updatedByID = {for (final item in updates) item.id: item}; state = state.copyWith( items: state.items .map((item) => updatedByID[item.id] ?? item) .toList(), statusMessage: completed == groups.length ? '已补齐 ${groups.length} 个影视条目的海报与横幅' : '正在补齐影视图片 $completed/${groups.length}', ); } } } finally { _artworkHydrationLibraries.remove(libraryID); } } void clearError() { state = state.copyWith(clearError: true); } Future _recognizeMediaItem( MediaLibraryItem fallback, String apiKey, { required String proxyHost, required String proxyPort, }) async { if (_api == null || apiKey.trim().isEmpty) return fallback; try { final parsed = ParsedMediaName.parse( fallback.file.name, directoryName: _parentDirectoryName(fallback.file.cloudPath), ); final requestedKind = fallback.mediaKind == TMDBMediaKind.tv ? 'tv' : fallback.mediaKind == TMDBMediaKind.movie ? 'movie' : 'auto'; final result = await _api!.tmdbSearch( fallback.title, apiKey: apiKey, mediaKind: requestedKind, proxyHost: proxyHost, proxyPort: proxyPort, year: parsed.year, ); final values = result['results']; if (values is! List) return fallback; final expectedType = requestedKind == 'auto' ? null : requestedKind; final normalizedTitle = _normalizeMediaTitle(fallback.title); if (normalizedTitle.isEmpty) return fallback; Map? candidate; var bestScore = -1; for (final value in values) { if (value is! Map) continue; final map = Map.from(value); final type = map['media_type']?.toString() ?? expectedType; if (type != 'movie' && type != 'tv') continue; final releaseDate = (map['release_date'] ?? map['first_air_date'])?.toString() ?? ''; // An explicit year in the file name is authoritative. Do not silently // attach a remake or same-title work from a different year. if (parsed.year != null && !releaseDate.startsWith('${parsed.year}')) { continue; } final candidateTitle = _normalizeMediaTitle( (map['title'] ?? map['name'] ?? '').toString(), ); final originalTitle = _normalizeMediaTitle( (map['original_title'] ?? map['original_name'] ?? '').toString(), ); var score = 0; if (candidateTitle == normalizedTitle || originalTitle == normalizedTitle) { score += 100; } else if (candidateTitle.contains(normalizedTitle) || normalizedTitle.contains(candidateTitle) || originalTitle.contains(normalizedTitle) || normalizedTitle.contains(originalTitle)) { score += 40; } if (score == 0) continue; if (parsed.year != null && releaseDate.startsWith('${parsed.year}')) { score += 30; } if (score > bestScore) { bestScore = score; map['media_type'] = type; candidate = map; } } if (candidate == null) return fallback; var item = _itemFromTMDBCandidate(fallback, candidate); if (item.tmdbID == null) return item; try { final details = await _tmdbDetails( item.tmdbID!, item.mediaKind, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort, ); item = _itemFromTMDBDetails(item, details); } catch (_) { // The search result already has enough information for an initial row. } return item; } catch (_) { return fallback; } } bool _needsMediaRefresh(MediaLibraryItem item) { return item.tmdbID == null || item.mediaKind == null || item.title.trim().isEmpty || item.originalTitle.trim().isEmpty || item.overview.trim().isEmpty || item.releaseDate.trim().isEmpty || item.posterPath?.isEmpty != false || item.backdropPath?.isEmpty != false; } Future>> _tmdbCandidatesForItem( MediaLibraryItem fallback, String apiKey, { required String proxyHost, required String proxyPort, }) async { if (_api == null || apiKey.trim().isEmpty) return const []; final parsed = ParsedMediaName.parse( fallback.file.name, directoryName: _parentDirectoryName(fallback.file.cloudPath), ); final requestedKind = fallback.mediaKind == TMDBMediaKind.tv ? 'tv' : fallback.mediaKind == TMDBMediaKind.movie ? 'movie' : 'auto'; final result = await _api!.tmdbSearch( fallback.title, apiKey: apiKey, mediaKind: requestedKind, proxyHost: proxyHost, proxyPort: proxyPort, year: parsed.year, ); final values = result['results']; if (values is! List) return const []; final expectedType = requestedKind == 'auto' ? null : requestedKind; final normalizedTitle = _normalizeMediaTitle(fallback.title); if (normalizedTitle.isEmpty) return const []; final scored = <({int score, Map candidate})>[]; for (final value in values) { if (value is! Map) continue; final candidate = Map.from(value); final type = candidate['media_type']?.toString() ?? expectedType; if (type != 'movie' && type != 'tv') continue; final releaseDate = (candidate['release_date'] ?? candidate['first_air_date']) ?.toString() ?? ''; if (parsed.year != null && !releaseDate.startsWith('${parsed.year}')) { continue; } final title = _normalizeMediaTitle( (candidate['title'] ?? candidate['name'] ?? '').toString(), ); final originalTitle = _normalizeMediaTitle( (candidate['original_title'] ?? candidate['original_name'] ?? '') .toString(), ); var score = 0; if (title == normalizedTitle || originalTitle == normalizedTitle) { score = 100; } else if (title.contains(normalizedTitle) || normalizedTitle.contains(title) || originalTitle.contains(normalizedTitle) || normalizedTitle.contains(originalTitle)) { score = 40; } if (score == 0) continue; if (parsed.year != null) score += 30; candidate['media_type'] = type; scored.add((score: score, candidate: candidate)); } scored.sort((a, b) => b.score.compareTo(a.score)); return scored.map((entry) => entry.candidate).toList(); } Future _applyTMDBCandidateAndDetails( MediaLibraryItem fallback, Map candidate, String apiKey, { required String proxyHost, required String proxyPort, }) async { var item = _itemFromTMDBCandidate(fallback, candidate); if (item.tmdbID == null) return item; try { final details = await _tmdbDetails( item.tmdbID!, item.mediaKind, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort, ); item = _itemFromTMDBDetails(item, details); } catch (_) { // The selected search candidate remains usable when detail hydration fails. } return item; } Future _renameMatchedMediaFile( MediaLibraryItem item, { int? manualSeason, int? manualEpisode, }) async { if (_api == null || item.tmdbID == null || item.mediaKind == null) { return item; } final directoryName = _parentDirectoryName(item.file.cloudPath); // Parse with the parent context first: numeric/disc file names commonly // keep title, year, season and release tags only on their parent folder. final fileParsed = ParsedMediaName.parse( item.file.name, directoryName: directoryName, ); final parentParsed = directoryName == null ? null : ParsedMediaName.parse(directoryName); final parsed = ParsedMediaName( title: fileParsed.title, year: fileParsed.year ?? parentParsed?.year, season: manualSeason ?? fileParsed.season ?? parentParsed?.season, episode: manualEpisode ?? fileParsed.episode, isEpisode: (manualSeason ?? fileParsed.season ?? parentParsed?.season) != null && (manualEpisode ?? fileParsed.episode) != null, resolution: fileParsed.resolution ?? parentParsed?.resolution, source: fileParsed.source ?? parentParsed?.source, videoCodec: fileParsed.videoCodec ?? parentParsed?.videoCodec, audio: fileParsed.audio ?? parentParsed?.audio, dynamicRange: fileParsed.dynamicRange ?? parentParsed?.dynamicRange, ); // A canonical media name must retain a resolution. If neither the file nor // its immediate directory provides one, leave the resource untouched. if (parsed.resolution == null || parsed.resolution!.isEmpty) return item; final extension = _extensionOf(item.file.name); final year = item.year.isEmpty ? '' : '.${item.year}'; final episode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode ? '.S${parsed.season!.toString().padLeft(2, '0')}E${parsed.episode!.toString().padLeft(2, '0')}' : ''; final technical = [ parsed.resolution!, if (parsed.source?.isNotEmpty == true) parsed.source!, if (parsed.dynamicRange?.isNotEmpty == true) parsed.dynamicRange!, if (parsed.videoCodec?.isNotEmpty == true) parsed.videoCodec!, if (parsed.audio?.isNotEmpty == true) parsed.audio!, ].join('.'); final targetName = '${_safeCloudName('${item.title}$year$episode.$technical')}${extension.isEmpty ? '' : '.$extension'}'; if (targetName == item.file.name) return item; try { await _api!.fsRename(item.file.id, targetName); final renamedFile = await _resolveCurrentCloudFile( item.file.copyWith(name: targetName), ) ?? item.file.copyWith(name: targetName); final updated = item.copyWith(file: renamedFile); await _writeNfoForRenamedMedia(updated, parsed); return updated; } catch (_) { return item; } } Future _writeNfoForRenamedMedia( MediaLibraryItem item, ParsedMediaName parsed, ) async { try { final detail = await _api!.fsDetail(item.file.id); final parentID = _findStringDeep(detail, const [ 'parentId', 'parent_id', 'parentFileId', ]); if (parentID == null || parentID.isEmpty) return; final isEpisode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode; final nfoName = isEpisode ? '${_safeCloudName(item.title)}.S${parsed.season!.toString().padLeft(2, '0')}E${parsed.episode!.toString().padLeft(2, '0')}.nfo' : item.mediaKind == TMDBMediaKind.tv ? 'tvshow.nfo' : 'movie.nfo'; final root = isEpisode ? 'episodedetails' : item.mediaKind == TMDBMediaKind.tv ? 'tvshow' : 'movie'; final technical = [ if (parsed.resolution != null) '${_xml(parsed.resolution!)}', if (parsed.source != null) '${_xml(parsed.source!)}', if (parsed.videoCodec != null) '${_xml(parsed.videoCodec!)}', if (parsed.dynamicRange != null) '${_xml(parsed.dynamicRange!)}', if (parsed.audio != null) '', ].join(); final episodeFields = isEpisode ? '${parsed.season}${parsed.episode}' : ''; final xml = '' '<$root>${item.tmdbID}' '${_xml(item.title)}' '${_xml(item.originalTitle)}' '${_xml(item.year)}' '${_xml(item.overview)}$episodeFields' '' ''; final temp = File('${Directory.systemTemp.path}/$nfoName'); await temp.writeAsString(xml, flush: true); try { await _api!.fileUpload( temp, parentID: parentID, contentType: 'application/xml', ); } finally { if (await temp.exists()) await temp.delete(); } } catch (_) { // File renaming remains successful if sidecar upload is unavailable. } } String _extensionOf(String value) { final index = value.lastIndexOf('.'); return index <= 0 ? '' : value.substring(index + 1).toLowerCase(); } String? _parentDirectoryName(String cloudPath) { final values = cloudPath .split(RegExp(r'[\\/]')) .where((part) => part.isNotEmpty) .toList(); return values.length < 2 ? null : values[values.length - 2]; } String _parentPath(String cloudPath) { final index = cloudPath.lastIndexOf(RegExp(r'[\\/]')); return index <= 0 ? '' : cloudPath.substring(0, index); } String _cloudPathWithName(String cloudPath, String name) { final parentPath = _parentPath(cloudPath); return parentPath.isEmpty ? name : '$parentPath/$name'; } String _normalizeMediaTitle(String value) { return value.toLowerCase().replaceAll( RegExp(r'[^a-z0-9\u4e00-\u9fff]+'), '', ); } String _safeCloudName(String value) { final cleaned = value .replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), ' ') .replaceAll(RegExp(r'\s+'), ' ') .trim(); final fallback = cleaned.isEmpty ? 'Untitled' : cleaned; return fallback.length > 180 ? fallback.substring(0, 180).trim() : fallback; } String _xml(String value) => value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); MediaLibraryItem _itemFromTMDBCandidate( MediaLibraryItem fallback, Map candidate, ) { final type = candidate['media_type']?.toString(); final title = (candidate['title'] ?? candidate['name'])?.toString().trim(); final originalTitle = (candidate['original_title'] ?? candidate['original_name']) ?.toString() .trim(); final releaseDate = (candidate['release_date'] ?? candidate['first_air_date']) ?.toString() ?? ''; return fallback.copyWith( tmdbID: _toInt(candidate['id']), title: title == null || title.isEmpty ? fallback.title : title, originalTitle: originalTitle == null || originalTitle.isEmpty ? fallback.originalTitle : originalTitle, mediaKind: type == 'tv' ? TMDBMediaKind.tv : TMDBMediaKind.movie, releaseDate: releaseDate, overview: candidate['overview']?.toString() ?? '', posterPath: candidate['poster_path']?.toString(), backdropPath: candidate['backdrop_path']?.toString(), updatedAt: DateTime.now(), ); } Future> _tmdbDetails( int id, TMDBMediaKind? kind, { required String apiKey, required String proxyHost, required String proxyPort, }) { final mediaKind = kind == TMDBMediaKind.tv ? 'tv' : 'movie'; final key = '$mediaKind:$id'; return _tmdbDetailsRequests.putIfAbsent( key, () => _api!.tmdbDetails( id, mediaKind: mediaKind, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort, ), ); } MediaLibraryItem _itemFromTMDBDetails( MediaLibraryItem item, Map details, ) { final title = (details['title'] ?? details['name'])?.toString().trim(); final originalTitle = (details['original_title'] ?? details['original_name']) ?.toString() .trim(); final releaseDate = (details['release_date'] ?? details['first_air_date'])?.toString() ?? item.releaseDate; final collection = details['belongs_to_collection']; final collectionMap = collection is Map ? Map.from(collection) : const {}; return item.copyWith( title: title == null || title.isEmpty ? item.title : title, originalTitle: originalTitle == null || originalTitle.isEmpty ? item.originalTitle : originalTitle, releaseDate: releaseDate, overview: details['overview']?.toString().trim().isNotEmpty == true ? details['overview'].toString() : item.overview, posterPath: _preferredArtworkPath(details, 'posters') ?? details['poster_path']?.toString() ?? item.posterPath, backdropPath: _preferredArtworkPath(details, 'backdrops') ?? details['backdrop_path']?.toString() ?? item.backdropPath, collectionID: _toInt(collectionMap['id']) ?? item.collectionID, collectionName: collectionMap['name']?.toString() ?? item.collectionName, updatedAt: DateTime.now(), ); } String? _preferredArtworkPath(Map details, String key) { final images = details['images']; if (images is! Map) return null; final values = images[key]; if (values is! List) return null; const languages = ['zh-CN', 'zh', null, 'en']; for (final language in languages) { for (final value in values) { if (value is! Map || value['iso_639_1'] != language) continue; final path = value['file_path']?.toString(); if (path != null && path.isNotEmpty) return path; } } for (final value in values) { if (value is! Map) continue; final path = value['file_path']?.toString(); if (path != null && path.isNotEmpty) return path; } return null; } int? _toInt(dynamic value) { if (value is int) return value; return int.tryParse(value?.toString() ?? ''); } String _formatBytes(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (bytes < 1024 * 1024 * 1024) { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; } Future> _allGlobalRemoteFiles() async { final batches = await Future.wait([ _allGlobalRemoteFilesByType(), _allGlobalRemoteFilesByType(resType: 2), ]); final unique = { for (final file in batches.expand((files) => files)) file.id: file, }; final values = unique.values.toList(); await FileMetadataCache.cacheFiles(values); return values; } Future> _cachedGlobalFiles() async { final values = await FileMetadataCache.allCachedFolderChildren(); if (values.isEmpty) return _allGlobalRemoteFiles(); return values; } Future> _allGlobalRemoteFilesByType({int? resType}) async { // get_file_list 会将任意更大的 pageSize 静默限制为 1000;若用请求值 // 10000 判断末页,会在第一页被错误地视为已读完。 const pageSize = 1000; final values = []; final seenIDs = {}; for (var page = 0; !_cancelScan; page++) { final typeLabel = resType == 2 ? '目录' : '文件'; AppLogger.info('CloudIndex', '正在获取全盘$typeLabel索引,第 ${page + 1} 页'); final response = await _api!.fsFiles( parentID: '*', page: page, pageSize: pageSize, orderBy: 0, sortType: 0, resType: resType, ); final batch = _extractFiles(response); final added = batch.where((file) => seenIDs.add(file.id)).toList(); values.addAll(added); AppLogger.info( 'CloudIndex', '全盘$typeLabel索引第 ${page + 1} 页完成,获取 ${batch.length} 项,新增 ${added.length} 项,累计 ${values.length} 项', ); if (batch.length < pageSize) break; if (added.isEmpty) { AppLogger.warning( 'CloudIndex', '全盘$typeLabel索引第 ${page + 1} 页与上一页重复,已停止继续分页', ); break; } } return values; } bool _isWithinLibrarySource(CloudFile file, MediaLibrarySource source) { final rootID = source.rootID; if (rootID == null || rootID.isEmpty) return true; if (file.parentID == rootID || file.id == rootID) return true; return (file.fullParentIDs ?? '') .split('/') .where((id) => id.isNotEmpty) .contains(rootID); } String _globalCloudPath( CloudFile file, Iterable allFiles, String sourcePath, ) { final namesByID = {for (final item in allFiles) item.id: item.name}; final parents = (file.fullParentIDs ?? '') .split('/') .where((id) => id.isNotEmpty) .map((id) => namesByID[id]) .whereType() .toList(); if (parents.isEmpty) return '$sourcePath/${file.name}'; return '/${[...parents, file.name].join('/')}'; } Future _scanSource( String? rootID, String rootPath, { required bool recursive, required int minimumSizeBytes, required bool forceRemote, required Future Function(List files) onMediaFiles, }) async { final folders = <_ScanFolder>[_ScanFolder(rootID, rootPath)]; final visited = {}; var discovered = 0; while (folders.isNotEmpty && !_cancelScan) { final folder = folders.removeAt(0); final visitKey = folder.id ?? 'root'; if (!visited.add(visitKey)) continue; // A library source can point at BDMV/VIDEO_TS directly. There is no // parent disc folder to inspect in that case, so skip it before listing // and scraping its internal transport streams. if (isMediaScanDiscInternalPath(folder.path)) { _appendScanLog('跳过光盘目录内部文件:${folder.path}'); continue; } var page = 0; final cachedFolder = forceRemote ? null : await FileMetadataCache.folderChildren(folder.id); final folderSnapshot = []; while (!_cancelScan) { late List files; if (cachedFolder != null) { files = cachedFolder; } else { AppLogger.info('Media', '正在读取目录「${folder.path}」第 ${page + 1} 页'); final response = await _api!.fsFiles( parentID: folder.id, page: page, pageSize: 200, orderBy: 0, sortType: 0, ); files = _extractFiles(response); AppLogger.info( 'Media', '目录「${folder.path}」第 ${page + 1} 页读取完成,获取 ${files.length} 项', ); files = await _enrichAndCacheFiles(files); folderSnapshot.addAll(files); } // A folder containing BDMV or VIDEO_TS is a disc root. Do not descend // into any of its children: BDMV/STREAM and VIDEO_TS files are segments // of one work, not separately scrapeable media files. final isDiscRoot = isMediaScanDiscLayout(files); final mediaBatch = []; for (final file in files) { if (file.isDirectory) { if (recursive && !isDiscRoot) { final childPath = '${folder.path}/${file.name}'; if (!isMediaScanDiscInternalPath(childPath)) { folders.add(_ScanFolder(file.id, childPath)); } } } else if (file.isVideo && (file.size ?? 0) >= minimumSizeBytes) { mediaBatch.add(_withPath(file, '${folder.path}/${file.name}')); } } discovered += mediaBatch.length; if (mediaBatch.isNotEmpty) await onMediaFiles(mediaBatch); state = state.copyWith( progress: MediaLibraryScanProgress( phase: '扫描 ${folder.path}', completed: discovered, total: folders.length + visited.length, ), ); if (cachedFolder != null || files.length < 200) { if (cachedFolder == null) { await FileMetadataCache.cacheFolderChildren( folder.id, folderSnapshot, ); } break; } page += 1; } } } Future> _enrichAndCacheFiles(List files) async { final resolved = []; final pending = []; for (final file in files) { final cached = await FileMetadataCache.file(file.id); if (cached != null) { resolved.add( file.copyWith( size: cached.size, gcid: cached.gcid, modifiedAt: cached.modifiedAt, cloudPath: file.cloudPath, ), ); } else if (!file.isDirectory && (file.gcid == null || file.gcid!.isEmpty)) { pending.add(file); } else { resolved.add(file); } } if (pending.isEmpty) { await FileMetadataCache.cacheFiles(resolved); return resolved; } final enriched = { for (final file in resolved) file.id: file, }; var next = 0; Future worker() async { while (next < pending.length) { final file = pending[next++]; try { final detail = await _api!.fsDetail(file.id); final detailFile = _extractFiles( detail, ).where((candidate) => candidate.id == file.id).firstOrNull; final gcid = detailFile?.gcid ?? _findStringDeep(detail, const [ 'gcid', 'gcId', 'gcidValue', 'hash', ]); final size = detailFile?.size ?? _findIntDeep(detail, const [ 'size', 'fileSize', 'resSize', 'totalSize', ]); enriched[file.id] = file.copyWith(gcid: gcid, size: size); } catch (_) { enriched[file.id] = file; } } } await Future.wait(List.generate(6, (_) => worker())); final values = [for (final file in files) enriched[file.id] ?? file]; await FileMetadataCache.cacheFiles(values); return values; } String? _findStringDeep(Map value, List keys) { for (final entry in value.entries) { if (keys.contains(entry.key) && entry.value != null) { final text = entry.value.toString().trim(); if (text.isNotEmpty) return text; } if (entry.value is Map) { final found = _findStringDeep( Map.from(entry.value), keys, ); if (found != null) return found; } else if (entry.value is List) { for (final child in entry.value as List) { if (child is Map) { final found = _findStringDeep( Map.from(child), keys, ); if (found != null) return found; } } } } return null; } int? _findIntDeep(Map value, List keys) { for (final entry in value.entries) { if (keys.contains(entry.key)) { final parsed = int.tryParse(entry.value?.toString() ?? ''); if (parsed != null) return parsed; } if (entry.value is Map) { final found = _findIntDeep( Map.from(entry.value), keys, ); if (found != null) return found; } else if (entry.value is List) { for (final child in entry.value as List) { if (child is Map) { final found = _findIntDeep(Map.from(child), keys); if (found != null) return found; } } } } return null; } CloudFile _withPath(CloudFile file, String path) { return CloudFile( id: file.id, name: file.name, isDirectory: file.isDirectory, size: file.size, gcid: file.gcid, subDirectoryCount: file.subDirectoryCount, subFileCount: file.subFileCount, modifiedAt: file.modifiedAt, cloudPath: path, parentID: file.parentID, fullParentIDs: file.fullParentIDs, fileType: file.fileType, ); } Future _resolveCurrentCloudFile( CloudFile knownFile, { String? libraryID, }) async { try { final detail = await _api!.fsDetail(knownFile.id); final current = _fileFromDetail(detail, knownFile.id, knownFile); if (current != null) { return _cacheResolvedCloudFile(knownFile, current); } } catch (error) { // A rename can replace the cloud record. Locate its new ID below. _appendScanLog('[同步识别][调试] 旧文件 ID 查询失败:${knownFile.id},$error;开始回退定位'); } final cached = await FileMetadataCache.file(knownFile.id); final parentID = knownFile.parentID ?? cached?.parentID; final gcid = knownFile.gcid?.trim(); if (cached != null && gcid != null && gcid.isNotEmpty && cached.gcid == gcid && cached.id != knownFile.id) { _appendScanLog( '[同步识别][调试] 从 GCID 缓存恢复新文件:${knownFile.id} -> ${cached.id}', ); return _cacheResolvedCloudFile( knownFile, cached, cloudPath: cached.cloudPath.isEmpty ? null : cached.cloudPath, ); } final candidates = []; final candidateIDs = {}; void addCandidates(Iterable files) { for (final file in files) { if (file.isDirectory == knownFile.isDirectory && file.name == knownFile.name && candidateIDs.add(file.id)) { candidates.add(file); } } } if (parentID != null && parentID.isNotEmpty) { try { final response = await _api!.fsFiles( parentID: parentID, page: 0, pageSize: 1000, orderBy: 0, sortType: 0, ); addCandidates(_extractFiles(response)); } catch (_) { // The global search below remains available when the parent is stale. } } try { final response = await _api!.searchFiles( knownFile.name, page: 0, pageSize: 100, ); addCandidates(_extractFiles(response)); } catch (_) { // Return null below so callers can surface an actionable sync failure. } for (final candidate in candidates) { var resolved = candidate; if ((knownFile.gcid?.isNotEmpty ?? false) && candidate.gcid != knownFile.gcid) { try { final detail = await _api!.fsDetail(candidate.id); resolved = _fileFromDetail(detail, candidate.id, candidate) ?? candidate; } catch (_) { continue; } } if (knownFile.gcid?.isNotEmpty == true && resolved.gcid != knownFile.gcid) { continue; } return _cacheResolvedCloudFile(knownFile, resolved); } if (gcid != null && gcid.isNotEmpty && parentID != null) { final located = await _resolveFromCurrentDirectory( knownFile, parentID: parentID, ); if (located != null) return located; } _appendScanLog( '[同步识别][调试] 未定位到当前文件目录中的 GCID;停止自动查找,建议重新扫描媒体库', isError: true, ); return null; } Future _resolveFromCurrentDirectory( CloudFile knownFile, { required String parentID, }) async { final gcid = knownFile.gcid?.trim(); if (gcid == null || gcid.isEmpty) return null; _appendScanLog('[同步识别][调试] 从当前文件目录扫描:父目录 ID=$parentID,GCID=$gcid'); final parentPath = _parentPath(knownFile.cloudPath); List children; try { children = await _allRemoteFolderFiles(parentID); } catch (_) { return null; } await FileMetadataCache.cacheFolderChildren(parentID, children); final extension = _extensionOf(knownFile.name).toLowerCase(); final candidates = children .where((child) { if (child.isDirectory) return false; if (knownFile.size != null && child.size != knownFile.size) { return false; } return extension.isEmpty || _extensionOf(child.name).toLowerCase() == extension; }) .take(12); for (final child in candidates) { var resolved = child; if (resolved.gcid != gcid) { try { final detail = await _api!.fsDetail(child.id); resolved = _fileFromDetail(detail, child.id, child) ?? child; } catch (_) { continue; } } if (resolved.gcid == gcid) { final exact = _withPath(resolved, '$parentPath/${resolved.name}'); _appendScanLog( '[同步识别][调试] 当前目录定位成功:${knownFile.id} -> ' '${exact.id},路径=${exact.cloudPath}', ); return _cacheResolvedCloudFile( knownFile, exact, cloudPath: exact.cloudPath, ); } } _appendScanLog( '[同步识别][调试] 当前文件目录未命中:已检查 ${children.length} 个条目,详情查询不超过 12 次', ); return null; } Future> _allRemoteFolderFiles(String? parentID) async { const pageSize = 1000; final files = []; for (var page = 0; ; page++) { final response = await _api!.fsFiles( parentID: parentID, page: page, pageSize: pageSize, orderBy: 0, sortType: 0, ); final batch = _extractFiles(response); files.addAll(batch); if (batch.length < pageSize) break; } return files; } Future _cacheResolvedCloudFile( CloudFile knownFile, CloudFile resolved, { String? cloudPath, }) async { final parentPath = _parentPath(knownFile.cloudPath); final file = knownFile.copyWith( id: resolved.id, name: resolved.name, size: resolved.size, gcid: resolved.gcid, modifiedAt: resolved.modifiedAt, cloudPath: cloudPath ?? (parentPath.isEmpty ? resolved.name : '$parentPath/${resolved.name}'), parentID: resolved.parentID ?? knownFile.parentID, fullParentIDs: resolved.fullParentIDs ?? knownFile.fullParentIDs, fileType: resolved.fileType, ); await FileMetadataCache.cacheFiles([file]); final parentID = file.parentID ?? knownFile.parentID; if (parentID != null && parentID.isNotEmpty) { await FileMetadataCache.updateFolderChildren( parentID, removeIDs: file.id == knownFile.id ? const [] : [knownFile.id], addOrReplace: [file], ); } return file; } CloudFile? _fileFromDetail( Map detail, String fileID, CloudFile fallback, ) { final extracted = _extractFiles( detail, ).where((file) => file.id == fileID).firstOrNull; if (extracted != null && extracted.name.isNotEmpty) return extracted; Map? findExactMap(dynamic value) { if (value is Map) { final map = Map.from(value); for (final key in const [ 'fileId', 'file_id', 'resId', 'res_id', 'id', ]) { if (map[key]?.toString() == fileID) return map; } for (final child in map.values) { final found = findExactMap(child); if (found != null) return found; } } else if (value is List) { for (final child in value) { final found = findExactMap(child); if (found != null) return found; } } return null; } final exact = findExactMap(detail); if (exact == null) return null; try { final parsed = CloudFile.fromJson(exact); if (parsed.name.isNotEmpty) return parsed; } catch (_) { // Fall back to extracting fields directly from the exact File ID object. } return fallback.copyWith( name: _findStringDeep(exact, const ['fileName', 'name', 'resName']) ?? fallback.name, gcid: _findStringDeep(exact, const ['gcid', 'gcId', 'gcidValue', 'hash']), size: _findIntDeep(exact, const [ 'size', 'fileSize', 'resSize', 'totalSize', ]), parentID: _findStringDeep(exact, const [ 'parentId', 'parent_id', 'parentFileId', ]), ); } List _extractFiles(Map json) { final result = []; final seen = {}; void appendList(List values) { for (final value in values) { if (value is Map) { try { final file = CloudFile.fromJson(Map.from(value)); if (seen.add(file.id)) result.add(file); } catch (_) {} } } } final preferred = _findArrayDeep(json, const [ 'list', 'files', 'fileList', 'items', 'records', 'rows', 'resList', 'resourceList', ]); if (preferred != null) { appendList(preferred); } void visit(dynamic value) { if (value is Map) { try { final file = CloudFile.fromJson(Map.from(value)); if (seen.add(file.id)) result.add(file); } catch (_) {} for (final child in value.values) { visit(child); } } else if (value is List) { for (final child in value) { visit(child); } } } visit(json); return result; } Future _migrateLegacyHiveIfNeeded() async { if (!await _store.isEmpty) return; final rawLibraries = StorageManager.get( StorageKeys.mediaLibraries, ); if (rawLibraries is! List) return; final libraries = rawLibraries .whereType() .map( (item) => MediaLibraryDefinition.fromJson(Map.from(item)), ) .toList(); if (libraries.isEmpty) return; final rawItems = StorageManager.get(StorageKeys.mediaLibraryItems); final items = rawItems is List ? rawItems .whereType() .map( (item) => MediaLibraryItem.fromJson(Map.from(item)), ) .where((item) => item.file.id.isNotEmpty) .toList() : []; await _store.saveLibraries(libraries); await _store.replaceItems(items); } Future> _loadLibraries() { return _store.libraries(); } Future> _loadItems(String libraryID) { return _store.items(libraryID: libraryID); } Future> _loadAllItems() { return _store.items(); } Future _saveLibraries(List libraries) { return _store.saveLibraries(libraries); } Future _saveAllItems(List items) { return _store.replaceItems(items); } Future _replaceItemsByPreviousIDs( Map replacements, ) async { if (replacements.isEmpty) return; final allItems = await _loadAllItems(); final replacementIDs = replacements.values .map((item) => '${item.libraryID}:${item.id}') .toSet(); allItems.removeWhere((item) { final key = '${item.libraryID}:${item.id}'; return replacements.containsKey(key) || replacementIDs.contains(key); }); allItems.addAll(replacements.values); await _saveAllItems(allItems); } Future _removeDiscInternalItems() async { final items = await _loadAllItems(); final retained = items .where((item) => !isMediaScanDiscInternalPath(item.file.cloudPath)) .toList(); final removed = items.length - retained.length; if (removed > 0) await _saveAllItems(retained); return removed; } Future _upsertItems(Iterable items) { return _store.upsertItems(items); } /* List _loadLibraries() { final raw = StorageManager.get(StorageKeys.mediaLibraries); if (raw is! List) return []; return raw .whereType() .map( (item) => MediaLibraryDefinition.fromJson(Map.from(item)), ) .toList(); } List _loadItems(String libraryID) { return _loadAllItems() .where((item) => item.libraryID == libraryID) .toList(); } List _loadAllItems() { final raw = StorageManager.get(StorageKeys.mediaLibraryItems); if (raw is! List) return []; return raw .whereType() .map( (item) => MediaLibraryItem.fromJson(Map.from(item)), ) .where((item) => item.file.id.isNotEmpty) .toList(); } Future _saveLibraries(List libraries) { return StorageManager.set( StorageKeys.mediaLibraries, libraries.map((library) => library.toJson()).toList(), ); } Future _saveAllItems(List items) { return StorageManager.set( StorageKeys.mediaLibraryItems, items.map((item) => item.toJson()).toList(), ); } */ static List? _findArrayDeep( Map json, List keys, ) { for (final key in keys) { final value = json[key]; if (value is List) return value; } const preferredKeys = ['data', 'result', 'payload']; for (final key in preferredKeys) { final value = json[key]; if (value is Map) { final found = _findArrayDeep(value, keys); if (found != null) return found; } } for (final entry in json.entries) { if (preferredKeys.contains(entry.key)) continue; final value = entry.value; if (value is Map) { final found = _findArrayDeep(value, keys); if (found != null) return found; } } return null; } } class _ScanFolder { final String? id; final String path; 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; const _SyncedMediaItem({required this.original, required this.fallback}); } final mediaLibraryProvider = StateNotifierProvider( (ref) => MediaLibraryNotifier(), );