diff --git a/lib/core/storage/file_metadata_cache.dart b/lib/core/storage/file_metadata_cache.dart index b444f5b..a3ffad6 100644 --- a/lib/core/storage/file_metadata_cache.dart +++ b/lib/core/storage/file_metadata_cache.dart @@ -1,165 +1,35 @@ import '../../models/cloud_file.dart'; -import 'storage_manager.dart'; +import 'media_library_store.dart'; -/// Permanent cloud-file indexes used by the workspace and media scanner. -/// -/// Folder records retain ordered child IDs as their index; the snapshots make -/// an offline scan possible before the server needs to be consulted again. +/// File metadata indexes live in the same SQLite database as scraped media. class FileMetadataCache { - static const _rootFolderID = '@root'; - static Future _writes = Future.value(); - - static String _folderKey(String? folderID) => folderID ?? _rootFolderID; + static final _store = MediaLibraryStore(); static Future cacheFolderChildren( String? folderID, List files, - ) { - return _enqueue(() async { - final folders = _map(StorageKeys.folderChildrenIndex); - final fileIndex = _map(StorageKeys.fileGcidIndex); - final details = _map(StorageKeys.gcidDetails); - folders[_folderKey(folderID)] = { - 'childIds': files.map((file) => file.id).toList(), - 'children': files.map((file) => file.toJson()).toList(), - }; - _indexFiles(files, fileIndex, details); - await StorageManager.set(StorageKeys.folderChildrenIndex, folders); - await StorageManager.set(StorageKeys.fileGcidIndex, fileIndex); - await StorageManager.set(StorageKeys.gcidDetails, details); - }); - } + ) => _store.cacheFolderChildren(folderID, files); - static Future cacheFiles(List files) { - return _enqueue(() async { - final fileIndex = _map(StorageKeys.fileGcidIndex); - final details = _map(StorageKeys.gcidDetails); - _indexFiles(files, fileIndex, details); - await StorageManager.set(StorageKeys.fileGcidIndex, fileIndex); - await StorageManager.set(StorageKeys.gcidDetails, details); - }); - } + static Future cacheFiles(List files) => + _store.cacheFiles(files); + + static Future?> folderChildren(String? folderID) => + _store.folderChildren(folderID); + + static Future file(String fileID) => _store.cachedFile(fileID); static Future updateFolderChildren( String? folderID, { Iterable removeIDs = const [], Iterable addOrReplace = const [], bool invalidate = false, - }) { - return _enqueue(() async { - final folders = _map(StorageKeys.folderChildrenIndex); - final key = _folderKey(folderID); - if (invalidate) { - folders.remove(key); - await StorageManager.set(StorageKeys.folderChildrenIndex, folders); - return; - } - final entry = folders[key]; - if (entry is! Map || entry['children'] is! List) return; - final removed = removeIDs.toSet(); - final children = (entry['children'] as List) - .whereType() - .map((value) => CloudFile.fromJson(Map.from(value))) - .where((file) => !removed.contains(file.id)) - .toList(); - final replacement = {for (final file in addOrReplace) file.id: file}; - children.removeWhere((file) => replacement.containsKey(file.id)); - children.addAll(replacement.values); - folders[key] = { - 'childIds': children.map((file) => file.id).toList(), - 'children': children.map((file) => file.toJson()).toList(), - }; - final fileIndex = _map(StorageKeys.fileGcidIndex); - final details = _map(StorageKeys.gcidDetails); - _indexFiles(replacement.values.toList(), fileIndex, details); - await StorageManager.set(StorageKeys.folderChildrenIndex, folders); - await StorageManager.set(StorageKeys.fileGcidIndex, fileIndex); - await StorageManager.set(StorageKeys.gcidDetails, details); - }); - } + }) => _store.updateFolderChildren( + folderID, + removeIDs: removeIDs, + addOrReplace: addOrReplace, + invalidate: invalidate, + ); - static Future removeFilesFromAllFolders(Iterable fileIDs) { - final removed = fileIDs.toSet(); - if (removed.isEmpty) return Future.value(); - return _enqueue(() async { - final folders = _map(StorageKeys.folderChildrenIndex); - for (final entry in folders.entries.toList()) { - final snapshot = entry.value; - if (snapshot is! Map || snapshot['children'] is! List) continue; - final children = (snapshot['children'] as List) - .whereType() - .where((child) => !removed.contains(_fileID(child))) - .toList(); - final original = snapshot['children'] as List; - if (children.length == original.length) continue; - folders[entry.key] = { - 'childIds': children.map(_fileID).whereType().toList(), - 'children': children, - }; - } - await StorageManager.set(StorageKeys.folderChildrenIndex, folders); - }); - } - - static List? folderChildren(String? folderID) { - final folders = _map(StorageKeys.folderChildrenIndex); - final entry = folders[_folderKey(folderID)]; - if (entry is! Map || entry['children'] is! List) return null; - try { - return (entry['children'] as List) - .whereType() - .map((value) => CloudFile.fromJson(Map.from(value))) - .toList(); - } catch (_) { - return null; - } - } - - static CloudFile? file(String fileID) { - final fileIndex = _map(StorageKeys.fileGcidIndex); - final gcid = fileIndex[fileID]?.toString(); - if (gcid == null || gcid.isEmpty) return null; - final details = _map(StorageKeys.gcidDetails); - final value = details[gcid]; - if (value is! Map) return null; - try { - return CloudFile.fromJson(Map.from(value)); - } catch (_) { - return null; - } - } - - static Map _map(String key) { - final raw = StorageManager.get(key); - if (raw is! Map) return {}; - return raw.map((mapKey, value) => MapEntry(mapKey.toString(), value)); - } - - static void _indexFiles( - List files, - Map fileIndex, - Map details, - ) { - for (final file in files) { - final gcid = file.gcid?.trim(); - if (gcid == null || gcid.isEmpty) continue; - fileIndex[file.id] = gcid; - details[gcid] = file.toJson(); - } - } - - static String? _fileID(Map value) { - for (final key in const ['id', 'fileId', 'file_id', 'resId', 'res_id']) { - final candidate = value[key]; - if (candidate != null && candidate.toString().isNotEmpty) { - return candidate.toString(); - } - } - return null; - } - - static Future _enqueue(Future Function() operation) { - _writes = _writes.then((_) => operation()); - return _writes; - } + static Future removeFilesFromAllFolders(Iterable fileIDs) => + _store.removeFilesFromAllFolders(fileIDs); } diff --git a/lib/core/storage/media_library_store.dart b/lib/core/storage/media_library_store.dart new file mode 100644 index 0000000..2b414a4 --- /dev/null +++ b/lib/core/storage/media_library_store.dart @@ -0,0 +1,459 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:sqflite/sqflite.dart'; + +import '../../models/cloud_file.dart'; +import '../../models/media_library.dart'; + +/// SQLite-backed scraped media cache. The schema intentionally matches the +/// macOS client so its backup database can be merged without deserializing +/// large poster/backdrop BLOBs in Dart. +class MediaLibraryStore { + Database? _database; + + Future get _db async { + if (_database != null) return _database!; + final databasePath = path.join( + await getDatabasesPath(), + 'media-library.sqlite3', + ); + _database = await openDatabase( + databasePath, + version: 2, + onCreate: (db, _) async { + await _createSchema(db); + }, + onUpgrade: (db, _, _) async { + await _createSchema(db); + }, + ); + await _createSchema(_database!); + return _database!; + } + + Future initialize() async { + await _db; + } + + Future get isEmpty async { + final rows = await (await _db).rawQuery( + 'SELECT COUNT(*) AS count FROM media_libraries', + ); + return (rows.first['count'] as int? ?? 0) == 0; + } + + Future> libraries() async { + final db = await _db; + final rows = await db.query( + 'media_libraries', + orderBy: 'updated_at DESC, name COLLATE NOCASE', + ); + final sources = await db.query( + 'media_library_sources', + orderBy: 'library_id, sort_order', + ); + final sourcesByLibrary = >{}; + for (final row in sources) { + final libraryID = row['library_id']?.toString() ?? ''; + sourcesByLibrary + .putIfAbsent(libraryID, () => []) + .add( + MediaLibrarySource( + id: row['id']?.toString() ?? '', + rootID: row['root_id']?.toString(), + path: row['root_path']?.toString() ?? '未配置目录', + ), + ); + } + return rows.map((row) { + final id = row['id']?.toString() ?? ''; + return MediaLibraryDefinition( + id: id, + name: row['name']?.toString() ?? '未命名媒体库', + sources: + sourcesByLibrary[id] ?? + [ + MediaLibrarySource( + id: '$id-legacy', + rootID: row['root_id']?.toString(), + path: row['root_path']?.toString() ?? '未配置目录', + ), + ], + kind: MediaLibraryKind.values.firstWhere( + (kind) => kind.name == row['kind']?.toString(), + orElse: () => MediaLibraryKind.mixed, + ), + recursive: row['recursive'] != 0, + minimumSizeMB: _asInt(row['minimum_size_mb']) ?? 50, + updatedAt: _dateFromEpoch(row['updated_at']), + ); + }).toList(); + } + + Future> items({String? libraryID}) async { + final rows = await (await _db).query( + 'media_items', + where: libraryID == null ? null : 'library_id = ?', + whereArgs: libraryID == null ? null : [libraryID], + orderBy: 'title COLLATE NOCASE', + ); + return rows.map(_itemFromRow).toList(); + } + + Future saveLibraries(List libraries) async { + final db = await _db; + await db.transaction((txn) async { + for (final library in libraries) { + await txn.insert('media_libraries', { + 'id': library.id, + 'name': library.name, + 'root_id': library.rootID, + 'root_path': library.rootPath, + 'kind': library.kind.name, + 'recursive': library.recursive ? 1 : 0, + 'minimum_size_mb': library.minimumSizeMB, + 'updated_at': _epoch(library.updatedAt), + }, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.delete( + 'media_library_sources', + where: 'library_id = ?', + whereArgs: [library.id], + ); + for (var index = 0; index < library.sources.length; index++) { + final source = library.sources[index]; + await txn.insert('media_library_sources', { + 'id': source.id, + 'library_id': library.id, + 'root_id': source.rootID, + 'root_path': source.path, + 'sort_order': index, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + } + }); + } + + Future deleteLibrary(String id) async { + await (await _db).delete( + 'media_libraries', + where: 'id = ?', + whereArgs: [id], + ); + } + + Future replaceItems(List items) async { + final db = await _db; + await db.transaction((txn) async { + await txn.delete('media_items'); + for (final item in items) { + await txn.insert('media_items', _itemRow(item)); + } + }); + } + + Future importBackup(String backupPath) async { + final db = await _db; + await db.execute('ATTACH DATABASE ? AS imported_backup', [backupPath]); + try { + await db.transaction((txn) async { + await txn.execute( + 'INSERT OR REPLACE INTO media_libraries SELECT * FROM imported_backup.media_libraries', + ); + await txn.execute( + 'INSERT OR REPLACE INTO media_library_sources SELECT * FROM imported_backup.media_library_sources', + ); + await txn.execute( + 'INSERT OR REPLACE INTO media_items SELECT * FROM imported_backup.media_items', + ); + }); + } finally { + await db.execute('DETACH DATABASE imported_backup'); + } + } + + Future exportBackupTo(String destinationPath) async { + final db = await _db; + await db.execute('PRAGMA wal_checkpoint(FULL)'); + await File(db.path).copy(destinationPath); + } + + Future cacheFolderChildren( + String? folderID, + List files, + ) async { + final db = await _db; + await db.transaction((txn) async { + for (final file in files) { + final gcid = file.gcid?.trim(); + if (gcid == null || gcid.isEmpty) continue; + await txn.insert('file_index', { + 'file_id': file.id, + 'gcid': gcid, + }, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.insert('gcid_details', { + 'gcid': gcid, + 'file_json': jsonEncode(file.toJson()), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await txn.insert('folder_children', { + 'folder_id': _folderID(folderID), + 'child_ids': jsonEncode(files.map((file) => file.id).toList()), + 'children_json': jsonEncode( + files.map((file) => file.toJson()).toList(), + ), + }, conflictAlgorithm: ConflictAlgorithm.replace); + }); + } + + Future cacheFiles(List files) async { + final db = await _db; + await db.transaction((txn) async { + for (final file in files) { + final gcid = file.gcid?.trim(); + if (gcid == null || gcid.isEmpty) continue; + await txn.insert('file_index', { + 'file_id': file.id, + 'gcid': gcid, + }, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.insert('gcid_details', { + 'gcid': gcid, + 'file_json': jsonEncode(file.toJson()), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + Future?> folderChildren(String? folderID) async { + final rows = await (await _db).query( + 'folder_children', + columns: ['children_json'], + where: 'folder_id = ?', + whereArgs: [_folderID(folderID)], + limit: 1, + ); + if (rows.isEmpty) return null; + try { + final values = jsonDecode( + rows.first['children_json']?.toString() ?? '[]', + ); + if (values is! List) return null; + return values + .whereType() + .map((value) => CloudFile.fromJson(Map.from(value))) + .toList(); + } catch (_) { + return null; + } + } + + Future cachedFile(String fileID) async { + final rows = await (await _db).rawQuery( + '''SELECT d.file_json FROM file_index i + JOIN gcid_details d ON d.gcid = i.gcid + WHERE i.file_id = ? LIMIT 1''', + [fileID], + ); + if (rows.isEmpty) return null; + try { + final value = jsonDecode(rows.first['file_json']?.toString() ?? '{}'); + return value is Map + ? CloudFile.fromJson(Map.from(value)) + : null; + } catch (_) { + return null; + } + } + + Future updateFolderChildren( + String? folderID, { + Iterable removeIDs = const [], + Iterable addOrReplace = const [], + bool invalidate = false, + }) async { + final db = await _db; + final key = _folderID(folderID); + if (invalidate) { + await db.delete( + 'folder_children', + where: 'folder_id = ?', + whereArgs: [key], + ); + return; + } + final existing = await folderChildren(folderID); + if (existing == null) return; + final removed = removeIDs.toSet(); + final replacement = {for (final file in addOrReplace) file.id: file}; + final children = + existing + .where( + (file) => + !removed.contains(file.id) && + !replacement.containsKey(file.id), + ) + .toList() + ..addAll(replacement.values); + await cacheFolderChildren(folderID, children); + } + + Future removeFilesFromAllFolders(Iterable fileIDs) async { + final ids = fileIDs.toSet(); + if (ids.isEmpty) return; + final db = await _db; + final rows = await db.query('folder_children'); + await db.transaction((txn) async { + for (final row in rows) { + final folderID = row['folder_id']?.toString(); + final children = await folderChildren( + folderID == _rootFolderID ? null : folderID, + ); + if (children == null) continue; + final retained = children + .where((file) => !ids.contains(file.id)) + .toList(); + if (retained.length == children.length) continue; + await txn.update( + 'folder_children', + { + 'child_ids': jsonEncode(retained.map((file) => file.id).toList()), + 'children_json': jsonEncode( + retained.map((file) => file.toJson()).toList(), + ), + }, + where: 'folder_id = ?', + whereArgs: [folderID], + ); + } + }); + } + + Future _createSchema(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS media_libraries ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + root_id TEXT, + root_path TEXT NOT NULL, + kind TEXT NOT NULL, + recursive INTEGER NOT NULL DEFAULT 1, + updated_at REAL, + minimum_size_mb INTEGER NOT NULL DEFAULT 50 + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS media_library_sources ( + id TEXT PRIMARY KEY NOT NULL, + library_id TEXT NOT NULL, + root_id TEXT, + root_path TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS media_items ( + library_id TEXT NOT NULL, + file_id TEXT NOT NULL, + resource_path TEXT NOT NULL, + cloud_name TEXT NOT NULL, + file_size INTEGER, + gcid TEXT, + file_type INTEGER NOT NULL, + tmdb_id INTEGER, + media_kind TEXT, + title TEXT NOT NULL, + original_title TEXT NOT NULL, + release_date TEXT NOT NULL, + overview TEXT NOT NULL, + poster BLOB, + backdrop BLOB, + has_chinese_audio INTEGER NOT NULL DEFAULT 0, + has_chinese_subtitle INTEGER NOT NULL DEFAULT 0, + collection_id INTEGER, + collection_name TEXT, + updated_at REAL NOT NULL, + PRIMARY KEY (library_id, file_id) + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS file_index ( + file_id TEXT PRIMARY KEY NOT NULL, + gcid TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS gcid_details ( + gcid TEXT PRIMARY KEY NOT NULL, + file_json TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS folder_children ( + folder_id TEXT PRIMARY KEY NOT NULL, + child_ids TEXT NOT NULL, + children_json TEXT NOT NULL + ) + '''); + } + + MediaLibraryItem _itemFromRow(Map row) { + return MediaLibraryItem.fromJson({ + 'libraryID': row['library_id'], + 'fileID': row['file_id'], + 'resourcePath': row['resource_path'], + 'cloudName': row['cloud_name'], + 'fileSize': row['file_size'], + 'gcid': row['gcid'], + 'fileType': row['file_type'], + 'tmdbID': row['tmdb_id'], + 'mediaKind': row['media_kind'], + 'title': row['title'], + 'originalTitle': row['original_title'], + 'releaseDate': row['release_date'], + 'overview': row['overview'], + 'hasChineseAudio': row['has_chinese_audio'] == 1, + 'hasChineseSubtitle': row['has_chinese_subtitle'] == 1, + 'collectionID': row['collection_id'], + 'collectionName': row['collection_name'], + 'updatedAt': _dateFromEpoch(row['updated_at'])?.toIso8601String(), + }); + } + + Map _itemRow(MediaLibraryItem item) => { + 'library_id': item.libraryID, + 'file_id': item.file.id, + 'resource_path': item.file.cloudPath, + 'cloud_name': item.file.name, + 'file_size': item.file.size, + 'gcid': item.file.gcid, + 'file_type': item.file.fileType, + 'tmdb_id': item.tmdbID, + 'media_kind': item.mediaKind?.name, + 'title': item.title, + 'original_title': item.originalTitle, + 'release_date': item.releaseDate, + 'overview': item.overview, + 'has_chinese_audio': item.hasChineseAudio ? 1 : 0, + 'has_chinese_subtitle': item.hasChineseSubtitle ? 1 : 0, + 'collection_id': item.collectionID, + 'collection_name': item.collectionName, + 'updated_at': _epoch(item.updatedAt) ?? 0, + }; + + static int? _asInt(Object? value) => + value is int ? value : int.tryParse('$value'); + + static double? _epoch(DateTime? value) => + value == null ? null : value.millisecondsSinceEpoch / 1000; + + static DateTime? _dateFromEpoch(Object? value) { + final seconds = value is num ? value.toDouble() : double.tryParse('$value'); + return seconds == null + ? null + : DateTime.fromMillisecondsSinceEpoch((seconds * 1000).round()); + } + + static const _rootFolderID = '@root'; + static String _folderID(String? folderID) => folderID ?? _rootFolderID; +} diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index a849dbe..87773dc 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../core/storage/storage_manager.dart'; @@ -33,6 +34,7 @@ class _MediaLibraryPageState extends ConsumerState { bool _tmdbSearching = false; String? _tmdbError; List> _tmdbResults = []; + bool _backupBusy = false; final _apiKeyController = TextEditingController(); final _searchController = TextEditingController(); @@ -165,6 +167,22 @@ class _MediaLibraryPageState extends ConsumerState { child: const Text('媒体库'), ), const SizedBox(width: 8), + ShadButton.outline( + onPressed: _backupBusy || state.isScanning + ? null + : _exportScrapedData, + leading: const Icon(Icons.save_alt_rounded, size: 16), + child: const Text('导出数据'), + ), + const SizedBox(width: 8), + ShadButton.outline( + onPressed: _backupBusy || state.isScanning + ? null + : _importScrapedData, + leading: const Icon(Icons.upload_file_rounded, size: 16), + child: const Text('导入数据'), + ), + const SizedBox(width: 8), ShadButton.outline( onPressed: state.selectedLibrary == null || state.isScanning ? null @@ -564,6 +582,37 @@ class _MediaLibraryPageState extends ConsumerState { }); } + Future _exportScrapedData() async { + final directory = await FilePicker.getDirectoryPath( + dialogTitle: '选择刮削数据导出目录', + ); + if (directory == null || !mounted) return; + setState(() => _backupBusy = true); + try { + await ref + .read(mediaLibraryProvider.notifier) + .exportScrapedData('$directory/media-library.sqlite3'); + } finally { + if (mounted) setState(() => _backupBusy = false); + } + } + + Future _importScrapedData() async { + final backup = await FilePicker.pickFile( + dialogTitle: '导入影视缓存与刮削数据', + type: FileType.custom, + allowedExtensions: const ['sqlite3', 'sqlite', 'db'], + ); + final path = backup?.path; + if (path == null || !mounted) return; + setState(() => _backupBusy = true); + try { + await ref.read(mediaLibraryProvider.notifier).importScrapedData(path); + } finally { + if (mounted) setState(() => _backupBusy = false); + } + } + Future _searchTMDB(String query) async { final text = query.trim(); if (text.isEmpty || _tmdbApiKey.isEmpty) return; diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index c267261..311f543 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/legacy.dart'; import '../api/guangya_api.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'; @@ -83,6 +84,7 @@ class MediaLibraryState { class MediaLibraryNotifier extends StateNotifier { GuangyaAPI? _api; + final _store = MediaLibraryStore(); bool _loaded = false; bool _cancelScan = false; @@ -95,11 +97,13 @@ class MediaLibraryNotifier extends StateNotifier { _loaded = true; state = state.copyWith(isLoading: true, clearError: true); try { - final libraries = _loadLibraries(); + await _store.initialize(); + await _migrateLegacyHiveIfNeeded(); + final libraries = await _loadLibraries(); final selectedID = libraries.isEmpty ? null : libraries.first.id; final items = selectedID == null ? [] - : _loadItems(selectedID); + : await _loadItems(selectedID); state = state.copyWith( libraries: libraries, selectedLibraryID: selectedID, @@ -115,7 +119,7 @@ class MediaLibraryNotifier extends StateNotifier { Future selectLibrary(String id) async { state = state.copyWith( selectedLibraryID: id, - items: _loadItems(id), + items: await _loadItems(id), searchQuery: '', clearError: true, ); @@ -165,7 +169,7 @@ class MediaLibraryNotifier extends StateNotifier { final libraries = state.libraries .where((library) => library.id != id) .toList(); - final allItems = _loadAllItems() + final allItems = await _loadAllItems() ..removeWhere((item) => item.libraryID == id); await _saveLibraries(libraries); await _saveAllItems(allItems); @@ -174,7 +178,7 @@ class MediaLibraryNotifier extends StateNotifier { libraries: libraries, selectedLibraryID: selectedID, clearSelectedLibrary: selectedID == null, - items: selectedID == null ? const [] : _loadItems(selectedID), + items: selectedID == null ? const [] : await _loadItems(selectedID), statusMessage: '媒体库已删除', ); } @@ -188,11 +192,53 @@ class MediaLibraryNotifier extends StateNotifier { state = state.copyWith( libraries: libraries, selectedLibraryID: library.id, - items: _loadItems(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 _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: '刮削数据已导入', + ); + } 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 scanSelectedLibrary() async { final library = state.selectedLibrary; if (library == null || _api == null || state.isScanning) return; @@ -206,10 +252,10 @@ class MediaLibraryNotifier extends StateNotifier { ); try { - final initialItems = _loadAllItems() + final initialItems = await _loadAllItems() ..removeWhere((item) => item.libraryID == library.id); final unique = { - for (final item in _loadItems(library.id)) item.file.id: item, + for (final item in await _loadItems(library.id)) item.file.id: item, }; state = state.copyWith(items: unique.values.toList()); final tmdbApiKey = @@ -331,7 +377,7 @@ class MediaLibraryNotifier extends StateNotifier { Future> searchAllItems(String query) async { final normalized = query.trim().toLowerCase(); if (normalized.isEmpty) return const []; - return _loadAllItems().where((item) { + return (await _loadAllItems()).where((item) { return item.title.toLowerCase().contains(normalized) || item.file.name.toLowerCase().contains(normalized) || item.file.cloudPath.toLowerCase().contains(normalized); @@ -424,7 +470,7 @@ class MediaLibraryNotifier extends StateNotifier { if (!visited.add(visitKey)) continue; var page = 0; - final cachedFolder = FileMetadataCache.folderChildren(folder.id); + final cachedFolder = await FileMetadataCache.folderChildren(folder.id); final folderSnapshot = []; while (!_cancelScan) { late List files; @@ -481,7 +527,7 @@ class MediaLibraryNotifier extends StateNotifier { final resolved = []; final pending = []; for (final file in files) { - final cached = FileMetadataCache.file(file.id); + final cached = await FileMetadataCache.file(file.id); if (cached != null) { resolved.add( file.copyWith( @@ -660,6 +706,56 @@ class MediaLibraryNotifier extends StateNotifier { 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); + } + + /* List _loadLibraries() { final raw = StorageManager.get(StorageKeys.mediaLibraries); if (raw is! List) return []; @@ -703,6 +799,7 @@ class MediaLibraryNotifier extends StateNotifier { items.map((item) => item.toJson()).toList(), ); } + */ static List? _findArrayDeep( Map json, diff --git a/pubspec.lock b/pubspec.lock index e434811..57f9975 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1291,7 +1291,7 @@ packages: source: hosted version: "1.10.2" sqflite: - dependency: transitive + dependency: "direct main" description: name: sqflite sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" diff --git a/pubspec.yaml b/pubspec.yaml index 020cb15..a5214d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: cached_network_image: ^3.4.1 hive: ^2.2.3 hive_flutter: ^1.1.0 + sqflite: ^2.4.2 adaptive_platform_ui: ^0.1.106 liquid_glass_easy: ^2.0.1 window_manager: ^0.5.1