feat: store scraped media data in sqlite
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -1291,7 +1291,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user