diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 103409d..8785de7 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -476,10 +476,25 @@ class _MediaLibraryPageState extends ConsumerState { 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>( + 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>? 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 diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index f9e621d..3657edb 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -27,6 +27,13 @@ bool isMediaScanDiscLayout(Iterable children) { return directoryNames.contains('BDMV') || directoryNames.contains('VIDEO_TS'); } +class MediaTMDBMatchRequest { + final List items; + final List> candidates; + + const MediaTMDBMatchRequest({required this.items, required this.candidates}); +} + class MediaLibraryState { final List libraries; final String? selectedLibraryID; @@ -564,12 +571,12 @@ class MediaLibraryNotifier extends StateNotifier { /// 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 refreshAndRecognizeItems( + Future> refreshAndRecognizeItems( Iterable 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(StorageKeys.tmdbApiKey) ?? ''; state = state.copyWith(statusMessage: '正在同步云盘文件信息…', clearError: true); final synced = <_SyncedMediaItem>[]; @@ -613,12 +620,13 @@ class MediaLibraryNotifier extends StateNotifier { : '同步失败:${failures.first}', statusMessage: '未能同步云盘文件信息', ); - return; + return const []; } state = state.copyWith(statusMessage: '正在自动识别并规范命名…'); final updates = []; final replacements = {}; + final pendingMatches = []; var recognizedCount = 0; var renamedCount = 0; for (final entry in synced) { @@ -634,16 +642,35 @@ class MediaLibraryNotifier extends StateNotifier { '标题=${parsed.title},年份=${parsed.year ?? '-'},' '类型=${fallback.mediaKind?.name ?? 'automatic'}', ); - var updated = apiKey.trim().isEmpty - ? fallback - : await _recognizeMediaItem( - fallback, - apiKey, - proxyHost: - StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', - proxyPort: - StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', - ); + var updated = fallback; + if (apiKey.trim().isNotEmpty) { + final candidates = await _tmdbCandidatesForItem( + fallback, + apiKey, + proxyHost: + StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', + proxyPort: + StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', + ); + if (candidates.length == 1) { + updated = await _applyTMDBCandidateAndDetails( + fallback, + candidates.single, + apiKey, + proxyHost: + StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', + proxyPort: + StorageManager.get(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 { 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 { // 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 { } } + Future>> _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 candidate})>[]; + for (final value in values) { + if (value is! Map) continue; + final candidate = Map.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 _applyTMDBCandidateAndDetails( + MediaLibraryItem fallback, + Map 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 _renameMatchedMediaFile( MediaLibraryItem item, ) async {