import 'dart:async'; import 'package:flutter_riverpod/legacy.dart'; import '../api/guangya_api.dart'; import '../core/storage/file_metadata_cache.dart'; import '../core/storage/storage_manager.dart'; import '../models/cloud_file.dart'; import '../models/media_library.dart'; class MediaLibraryState { final List libraries; final String? selectedLibraryID; final List items; final bool isLoading; final bool isScanning; final MediaLibraryScanProgress progress; 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.progress = const MediaLibraryScanProgress(), 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, MediaLibraryScanProgress? progress, 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, progress: progress ?? this.progress, searchQuery: searchQuery ?? this.searchQuery, errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage), ); } } class MediaLibraryNotifier extends StateNotifier { GuangyaAPI? _api; bool _loaded = false; bool _cancelScan = false; 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 { final libraries = _loadLibraries(); final selectedID = libraries.isEmpty ? null : libraries.first.id; final items = selectedID == null ? [] : _loadItems(selectedID); state = state.copyWith( libraries: libraries, selectedLibraryID: selectedID, items: items, ); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } finally { state = state.copyWith(isLoading: false); } } Future selectLibrary(String id) async { state = state.copyWith( selectedLibraryID: id, items: _loadItems(id), searchQuery: '', clearError: true, ); } 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 = _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 [] : _loadItems(selectedID), statusMessage: '媒体库已删除', ); } Future scanSelectedLibrary() async { final library = state.selectedLibrary; if (library == null || _api == null || state.isScanning) return; _cancelScan = false; state = state.copyWith( isScanning: true, progress: const MediaLibraryScanProgress(phase: '准备扫描'), clearError: true, clearStatus: true, ); try { final initialItems = _loadAllItems() ..removeWhere((item) => item.libraryID == library.id); final unique = { for (final item in _loadItems(library.id)) 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 .where((file) => !unique.containsKey(file.id)) .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); final item = await _recognizeMediaItem( fallback, tmdbApiKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort, ); unique[file.id] = item; completed += 1; final visible = unique.values.toList() ..sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); pendingPersistence = pendingPersistence.then( (_) => _saveAllItems([...initialItems, ...unique.values]), ); await pendingPersistence; state = state.copyWith( items: visible, progress: MediaLibraryScanProgress( phase: tmdbApiKey.isEmpty ? '正在建立本地索引' : '正在识别 ${file.name}', completed: completed, ), ); } } final concurrency = (int.tryParse( StorageManager.get( StorageKeys.mediaScanConcurrency, ) ?? '3', ) ?? 3) .clamp(1, 20); await Future.wait(List.generate(concurrency, (_) => worker())); } for (final source in library.sources) { if (_cancelScan) break; state = state.copyWith( progress: MediaLibraryScanProgress( phase: '扫描 ${source.path}', completed: completed, ), ); await _scanSource( source.rootID, source.path, recursive: library.recursive, minimumSizeBytes: library.minimumSizeMB * 1024 * 1024, onMediaFiles: indexBatch, ); } await pendingPersistence; final items = unique.values.toList() ..sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); final allItems = [...initialItems, ...items]; 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} 个项目' : '扫描完成:${items.length} 个视频文件', ); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } finally { state = state.copyWith( isScanning: false, progress: const MediaLibraryScanProgress(), ); } } void cancelScan() { _cancelScan = true; state = state.copyWith( progress: const MediaLibraryScanProgress(phase: '正在停止扫描'), ); } void setSearchQuery(String query) { state = state.copyWith(searchQuery: query); } Future> searchAllItems(String query) async { final normalized = query.trim().toLowerCase(); if (normalized.isEmpty) return const []; return _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())); } 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 result = await _api!.tmdbSearch( fallback.title, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort, ); final values = result['results']; if (values is! List) return fallback; Map? candidate; for (final value in values) { if (value is! Map) continue; final map = Map.from(value); final type = map['media_type']?.toString(); if (type == 'movie' || type == 'tv') { candidate = map; break; } } if (candidate == null) return fallback; 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 MediaLibraryItem( libraryID: fallback.libraryID, file: fallback.file, 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(), ); } catch (_) { return fallback; } } int? _toInt(dynamic value) { if (value is int) return value; return int.tryParse(value?.toString() ?? ''); } Future _scanSource( String? rootID, String rootPath, { required bool recursive, required int minimumSizeBytes, 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; var page = 0; final cachedFolder = FileMetadataCache.folderChildren(folder.id); final folderSnapshot = []; while (!_cancelScan) { late List files; if (cachedFolder != null) { files = cachedFolder; } else { final response = await _api!.fsFiles( parentID: folder.id, page: page, pageSize: 200, orderBy: 0, sortType: 0, ); files = _extractFiles(response); files = await _enrichAndCacheFiles(files); folderSnapshot.addAll(files); } final mediaBatch = []; for (final file in files) { if (file.isDirectory) { if (recursive) { folders.add(_ScanFolder(file.id, '${folder.path}/${file.name}')); } } 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 = 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, fileType: file.fileType, ); } 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); return result; } 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; } 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); } final mediaLibraryProvider = StateNotifierProvider( (ref) => MediaLibraryNotifier(), );