improve media matching workflow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user