improve media matching workflow

This commit is contained in:
ngfchl
2026-07-18 21:09:08 +08:00
parent a3a9ab9c8d
commit d52a3ea4f8
2 changed files with 240 additions and 100 deletions
+96 -83
View File
@@ -476,10 +476,25 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
onManualMatch: () => _showManualTMDBMatch(current ?? _detailWork!),
onRefreshAndRecognize: () async {
final selected = current ?? _detailWork!;
await ref
final pendingMatches = await ref
.read(mediaLibraryProvider.notifier)
.refreshAndRecognizeItems(selected.resources);
if (!mounted || _detailWork == null) return;
for (final request in pendingMatches) {
if (!context.mounted) return;
final candidate = await showShadDialog<Map<String, dynamic>>(
context: context,
builder: (_) => _ManualTMDBMatchDialog(
initialQuery: request.items.first.title,
initialResults: request.candidates,
),
);
if (candidate == null) continue;
final notifier = ref.read(mediaLibraryProvider.notifier);
for (final resource in request.items) {
await notifier.applyTMDBMatch(resource, candidate);
}
}
if (!context.mounted || _detailWork == null) return;
final updatedWorks = _MediaWork.fromItems(
ref.read(mediaLibraryProvider).items,
);
@@ -1974,14 +1989,8 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _refreshingMedia ? null : _refreshAndRecognize,
leading: _refreshingMedia
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync_rounded, size: 16),
child: Text(_refreshingMedia ? '正在同步并识别' : '同步云盘命名并识别'),
leading: const Icon(Icons.sync_rounded, size: 16),
child: const Text('同步云盘命名并识别'),
),
const SizedBox(height: 6),
ShadButton.ghost(
@@ -1991,21 +2000,15 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
widget.onManualMatch();
},
leading: const Icon(Icons.edit_note_rounded, size: 16),
child: const Text('手动匹配 TMDB'),
child: const Text('手动选择匹配'),
),
],
),
),
child: ShadButton.outline(
onPressed: _refreshingMedia ? null : () => _updatePopover.toggle(),
leading: _refreshingMedia
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync_rounded, size: 16),
child: Text(_refreshingMedia ? '正在识别' : '更新媒体信息'),
leading: const Icon(Icons.expand_more_rounded, size: 16),
child: const Text('更新媒体'),
),
);
}
@@ -2065,76 +2068,78 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
final cs = ShadTheme.of(context).colorScheme;
final item = widget.work.primary;
final isSeries = item.mediaKind == TMDBMediaKind.tv;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Stack(
children: [
Wrap(
spacing: 8,
runSpacing: 8,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: widget.onBack,
leading: const Icon(Icons.arrow_back_rounded, size: 16),
child: const Text('返回海报墙'),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: widget.onBack,
leading: const Icon(Icons.arrow_back_rounded, size: 16),
child: const Text('返回海报墙'),
),
ShadButton(
onPressed: () => widget.onPlay(_resource),
leading: const Icon(Icons.play_arrow_rounded, size: 16),
child: const Text('播放'),
),
ShadButton.outline(
onPressed: () => widget.onExternalPlay(_resource),
leading: const Icon(Icons.launch_rounded, size: 16),
child: const Text('外部播放'),
),
ShadButton.outline(
onPressed: () => widget.onDownload(_resource),
leading: const Icon(Icons.download_rounded, size: 16),
child: const Text('下载'),
),
_updateMediaMenu(),
],
),
_updateMediaMenu(),
const SizedBox(width: 8),
ShadButton(
onPressed: () => widget.onPlay(_resource),
leading: const Icon(Icons.play_arrow_rounded, size: 16),
child: const Text('播放'),
),
ShadButton.outline(
onPressed: () => widget.onExternalPlay(_resource),
leading: const Icon(Icons.launch_rounded, size: 16),
child: const Text('外部播放器'),
),
ShadButton.outline(
onPressed: () => widget.onDownload(_resource),
leading: const Icon(Icons.download_rounded, size: 16),
child: const Text('下载'),
const SizedBox(height: 14),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailInformation(context, item, isSeries),
if (item.isMatched) ...[
const SizedBox(height: 22),
_tmdbEnrichment(context),
],
const SizedBox(height: 26),
_resourceList(context, cs),
],
),
),
),
],
),
const SizedBox(height: 14),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 700;
final information = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailInformation(context, item, isSeries),
if (item.isMatched) ...[
const SizedBox(height: 22),
_tmdbEnrichment(context),
if (_refreshingMedia)
Positioned.fill(
child: ColoredBox(
color: cs.background.withValues(alpha: 0.86),
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 38,
height: 38,
child: CircularProgressIndicator(strokeWidth: 3),
),
SizedBox(height: 14),
Text('正在同步、识别并匹配媒体信息'),
],
],
);
final resources = _resourceList(context, cs);
return SingleChildScrollView(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
information,
const SizedBox(height: 18),
resources,
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: information),
const SizedBox(width: 28),
SizedBox(width: 310, child: resources),
],
),
);
},
),
),
),
),
),
],
);
}
@@ -2625,8 +2630,12 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
class _ManualTMDBMatchDialog extends ConsumerStatefulWidget {
final String initialQuery;
final List<Map<String, dynamic>>? initialResults;
const _ManualTMDBMatchDialog({required this.initialQuery});
const _ManualTMDBMatchDialog({
required this.initialQuery,
this.initialResults,
});
@override
ConsumerState<_ManualTMDBMatchDialog> createState() =>
@@ -2644,7 +2653,11 @@ class _ManualTMDBMatchDialogState
void initState() {
super.initState();
_queryController = TextEditingController(text: widget.initialQuery);
Future.microtask(_search);
if (widget.initialResults != null) {
_results = widget.initialResults!;
} else {
Future.microtask(_search);
}
}
@override
+144 -17
View File
@@ -27,6 +27,13 @@ bool isMediaScanDiscLayout(Iterable<CloudFile> children) {
return directoryNames.contains('BDMV') || directoryNames.contains('VIDEO_TS');
}
class MediaTMDBMatchRequest {
final List<MediaLibraryItem> items;
final List<Map<String, dynamic>> candidates;
const MediaTMDBMatchRequest({required this.items, required this.candidates});
}
class MediaLibraryState {
final List<MediaLibraryDefinition> libraries;
final String? selectedLibraryID;
@@ -564,12 +571,12 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
/// Pulls current file metadata from the cloud before parsing and matching it.
/// This is required after a file was renamed outside the media scanner.
Future<void> refreshAndRecognizeItems(
Future<List<MediaTMDBMatchRequest>> refreshAndRecognizeItems(
Iterable<MediaLibraryItem> values,
) async {
if (_api == null) return;
if (_api == null) return const [];
final originals = values.toList();
if (originals.isEmpty) return;
if (originals.isEmpty) return const [];
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
state = state.copyWith(statusMessage: '正在同步云盘文件信息…', clearError: true);
final synced = <_SyncedMediaItem>[];
@@ -613,12 +620,13 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
: '同步失败:${failures.first}',
statusMessage: '未能同步云盘文件信息',
);
return;
return const [];
}
state = state.copyWith(statusMessage: '正在自动识别并规范命名…');
final updates = <MediaLibraryItem>[];
final replacements = <String, MediaLibraryItem>{};
final pendingMatches = <MediaTMDBMatchRequest>[];
var recognizedCount = 0;
var renamedCount = 0;
for (final entry in synced) {
@@ -634,16 +642,35 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
'标题=${parsed.title},年份=${parsed.year ?? '-'}'
'类型=${fallback.mediaKind?.name ?? 'automatic'}',
);
var updated = apiKey.trim().isEmpty
? fallback
: await _recognizeMediaItem(
fallback,
apiKey,
proxyHost:
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
proxyPort:
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '',
);
var updated = fallback;
if (apiKey.trim().isNotEmpty) {
final candidates = await _tmdbCandidatesForItem(
fallback,
apiKey,
proxyHost:
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
proxyPort:
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '',
);
if (candidates.length == 1) {
updated = await _applyTMDBCandidateAndDetails(
fallback,
candidates.single,
apiKey,
proxyHost:
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
proxyPort:
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '',
);
} else if (candidates.length > 1 && original.tmdbID == null) {
pendingMatches.add(
MediaTMDBMatchRequest(items: [fallback], candidates: candidates),
);
_appendScanLog(
'[同步识别][DEBUG] 存在 ${candidates.length} 个 TMDB 候选,等待用户选择',
);
}
}
if (updated.tmdbID != null) {
recognizedCount++;
_appendScanLog(
@@ -705,6 +732,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
errorMessage: failures.isEmpty ? null : failures.first,
);
}
return pendingMatches;
}
/// Keeps SQLite media records aligned with file-manager rename operations
@@ -786,12 +814,20 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
// A selected candidate is still valid if its detail request fails.
}
}
await _store.upsertItems([updated]);
updated = await _renameMatchedMediaFile(updated);
await _replaceItemsByPreviousIDs({'${item.libraryID}:${item.id}': updated});
state = state.copyWith(
items: state.items
.map((current) => current.id == updated.id ? updated : current)
.map(
(current) =>
current.libraryID == item.libraryID && current.id == item.id
? updated
: current,
)
.toList(),
statusMessage: '已匹配《${updated.title}',
statusMessage: updated.file.name == item.file.name
? '已匹配《${updated.title}'
: '已匹配并规范命名《${updated.title}',
clearError: true,
);
return updated;
@@ -979,6 +1015,97 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
}
Future<List<Map<String, dynamic>>> _tmdbCandidatesForItem(
MediaLibraryItem fallback,
String apiKey, {
required String proxyHost,
required String proxyPort,
}) async {
if (_api == null || apiKey.trim().isEmpty) return const [];
final parsed = ParsedMediaName.parse(
fallback.file.name,
directoryName: _parentDirectoryName(fallback.file.cloudPath),
);
final requestedKind = fallback.mediaKind == TMDBMediaKind.tv
? 'tv'
: fallback.mediaKind == TMDBMediaKind.movie
? 'movie'
: 'auto';
final result = await _api!.tmdbSearch(
fallback.title,
apiKey: apiKey,
mediaKind: requestedKind,
proxyHost: proxyHost,
proxyPort: proxyPort,
year: parsed.year,
);
final values = result['results'];
if (values is! List) return const [];
final expectedType = requestedKind == 'auto' ? null : requestedKind;
final normalizedTitle = _normalizeMediaTitle(fallback.title);
if (normalizedTitle.isEmpty) return const [];
final scored = <({int score, Map<String, dynamic> candidate})>[];
for (final value in values) {
if (value is! Map) continue;
final candidate = Map<String, dynamic>.from(value);
final type = candidate['media_type']?.toString() ?? expectedType;
if (type != 'movie' && type != 'tv') continue;
final releaseDate =
(candidate['release_date'] ?? candidate['first_air_date'])
?.toString() ??
'';
if (parsed.year != null && !releaseDate.startsWith('${parsed.year}')) {
continue;
}
final title = _normalizeMediaTitle(
(candidate['title'] ?? candidate['name'] ?? '').toString(),
);
final originalTitle = _normalizeMediaTitle(
(candidate['original_title'] ?? candidate['original_name'] ?? '')
.toString(),
);
var score = 0;
if (title == normalizedTitle || originalTitle == normalizedTitle) {
score = 100;
} else if (title.contains(normalizedTitle) ||
normalizedTitle.contains(title) ||
originalTitle.contains(normalizedTitle) ||
normalizedTitle.contains(originalTitle)) {
score = 40;
}
if (score == 0) continue;
if (parsed.year != null) score += 30;
candidate['media_type'] = type;
scored.add((score: score, candidate: candidate));
}
scored.sort((a, b) => b.score.compareTo(a.score));
return scored.map((entry) => entry.candidate).toList();
}
Future<MediaLibraryItem> _applyTMDBCandidateAndDetails(
MediaLibraryItem fallback,
Map<String, dynamic> candidate,
String apiKey, {
required String proxyHost,
required String proxyPort,
}) async {
var item = _itemFromTMDBCandidate(fallback, candidate);
if (item.tmdbID == null) return item;
try {
final details = await _tmdbDetails(
item.tmdbID!,
item.mediaKind,
apiKey: apiKey,
proxyHost: proxyHost,
proxyPort: proxyPort,
);
item = _itemFromTMDBDetails(item, details);
} catch (_) {
// The selected search candidate remains usable when detail hydration fails.
}
return item;
}
Future<MediaLibraryItem> _renameMatchedMediaFile(
MediaLibraryItem item,
) async {