bound media sync lookups and rescan libraries

This commit is contained in:
ngfchl
2026-07-18 21:51:30 +08:00
parent 062ca506a4
commit c4f3383795
2 changed files with 114 additions and 177 deletions
+18 -7
View File
@@ -325,9 +325,9 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
? null
: () => ref
.read(mediaLibraryProvider.notifier)
.scanSelectedLibrary(),
.rescanSelectedLibrary(),
leading: const Icon(Icons.refresh_rounded, size: 16),
child: const Text('扫描'),
child: const Text('重新扫描'),
),
if (state.isScanning) ...[
const SizedBox(width: 8),
@@ -482,6 +482,8 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
builder: (_) => ExternalPlayerDialog(file: item.file),
),
onManualMatch: () => _showManualTMDBMatch(current ?? _detailWork!),
onCancelRefresh: () =>
ref.read(mediaLibraryProvider.notifier).cancelDetailSync(),
onRefreshAndRecognize: () async {
final selected = current ?? _detailWork!;
final pendingMatches = await ref
@@ -554,7 +556,7 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
? null
: () => ref
.read(mediaLibraryProvider.notifier)
.scanSelectedLibrary(),
.rescanSelectedLibrary(),
),
);
},
@@ -1932,6 +1934,7 @@ class _MediaDetailPanel extends ConsumerStatefulWidget {
final ValueChanged<MediaLibraryItem> onPlay;
final ValueChanged<MediaLibraryItem> onExternalPlay;
final VoidCallback onManualMatch;
final VoidCallback onCancelRefresh;
final Future<void> Function() onRefreshAndRecognize;
const _MediaDetailPanel({
@@ -1941,6 +1944,7 @@ class _MediaDetailPanel extends ConsumerStatefulWidget {
required this.onPlay,
required this.onExternalPlay,
required this.onManualMatch,
required this.onCancelRefresh,
required this.onRefreshAndRecognize,
});
@@ -2168,17 +2172,24 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
Positioned.fill(
child: ColoredBox(
color: cs.background.withValues(alpha: 0.86),
child: const Center(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
const SizedBox(
width: 38,
height: 38,
child: CircularProgressIndicator(strokeWidth: 3),
),
SizedBox(height: 14),
Text('正在同步、识别并匹配媒体信息'),
const SizedBox(height: 14),
const Text('正在同步、识别并匹配媒体信息'),
const SizedBox(height: 14),
ShadButton.destructive(
size: ShadButtonSize.sm,
onPressed: widget.onCancelRefresh,
leading: const Icon(Icons.stop_rounded, size: 16),
child: const Text('取消'),
),
],
),
),
+96 -170
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:flutter_riverpod/legacy.dart';
@@ -120,6 +119,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
final _artworkHydrationLibraries = <String>{};
bool _loaded = false;
bool _cancelScan = false;
bool _cancelDetailSync = false;
MediaLibraryNotifier() : super(const MediaLibraryState());
@@ -307,7 +307,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
}
Future<void> scanSelectedLibrary() async {
Future<void> scanSelectedLibrary({bool forceRemote = false}) async {
final library = state.selectedLibrary;
if (library == null || _api == null || state.isScanning) return;
@@ -318,7 +318,9 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
scanLogs: [
MediaLibraryScanLog(
createdAt: DateTime.now(),
message: '任务已创建,开始扫描「${library.name}',
message: forceRemote
? '任务已创建,正在重新扫描「${library.name}'
: '任务已创建,开始扫描「${library.name}',
),
],
clearError: true,
@@ -330,10 +332,12 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
if (removedDiscStreams > 0) {
_appendScanLog('已清理 $removedDiscStreams 个已入库的光盘目录内部文件');
}
final previousLibraryItems = await _loadItems(library.id);
final initialItems = await _loadAllItems()
..removeWhere((item) => item.libraryID == library.id);
final unique = <String, MediaLibraryItem>{
for (final item in await _loadItems(library.id)) item.file.id: item,
if (!forceRemote)
for (final item in previousLibraryItems) item.file.id: item,
};
state = state.copyWith(items: unique.values.toList());
final tmdbApiKey =
@@ -426,16 +430,34 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
source.path,
recursive: library.recursive,
minimumSizeBytes: library.minimumSizeMB * 1024 * 1024,
forceRemote: forceRemote,
onMediaFiles: indexBatch,
);
}
await pendingPersistence;
final items = unique.values.toList()
var items = unique.values.toList()
..sort(
(a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
// A cancelled full rescan has only seen a subset of the cloud tree. Keep
// unseen rows rather than incorrectly treating them as deleted.
if (_cancelScan && forceRemote) {
final merged = <String, MediaLibraryItem>{
for (final item in previousLibraryItems) item.id: item,
for (final item in items) item.id: item,
};
items = merged.values.toList()
..sort(
(a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
}
final allItems = [...initialItems, ...items];
final discoveredIDs = items.map((item) => item.id).toSet();
final removedMissing = _cancelScan || !forceRemote
? 0
: previousLibraryItems
.where((item) => !discoveredIDs.contains(item.id))
.length;
final updatedLibrary = library.copyWith(updatedAt: DateTime.now());
final libraries = state.libraries
.map((item) => item.id == library.id ? updatedLibrary : item)
@@ -448,12 +470,16 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
items: items,
statusMessage: _cancelScan
? '扫描已停止,已保留 ${items.length} 个项目'
: '扫描完成:${items.length} 个视频文件',
: removedMissing == 0
? '扫描完成:${items.length} 个视频文件'
: '重新扫描完成:${items.length} 个视频文件,已移除 $removedMissing 个不存在的条目',
);
_appendScanLog(
_cancelScan
? '扫描已停止,已保留 ${items.length} 个条目'
: '扫描完成,共入库 ${items.length} 个条目',
: removedMissing == 0
? '扫描完成,共入库 ${items.length} 个条目'
: '重新扫描完成,共入库 ${items.length} 个条目,已清理 $removedMissing 个失效条目',
);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
@@ -475,6 +501,14 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
_appendScanLog('正在请求停止扫描…');
}
Future<void> rescanSelectedLibrary() =>
scanSelectedLibrary(forceRemote: true);
void cancelDetailSync() {
_cancelDetailSync = true;
_appendScanLog('[同步识别][DEBUG] 已请求取消同步识别');
}
void _appendScanLog(String message, {bool isError = false}) {
final logs = [
...state.scanLogs,
@@ -578,11 +612,13 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
if (_api == null) return const [];
final originals = values.toList();
if (originals.isEmpty) return const [];
_cancelDetailSync = false;
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
state = state.copyWith(statusMessage: '正在同步云盘文件信息…', clearError: true);
final synced = <_SyncedMediaItem>[];
final failures = <String>[];
for (final original in originals) {
if (_cancelDetailSync) break;
try {
_appendScanLog(
'[同步识别][DEBUG] 开始:${original.file.name} '
@@ -619,10 +655,12 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
if (synced.isEmpty) {
state = state.copyWith(
errorMessage: failures.isEmpty
errorMessage: _cancelDetailSync
? null
: failures.isEmpty
? '未能同步云盘文件信息'
: '同步失败:${failures.first}',
statusMessage: '未能同步云盘文件信息',
statusMessage: _cancelDetailSync ? '同步识别已取消' : '未能同步云盘文件信息',
);
return const [];
}
@@ -634,6 +672,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
var recognizedCount = 0;
var renamedCount = 0;
for (final entry in synced) {
if (_cancelDetailSync) break;
final original = entry.original;
try {
final fallback = entry.fallback;
@@ -730,6 +769,8 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
.toList(),
statusMessage: apiKey.trim().isEmpty
? '已同步 ${updates.length} 个资源,未配置 TMDB,未执行自动识别'
: _cancelDetailSync
? '同步识别已取消,已处理 ${updates.length} 个资源'
: failures.isEmpty
? '已同步 ${updates.length} 个资源,自动识别 $recognizedCount 个,规范命名 $renamedCount'
: '已同步 ${updates.length} 个资源,识别 $recognizedCount 个,规范命名 $renamedCount 个,${failures.length} 个失败',
@@ -1410,6 +1451,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
String rootPath, {
required bool recursive,
required int minimumSizeBytes,
required bool forceRemote,
required Future<void> Function(List<CloudFile> files) onMediaFiles,
}) async {
final folders = <_ScanFolder>[_ScanFolder(rootID, rootPath)];
@@ -1429,7 +1471,9 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
var page = 0;
final cachedFolder = await FileMetadataCache.folderChildren(folder.id);
final cachedFolder = forceRemote
? null
: await FileMetadataCache.folderChildren(folder.id);
final folderSnapshot = <CloudFile>[];
while (!_cancelScan) {
late List<CloudFile> files;
@@ -1714,55 +1758,6 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
return _cacheResolvedCloudFile(knownFile, resolved);
}
// The user may have renamed the file outside this app, so its old name is
// no longer searchable. Resolve the previous parent directory by name and
// compare every child by GCID, which remains stable across a rename.
final parentName = _parentDirectoryName(knownFile.cloudPath);
if (gcid != null && gcid.isNotEmpty && parentName != null) {
try {
_appendScanLog('[同步识别][DEBUG] 按父目录 GCID 定位:目录=$parentNamegcid=$gcid');
final foldersResponse = await _api!.searchFiles(
parentName,
page: 0,
pageSize: 100,
);
final folders = _extractFiles(
foldersResponse,
).where((file) => file.isDirectory && file.name == parentName);
for (final folder in folders) {
final response = await _api!.fsFiles(
parentID: folder.id,
page: 0,
pageSize: 1000,
orderBy: 0,
sortType: 0,
);
for (final candidate in _extractFiles(response)) {
if (candidate.isDirectory) continue;
var resolved = candidate;
if (resolved.gcid != gcid) {
try {
final detail = await _api!.fsDetail(candidate.id);
resolved =
_fileFromDetail(detail, candidate.id, candidate) ??
candidate;
} catch (_) {
continue;
}
}
if (resolved.gcid == gcid) {
_appendScanLog(
'[同步识别][DEBUG] GCID 定位成功:${knownFile.id} -> '
'${resolved.id},名称=${resolved.name}',
);
return _cacheResolvedCloudFile(knownFile, resolved);
}
}
}
} catch (_) {
// The caller emits the final diagnostic after all resolution paths fail.
}
}
if (gcid != null && gcid.isNotEmpty && parentID != null) {
final located = await _resolveFromCurrentDirectory(
knownFile,
@@ -1770,11 +1765,10 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
);
if (located != null) return located;
}
final located = await _resolveFromLibrarySources(
knownFile,
libraryID: libraryID,
_appendScanLog(
'[同步识别][DEBUG] 未定位到当前文件目录中的 GCID;停止自动查找,建议重新扫描媒体库',
isError: true,
);
if (located != null) return located;
return null;
}
@@ -1784,119 +1778,51 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}) async {
final gcid = knownFile.gcid?.trim();
if (gcid == null || gcid.isEmpty) return null;
const maxFolders = 1200;
final parentPath = _parentPath(knownFile.cloudPath);
final queue = Queue<_ScanFolder>()..add(_ScanFolder(parentID, parentPath));
final visited = <String>{};
var searchedFolders = 0;
_appendScanLog('[同步识别][DEBUG] 从当前文件目录扫描:parentId=$parentIDgcid=$gcid');
while (queue.isNotEmpty && searchedFolders < maxFolders) {
final folder = queue.removeFirst();
if (folder.id == null || !visited.add(folder.id!)) continue;
searchedFolders++;
List<CloudFile> children;
try {
children = await _allRemoteFolderFiles(folder.id);
} catch (_) {
continue;
}
await FileMetadataCache.cacheFolderChildren(folder.id, children);
for (final child in children) {
final childPath = '${folder.path}/${child.name}';
if (child.isDirectory) {
queue.add(_ScanFolder(child.id, childPath));
final parentPath = _parentPath(knownFile.cloudPath);
List<CloudFile> children;
try {
children = await _allRemoteFolderFiles(parentID);
} catch (_) {
return null;
}
await FileMetadataCache.cacheFolderChildren(parentID, children);
final extension = _extensionOf(knownFile.name).toLowerCase();
final candidates = children
.where((child) {
if (child.isDirectory) return false;
if (knownFile.size != null && child.size != knownFile.size) {
return false;
}
return extension.isEmpty ||
_extensionOf(child.name).toLowerCase() == extension;
})
.take(12);
for (final child in candidates) {
var resolved = child;
if (resolved.gcid != gcid) {
try {
final detail = await _api!.fsDetail(child.id);
resolved = _fileFromDetail(detail, child.id, child) ?? child;
} catch (_) {
continue;
}
var resolved = child;
if (resolved.gcid != gcid) {
try {
final detail = await _api!.fsDetail(child.id);
resolved = _fileFromDetail(detail, child.id, child) ?? child;
} catch (_) {
continue;
}
}
if (resolved.gcid == gcid) {
final exact = _withPath(resolved, childPath);
_appendScanLog(
'[同步识别][DEBUG] 当前目录定位成功:${knownFile.id} -> '
'${exact.id},路径=${exact.cloudPath}',
);
return _cacheResolvedCloudFile(
knownFile,
exact,
cloudPath: exact.cloudPath,
);
}
}
}
_appendScanLog('[同步识别][DEBUG] 当前文件目录未命中:已检查 $searchedFolders 个目录');
return null;
}
Future<CloudFile?> _resolveFromLibrarySources(
CloudFile knownFile, {
String? libraryID,
}) async {
final gcid = knownFile.gcid?.trim();
if (gcid == null || gcid.isEmpty) return null;
final library = state.libraries
.where((item) => item.id == libraryID)
.firstOrNull;
if (library == null) return null;
const maxFolders = 6000;
final queue = Queue<_ScanFolder>();
for (final source in library.sources) {
queue.add(_ScanFolder(source.rootID, source.path));
}
final visited = <String>{};
var searchedFolders = 0;
_appendScanLog('[同步识别][DEBUG] 启动媒体库根目录 GCID 定向扫描:$gcid');
while (queue.isNotEmpty && searchedFolders < maxFolders) {
final folder = queue.removeFirst();
final key = folder.id ?? 'root:${folder.path}';
if (!visited.add(key)) continue;
searchedFolders++;
List<CloudFile> children;
try {
children = await _allRemoteFolderFiles(folder.id);
} catch (_) {
continue;
}
await FileMetadataCache.cacheFolderChildren(folder.id, children);
for (final child in children) {
final childPath = '${folder.path}/${child.name}';
if (child.isDirectory) {
queue.add(_ScanFolder(child.id, childPath));
continue;
}
var resolved = child;
if (resolved.gcid != gcid) {
try {
final detail = await _api!.fsDetail(child.id);
resolved = _fileFromDetail(detail, child.id, child) ?? child;
} catch (_) {
continue;
}
}
if (resolved.gcid == gcid) {
final exact = _withPath(resolved, childPath);
_appendScanLog(
'[同步识别][DEBUG] 媒体库源目录定位成功:${knownFile.id} -> '
'${exact.id},路径=${exact.cloudPath}',
);
return _cacheResolvedCloudFile(
knownFile,
exact,
cloudPath: exact.cloudPath,
);
}
if (resolved.gcid == gcid) {
final exact = _withPath(resolved, '$parentPath/${resolved.name}');
_appendScanLog(
'[同步识别][DEBUG] 当前目录定位成功:${knownFile.id} -> '
'${exact.id},路径=${exact.cloudPath}',
);
return _cacheResolvedCloudFile(
knownFile,
exact,
cloudPath: exact.cloudPath,
);
}
}
_appendScanLog(
'[同步识别][DEBUG] 媒体库源目录扫描未命中:已检查 $searchedFolders 个目录',
isError: true,
'[同步识别][DEBUG] 当前文件目录未命中:已检查 ${children.length} 个条目,详情查询不超过 12 次',
);
return null;
}