resync renamed media and preserve technical names

This commit is contained in:
ngfchl
2026-07-18 20:10:17 +08:00
parent ffb54ee2a6
commit 40d52d19ba
4 changed files with 161 additions and 6 deletions
+31
View File
@@ -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(
+12 -1
View File
@@ -461,6 +461,9 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
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<MediaLibraryItem> onPlay;
final ValueChanged<MediaLibraryItem> 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,
+100 -5
View File
@@ -325,7 +325,12 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
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<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(
Iterable<MediaLibraryItem> values,
) async {
if (_api == null) return;
final originals = values.toList();
if (originals.isEmpty) return;
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
state = state.copyWith(statusMessage: '正在同步云盘文件信息…', clearError: true);
final updates = <MediaLibraryItem>[];
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<String>(StorageKeys.tmdbProxyHost) ?? '',
proxyPort:
StorageManager.get<String>(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<MediaLibraryItem> applyTMDBMatch(
MediaLibraryItem item,
Map<String, dynamic> candidate,
@@ -698,17 +763,40 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
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 = <String>[
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<MediaLibraryState> {
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<MediaLibraryState> {
subFileCount: file.subFileCount,
modifiedAt: file.modifiedAt,
cloudPath: path,
parentID: file.parentID,
fullParentIDs: file.fullParentIDs,
fileType: file.fileType,
);
}
+18
View File
@@ -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);
});
});
}