diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 2643403..b3f7045 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -1946,7 +1946,7 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { widget.onRefreshAndRecognize(); }, leading: const Icon(Icons.sync_rounded, size: 16), - child: const Text('同步并自动识别'), + child: const Text('同步云盘命名并识别'), ), const SizedBox(height: 6), ShadButton.ghost( diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 42cbd6b..0587737 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -525,7 +525,10 @@ class MediaLibraryNotifier extends StateNotifier { if (_api == null || apiKey.trim().isEmpty) return; final groups = >{}; for (final item in values) { - final parsed = ParsedMediaName.parse(item.file.name); + final parsed = ParsedMediaName.parse( + item.file.name, + directoryName: _parentDirectoryName(item.file.cloudPath), + ); final key = '${item.mediaKind?.name ?? 'automatic'}:${parsed.title.toLowerCase()}'; groups.putIfAbsent(key, () => []).add(item); @@ -533,13 +536,21 @@ class MediaLibraryNotifier extends StateNotifier { final updates = []; for (final group in groups.values) { final prototype = group.first; + final fallback = MediaLibraryItem.fromFile( + prototype.libraryID, + prototype.file, + directoryName: _parentDirectoryName(prototype.file.cloudPath), + ); final recognized = await _recognizeMediaItem( - prototype.copyWith(file: prototype.file), + fallback, apiKey, proxyHost: StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', proxyPort: StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', ); - updates.addAll(group.map((item) => recognized.copyWith(file: item.file))); + final resolved = recognized.tmdbID == null && prototype.tmdbID != null + ? prototype + : recognized; + updates.addAll(group.map((item) => resolved.copyWith(file: item.file))); } if (updates.isEmpty) return; await _store.upsertItems(updates); @@ -562,6 +573,8 @@ class MediaLibraryNotifier extends StateNotifier { final apiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; state = state.copyWith(statusMessage: '正在同步云盘文件信息…', clearError: true); final updates = []; + final failures = []; + var recognizedCount = 0; for (final original in originals) { try { final detail = await _api!.fsDetail(original.file.id); @@ -600,20 +613,38 @@ class MediaLibraryNotifier extends StateNotifier { proxyPort: StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', ); + if (updated.tmdbID != null) recognizedCount++; + // A temporary TMDB or network failure must not erase a previously + // scraped item just because its refreshed file metadata was available. + if (updated.tmdbID == null && original.tmdbID != null) { + updated = original.copyWith( + file: latestFile, + updatedAt: DateTime.now(), + ); + } updated = await _renameMatchedMediaFile(updated); updates.add(updated); - } catch (_) { - // Keep indexing the remaining resources when one cloud detail fails. + } catch (error) { + failures.add('${original.file.name}: $error'); } } - if (updates.isEmpty) return; - await _store.upsertItems(updates); - _searchResultsCache.clear(); - final byID = {for (final item in updates) item.id: item}; - state = state.copyWith( - items: state.items.map((item) => byID[item.id] ?? item).toList(), - statusMessage: '已同步并重新识别 ${updates.length} 个资源', - ); + if (updates.isNotEmpty) { + await _store.upsertItems(updates); + _searchResultsCache.clear(); + final byID = {for (final item in updates) item.id: item}; + state = state.copyWith( + items: state.items.map((item) => byID[item.id] ?? item).toList(), + statusMessage: failures.isEmpty + ? '已同步 ${updates.length} 个资源,自动识别 $recognizedCount 个' + : '已同步 ${updates.length} 个资源,识别 $recognizedCount 个,${failures.length} 个同步失败', + errorMessage: failures.isEmpty ? null : failures.first, + ); + } else if (failures.isNotEmpty) { + state = state.copyWith( + errorMessage: '同步失败:${failures.first}', + statusMessage: '未能同步云盘文件信息', + ); + } } Future applyTMDBMatch( @@ -748,22 +779,66 @@ class MediaLibraryNotifier extends StateNotifier { }) async { if (_api == null || apiKey.trim().isEmpty) return fallback; try { + 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 fallback; + final expectedType = requestedKind == 'auto' ? null : requestedKind; + final normalizedTitle = _normalizeMediaTitle(fallback.title); + if (normalizedTitle.isEmpty) return fallback; Map? candidate; + var bestScore = -1; for (final value in values) { if (value is! Map) continue; final map = Map.from(value); - final type = map['media_type']?.toString(); - if (type == 'movie' || type == 'tv') { + final type = map['media_type']?.toString() ?? expectedType; + if (type != 'movie' && type != 'tv') continue; + final releaseDate = + (map['release_date'] ?? map['first_air_date'])?.toString() ?? ''; + // An explicit year in the file name is authoritative. Do not silently + // attach a remake or same-title work from a different year. + if (parsed.year != null && !releaseDate.startsWith('${parsed.year}')) { + continue; + } + final candidateTitle = _normalizeMediaTitle( + (map['title'] ?? map['name'] ?? '').toString(), + ); + final originalTitle = _normalizeMediaTitle( + (map['original_title'] ?? map['original_name'] ?? '').toString(), + ); + var score = 0; + if (candidateTitle == normalizedTitle || + originalTitle == normalizedTitle) { + score += 100; + } else if (candidateTitle.contains(normalizedTitle) || + normalizedTitle.contains(candidateTitle) || + originalTitle.contains(normalizedTitle) || + normalizedTitle.contains(originalTitle)) { + score += 40; + } + if (score == 0) continue; + if (parsed.year != null && releaseDate.startsWith('${parsed.year}')) { + score += 30; + } + if (score > bestScore) { + bestScore = score; + map['media_type'] = type; candidate = map; - break; } } if (candidate == null) return fallback; @@ -923,6 +998,13 @@ class MediaLibraryNotifier extends StateNotifier { return index <= 0 ? '' : cloudPath.substring(0, index); } + String _normalizeMediaTitle(String value) { + return value.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9\u4e00-\u9fff]+'), + '', + ); + } + String _safeCloudName(String value) { final cleaned = value .replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), ' ')