feat: store scraped media data in sqlite

This commit is contained in:
ngfchl
2026-07-18 13:36:32 +08:00
parent 9755e522fc
commit 432f7e98f3
6 changed files with 637 additions and 161 deletions
+19 -149
View File
@@ -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<void> _writes = Future.value();
static String _folderKey(String? folderID) => folderID ?? _rootFolderID;
static final _store = MediaLibraryStore();
static Future<void> cacheFolderChildren(
String? folderID,
List<CloudFile> 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<void> cacheFiles(List<CloudFile> 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<void> cacheFiles(List<CloudFile> files) =>
_store.cacheFiles(files);
static Future<List<CloudFile>?> folderChildren(String? folderID) =>
_store.folderChildren(folderID);
static Future<CloudFile?> file(String fileID) => _store.cachedFile(fileID);
static Future<void> updateFolderChildren(
String? folderID, {
Iterable<String> removeIDs = const [],
Iterable<CloudFile> 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>()
.map((value) => CloudFile.fromJson(Map<String, dynamic>.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<void> removeFilesFromAllFolders(Iterable<String> 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<Map>()
.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<String>().toList(),
'children': children,
};
}
await StorageManager.set(StorageKeys.folderChildrenIndex, folders);
});
}
static List<CloudFile>? 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>()
.map((value) => CloudFile.fromJson(Map<String, dynamic>.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<String, dynamic>.from(value));
} catch (_) {
return null;
}
}
static Map<String, dynamic> _map(String key) {
final raw = StorageManager.get<dynamic>(key);
if (raw is! Map) return <String, dynamic>{};
return raw.map((mapKey, value) => MapEntry(mapKey.toString(), value));
}
static void _indexFiles(
List<CloudFile> files,
Map<String, dynamic> fileIndex,
Map<String, dynamic> 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<void> _enqueue(Future<void> Function() operation) {
_writes = _writes.then((_) => operation());
return _writes;
}
static Future<void> removeFilesFromAllFolders(Iterable<String> fileIDs) =>
_store.removeFilesFromAllFolders(fileIDs);
}
+459
View File
@@ -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<Database> 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<void> initialize() async {
await _db;
}
Future<bool> 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<List<MediaLibraryDefinition>> 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 = <String, List<MediaLibrarySource>>{};
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<List<MediaLibraryItem>> 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<void> saveLibraries(List<MediaLibraryDefinition> 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<void> deleteLibrary(String id) async {
await (await _db).delete(
'media_libraries',
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> replaceItems(List<MediaLibraryItem> 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<void> 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<void> exportBackupTo(String destinationPath) async {
final db = await _db;
await db.execute('PRAGMA wal_checkpoint(FULL)');
await File(db.path).copy(destinationPath);
}
Future<void> cacheFolderChildren(
String? folderID,
List<CloudFile> 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<void> cacheFiles(List<CloudFile> 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<List<CloudFile>?> 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>()
.map((value) => CloudFile.fromJson(Map<String, dynamic>.from(value)))
.toList();
} catch (_) {
return null;
}
}
Future<CloudFile?> 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<String, dynamic>.from(value))
: null;
} catch (_) {
return null;
}
}
Future<void> updateFolderChildren(
String? folderID, {
Iterable<String> removeIDs = const [],
Iterable<CloudFile> 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<void> removeFilesFromAllFolders(Iterable<String> 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<void> _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<String, Object?> 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<String, Object?> _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;
}
+49
View File
@@ -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<MediaLibraryPage> {
bool _tmdbSearching = false;
String? _tmdbError;
List<Map<String, dynamic>> _tmdbResults = [];
bool _backupBusy = false;
final _apiKeyController = TextEditingController();
final _searchController = TextEditingController();
@@ -165,6 +167,22 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
child: const Text('媒体库'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: _backupBusy || state.isScanning
? null
: _exportScrapedData,
leading: const Icon(Icons.save_alt_rounded, size: 16),
child: const Text('导出数据'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: _backupBusy || state.isScanning
? null
: _importScrapedData,
leading: const Icon(Icons.upload_file_rounded, size: 16),
child: const Text('导入数据'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: state.selectedLibrary == null || state.isScanning
? null
@@ -564,6 +582,37 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
});
}
Future<void> _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<void> _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<void> _searchTMDB(String query) async {
final text = query.trim();
if (text.isEmpty || _tmdbApiKey.isEmpty) return;
+108 -11
View File
@@ -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<MediaLibraryState> {
GuangyaAPI? _api;
final _store = MediaLibraryStore();
bool _loaded = false;
bool _cancelScan = false;
@@ -95,11 +97,13 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
_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
? <MediaLibraryItem>[]
: _loadItems(selectedID);
: await _loadItems(selectedID);
state = state.copyWith(
libraries: libraries,
selectedLibraryID: selectedID,
@@ -115,7 +119,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
Future<void> 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<MediaLibraryState> {
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<MediaLibraryState> {
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<MediaLibraryState> {
state = state.copyWith(
libraries: libraries,
selectedLibraryID: library.id,
items: _loadItems(library.id),
items: await _loadItems(library.id),
statusMessage: '媒体库「${library.name}」已更新',
);
}
Future<void> 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<void> 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<void> scanSelectedLibrary() async {
final library = state.selectedLibrary;
if (library == null || _api == null || state.isScanning) return;
@@ -206,10 +252,10 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
);
try {
final initialItems = _loadAllItems()
final initialItems = await _loadAllItems()
..removeWhere((item) => item.libraryID == library.id);
final unique = <String, MediaLibraryItem>{
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<MediaLibraryState> {
Future<List<MediaLibraryItem>> 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<MediaLibraryState> {
if (!visited.add(visitKey)) continue;
var page = 0;
final cachedFolder = FileMetadataCache.folderChildren(folder.id);
final cachedFolder = await FileMetadataCache.folderChildren(folder.id);
final folderSnapshot = <CloudFile>[];
while (!_cancelScan) {
late List<CloudFile> files;
@@ -481,7 +527,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
final resolved = <CloudFile>[];
final pending = <CloudFile>[];
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<MediaLibraryState> {
return result;
}
Future<void> _migrateLegacyHiveIfNeeded() async {
if (!await _store.isEmpty) return;
final rawLibraries = StorageManager.get<dynamic>(
StorageKeys.mediaLibraries,
);
if (rawLibraries is! List) return;
final libraries = rawLibraries
.whereType<Map>()
.map(
(item) =>
MediaLibraryDefinition.fromJson(Map<String, dynamic>.from(item)),
)
.toList();
if (libraries.isEmpty) return;
final rawItems = StorageManager.get<dynamic>(StorageKeys.mediaLibraryItems);
final items = rawItems is List
? rawItems
.whereType<Map>()
.map(
(item) =>
MediaLibraryItem.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.file.id.isNotEmpty)
.toList()
: <MediaLibraryItem>[];
await _store.saveLibraries(libraries);
await _store.replaceItems(items);
}
Future<List<MediaLibraryDefinition>> _loadLibraries() {
return _store.libraries();
}
Future<List<MediaLibraryItem>> _loadItems(String libraryID) {
return _store.items(libraryID: libraryID);
}
Future<List<MediaLibraryItem>> _loadAllItems() {
return _store.items();
}
Future<void> _saveLibraries(List<MediaLibraryDefinition> libraries) {
return _store.saveLibraries(libraries);
}
Future<void> _saveAllItems(List<MediaLibraryItem> items) {
return _store.replaceItems(items);
}
/*
List<MediaLibraryDefinition> _loadLibraries() {
final raw = StorageManager.get<dynamic>(StorageKeys.mediaLibraries);
if (raw is! List) return [];
@@ -703,6 +799,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
items.map((item) => item.toJson()).toList(),
);
}
*/
static List<dynamic>? _findArrayDeep(
Map<String, dynamic> json,
+1 -1
View File
@@ -1291,7 +1291,7 @@ packages:
source: hosted
version: "1.10.2"
sqflite:
dependency: transitive
dependency: "direct main"
description:
name: sqflite
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
+1
View File
@@ -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