From 40d52d19ba43c79b13f55c68ffabcbf2222508f9 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 20:10:17 +0800 Subject: [PATCH] resync renamed media and preserve technical names --- lib/models/media_library.dart | 31 +++++++ lib/pages/media_library_page.dart | 13 ++- lib/providers/media_library_provider.dart | 105 ++++++++++++++++++++-- test/media_name_parser_test.dart | 18 ++++ 4 files changed, 161 insertions(+), 6 deletions(-) diff --git a/lib/models/media_library.dart b/lib/models/media_library.dart index ff3e435..dca447b 100644 --- a/lib/models/media_library.dart +++ b/lib/models/media_library.dart @@ -576,6 +576,37 @@ class ParsedMediaName { parent = ParsedMediaName.parse(directoryName); if (parent.title.isNotEmpty) title = parent.title; } + final seasonOnly = RegExp( + r'\b(?:Season|S)\s*0?(\d{1,2})\b', + caseSensitive: false, + ).firstMatch(normalized); + if (season == null && seasonOnly != null) { + season = int.tryParse(seasonOnly.group(1)!); + } + // Disc/episode files are frequently named only "0102" or "02". Use an + // explicit season from the parent directory first; otherwise only infer + // the unambiguous SS EE form to avoid treating years as episode numbers. + final numericOnly = RegExp(r'^\d{1,4}$').hasMatch(stem.trim()); + if (episode == null && numericOnly && directoryName != null) { + final digits = stem.trim(); + final parentSeason = parent?.season; + if (parentSeason != null) { + season = parentSeason; + episode = int.tryParse(digits); + } else if (digits.length == 3 || digits.length == 4) { + final split = digits.length - 2; + final inferredSeason = int.tryParse(digits.substring(0, split)); + final inferredEpisode = int.tryParse(digits.substring(split)); + if (inferredSeason != null && + inferredSeason > 0 && + inferredSeason <= 99 && + inferredEpisode != null && + inferredEpisode > 0) { + season = inferredSeason; + episode = inferredEpisode; + } + } + } if (title.isEmpty) title = normalized.trim(); return ParsedMediaName( diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 71fe5c0..2a56d7c 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -461,6 +461,9 @@ class _MediaLibraryPageState extends ConsumerState { builder: (_) => ExternalPlayerDialog(file: item.file), ), onManualMatch: () => _showManualTMDBMatch(current ?? _detailWork!), + onRefreshAndRecognize: () => ref + .read(mediaLibraryProvider.notifier) + .refreshAndRecognizeItems((current ?? _detailWork!).resources), onRescan: state.isScanning ? null : () => @@ -1879,6 +1882,7 @@ class _MediaDetailPanel extends ConsumerStatefulWidget { final ValueChanged onPlay; final ValueChanged onExternalPlay; final VoidCallback onManualMatch; + final VoidCallback onRefreshAndRecognize; final VoidCallback? onRescan; const _MediaDetailPanel({ @@ -1888,6 +1892,7 @@ class _MediaDetailPanel extends ConsumerStatefulWidget { required this.onPlay, required this.onExternalPlay, required this.onManualMatch, + required this.onRefreshAndRecognize, this.onRescan, }); @@ -1976,10 +1981,16 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { child: const Text('返回海报墙'), ), ShadButton.outline( - onPressed: widget.onManualMatch, + onPressed: widget.onRefreshAndRecognize, leading: const Icon(Icons.manage_search_rounded, size: 16), child: const Text('重新识别'), ), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: widget.onManualMatch, + leading: const Icon(Icons.edit_note_rounded, size: 16), + child: const Text('手动匹配'), + ), const SizedBox(width: 8), ShadButton.outline( onPressed: widget.onRescan, diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index f1ebec8..008320f 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -325,7 +325,12 @@ class MediaLibraryNotifier extends StateNotifier { directoryName: _parentDirectoryName(file.cloudPath), ); final existing = unique[file.id]; - var item = existing == null + final fileChanged = + existing != null && + (existing.file.name != file.name || + existing.file.gcid != file.gcid || + existing.file.cloudPath != file.cloudPath); + var item = existing == null || fileChanged ? await _recognizeMediaItem( fallback, tmdbApiKey, @@ -521,6 +526,66 @@ 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( + Iterable values, + ) async { + if (_api == null) return; + final originals = values.toList(); + if (originals.isEmpty) return; + final apiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; + state = state.copyWith(statusMessage: '正在同步云盘文件信息…', clearError: true); + final updates = []; + for (final original in originals) { + try { + final detail = await _api!.fsDetail(original.file.id); + final fromCloud = _extractFiles( + detail, + ).where((file) => file.id == original.file.id).firstOrNull; + final latestName = + fromCloud?.name ?? + _findStringDeep(detail, const ['fileName', 'name', 'resName']) ?? + original.file.name; + final parentPath = _parentPath(original.file.cloudPath); + final latestFile = (fromCloud ?? original.file).copyWith( + name: latestName, + cloudPath: parentPath.isEmpty + ? latestName + : '$parentPath/$latestName', + ); + await FileMetadataCache.cacheFiles([latestFile]); + final fallback = MediaLibraryItem.fromFile( + original.libraryID, + latestFile, + directoryName: _parentDirectoryName(latestFile.cloudPath), + ); + var updated = apiKey.trim().isEmpty + ? fallback + : await _recognizeMediaItem( + fallback, + apiKey, + proxyHost: + StorageManager.get(StorageKeys.tmdbProxyHost) ?? '', + proxyPort: + StorageManager.get(StorageKeys.tmdbProxyPort) ?? '', + ); + updated = await _renameMatchedMediaFile(updated); + updates.add(updated); + } catch (_) { + // Keep indexing the remaining resources when one cloud detail fails. + } + } + 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} 个资源', + ); + } + Future applyTMDBMatch( MediaLibraryItem item, Map candidate, @@ -698,17 +763,40 @@ class MediaLibraryNotifier extends StateNotifier { if (_api == null || item.tmdbID == null || item.mediaKind == null) { return item; } - final parsed = ParsedMediaName.parse( - item.file.name, - directoryName: _parentDirectoryName(item.file.cloudPath), + final directoryName = _parentDirectoryName(item.file.cloudPath); + final fileParsed = ParsedMediaName.parse(item.file.name); + final parentParsed = directoryName == null + ? null + : ParsedMediaName.parse(directoryName); + final parsed = ParsedMediaName( + title: fileParsed.title, + year: fileParsed.year ?? parentParsed?.year, + season: fileParsed.season ?? parentParsed?.season, + episode: fileParsed.episode, + isEpisode: fileParsed.isEpisode, + resolution: fileParsed.resolution ?? parentParsed?.resolution, + source: fileParsed.source ?? parentParsed?.source, + videoCodec: fileParsed.videoCodec ?? parentParsed?.videoCodec, + audio: fileParsed.audio ?? parentParsed?.audio, + dynamicRange: fileParsed.dynamicRange ?? parentParsed?.dynamicRange, ); + // A canonical media name must retain a resolution. If neither the file nor + // its immediate directory provides one, leave the resource untouched. + if (parsed.resolution == null || parsed.resolution!.isEmpty) return item; final extension = _extensionOf(item.file.name); final year = item.year.isEmpty ? '' : '.${item.year}'; final episode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode ? '.S${parsed.season!.toString().padLeft(2, '0')}E${parsed.episode!.toString().padLeft(2, '0')}' : ''; + final technical = [ + parsed.resolution!, + if (parsed.source?.isNotEmpty == true) parsed.source!, + if (parsed.dynamicRange?.isNotEmpty == true) parsed.dynamicRange!, + if (parsed.videoCodec?.isNotEmpty == true) parsed.videoCodec!, + if (parsed.audio?.isNotEmpty == true) parsed.audio!, + ].join('.'); final targetName = - '${_safeCloudName('${item.title}$year$episode')}${extension.isEmpty ? '' : '.$extension'}'; + '${_safeCloudName('${item.title}$year$episode.$technical')}${extension.isEmpty ? '' : '.$extension'}'; if (targetName == item.file.name) return item; try { @@ -795,6 +883,11 @@ class MediaLibraryNotifier extends StateNotifier { return values.length < 2 ? null : values[values.length - 2]; } + String _parentPath(String cloudPath) { + final index = cloudPath.lastIndexOf(RegExp(r'[\\/]')); + return index <= 0 ? '' : cloudPath.substring(0, index); + } + String _safeCloudName(String value) { final cleaned = value .replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), ' ') @@ -1134,6 +1227,8 @@ class MediaLibraryNotifier extends StateNotifier { subFileCount: file.subFileCount, modifiedAt: file.modifiedAt, cloudPath: path, + parentID: file.parentID, + fullParentIDs: file.fullParentIDs, fileType: file.fileType, ); } diff --git a/test/media_name_parser_test.dart b/test/media_name_parser_test.dart index e57c599..34b25e0 100644 --- a/test/media_name_parser_test.dart +++ b/test/media_name_parser_test.dart @@ -43,5 +43,23 @@ void main() { expect(stream.title, '加勒比海盗5:死无对证'); expect(stream.year, 2017); }); + + test('uses parent season information for numeric episode file names', () { + final fromSeasonFolder = ParsedMediaName.parse( + '02.mkv', + directoryName: '示例剧集 Season 3 2160p WEB-DL', + ); + final compact = ParsedMediaName.parse( + '0102.mkv', + directoryName: '示例剧集 1080p', + ); + + expect(fromSeasonFolder.season, 3); + expect(fromSeasonFolder.episode, 2); + expect(fromSeasonFolder.isEpisode, isTrue); + expect(compact.season, 1); + expect(compact.episode, 2); + expect(compact.isEpisode, isTrue); + }); }); }