resync renamed media and preserve technical names
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user