add media naming and nfo normalization

This commit is contained in:
ngfchl
2026-07-18 18:18:50 +08:00
parent bee146ec05
commit 49fefcfe1f
3 changed files with 263 additions and 48 deletions
+70 -33
View File
@@ -487,6 +487,11 @@ class ParsedMediaName {
final int? season;
final int? episode;
final bool isEpisode;
final String? resolution;
final String? source;
final String? videoCodec;
final String? audio;
final String? dynamicRange;
const ParsedMediaName({
required this.title,
@@ -494,50 +499,82 @@ class ParsedMediaName {
this.season,
this.episode,
this.isEpisode = false,
this.resolution,
this.source,
this.videoCodec,
this.audio,
this.dynamicRange,
});
factory ParsedMediaName.parse(String name) {
var stem = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
stem = stem.replaceAll(RegExp(r'[\._]+'), ' ');
final episodeMatch =
RegExp(
r'(?:S|第)\s*(\d{1,2})\s*(?:E|季\s*第?)\s*(\d{1,3})',
caseSensitive: false,
).firstMatch(stem) ??
RegExp(r'(\d{1,2})x(\d{1,3})', caseSensitive: false).firstMatch(stem);
final yearMatch = RegExp(r'(19\d{2}|20\d{2})').firstMatch(stem);
final cutIndex =
[
episodeMatch?.start,
yearMatch?.start,
RegExp(
r'\b(2160p|1080p|720p|bluray|web[- ]?dl|hdtv|x264|x265|hevc)\b',
final stem = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
final normalized = stem.replaceAll(RegExp(r'[._]+'), ' ');
final episodeMatch = RegExp(
r'\bS(\d{1,2})[ ._-]*E(\d{1,3})\b|\b(\d{1,2})x(\d{1,3})\b|第\s*(\d{1,2})\s*季\s*第?\s*(\d{1,3})\s*[集话]',
caseSensitive: false,
).firstMatch(normalized);
int? season;
int? episode;
if (episodeMatch != null) {
final groups = [
[1, 2],
[3, 4],
[5, 6],
];
for (final pair in groups) {
final a = episodeMatch.group(pair[0]);
final b = episodeMatch.group(pair[1]);
if (a != null && b != null) {
season = int.tryParse(a);
episode = int.tryParse(b);
break;
}
}
}
final episodeOnly = season == null
? RegExp(
r'\b(?:E|EP|Episode)[ ._-]*(\d{1,3})\b|第\s*(\d{1,3})\s*[集话]',
caseSensitive: false,
).firstMatch(stem)?.start,
].whereType<int>().fold<int?>(
null,
(min, value) => min == null || value < min ? value : min,
);
var title = cutIndex == null ? stem : stem.substring(0, cutIndex);
).firstMatch(normalized)
: null;
if (episodeOnly != null) {
season = 1;
episode = int.tryParse(
episodeOnly.group(1) ?? episodeOnly.group(2) ?? '',
);
}
String? first(String pattern) =>
RegExp(pattern, caseSensitive: false).firstMatch(normalized)?.group(0);
final yearMatch = RegExp(r'\b(19\d{2}|20\d{2})\b').firstMatch(normalized);
final boundary = RegExp(
r'\b(?:19\d{2}|20\d{2}|S\d{1,2}[ ._-]*E\d{1,3}|\d{1,2}x\d{1,3}|2160p|1080p|720p|480p|4k|web[- ]?(?:dl|rip)?|bluray|bdrip|remux|hdtv|dvd|x26[45]|h\.?26[45]|hevc|av1|aac|ac3|eac3|flac|truehd|dts|ddp|atmos|hdr|dv)\b',
caseSensitive: false,
);
final boundaryMatch = boundary.firstMatch(normalized);
var title = boundaryMatch != null
? normalized.substring(0, boundaryMatch.start)
: normalized;
title = title
.replaceAll(RegExp(r'[\[\]\(\)]'), ' ')
.replaceAll(RegExp(r'[\[\]【】(){}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (title.isEmpty) title = stem.trim();
if (title.isEmpty) title = normalized.trim();
return ParsedMediaName(
title: title,
year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!),
season: episodeMatch == null
? null
: int.tryParse(episodeMatch.group(1)!),
episode: episodeMatch == null
? null
: int.tryParse(episodeMatch.group(2)!),
isEpisode: episodeMatch != null,
season: season,
episode: episode,
isEpisode: season != null && episode != null,
resolution: first(r'\b(?:2160p|1080p|720p|480p|4k)\b'),
source: first(
r'\b(?:WEB[- ]?DL|WEBRip|BluRay|BDRip|REMUX|HDTV|DVD|UHD)\b',
),
videoCodec: first(r'\b(?:x26[45]|h\.?26[45]|HEVC|AV1|VC-1)\b'),
audio: first(
r'\b(?:Atmos|TrueHD|DTS(?:-HD)?|DDP?(?: ?[0-9.]+)?|AAC|FLAC)\b',
),
dynamicRange: first(r'\b(?:HDR10?\+?|Dolby[ .-]?Vision|DV)\b'),
);
}
}
+123 -10
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/legacy.dart';
@@ -311,21 +312,23 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
Future<void> pendingPersistence = Future.value();
Future<void> indexBatch(List<CloudFile> files) async {
final pending = files
.where((file) => !unique.containsKey(file.id))
.toList();
final pending = files.toList();
if (pending.isEmpty || _cancelScan) return;
var next = 0;
Future<void> worker() async {
while (!_cancelScan && next < pending.length) {
final file = pending[next++];
final fallback = MediaLibraryItem.fromFile(library.id, file);
final item = await _recognizeMediaItem(
fallback,
tmdbApiKey,
proxyHost: tmdbProxyHost,
proxyPort: tmdbProxyPort,
);
final existing = unique[file.id];
var item = existing == null
? await _recognizeMediaItem(
fallback,
tmdbApiKey,
proxyHost: tmdbProxyHost,
proxyPort: tmdbProxyPort,
)
: existing.copyWith(file: file);
item = await _renameMatchedMediaFile(item);
unique[file.id] = item;
completed += 1;
final visible = unique.values.toList()
@@ -347,7 +350,9 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
_appendScanLog(
item.tmdbID == null
? '已入库:${file.name}(未匹配 TMDB'
: '已识别并入库:${file.name}${item.title}',
: item.file.name == file.name
? '已识别并入库:${file.name}${item.title}'
: '已识别并重命名:${file.name}${item.file.name}',
);
}
}
@@ -645,6 +650,114 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
}
Future<MediaLibraryItem> _renameMatchedMediaFile(
MediaLibraryItem item,
) async {
if (_api == null || item.tmdbID == null || item.mediaKind == null) {
return item;
}
final parsed = ParsedMediaName.parse(item.file.name);
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 targetName =
'${_safeCloudName('${item.title}$year$episode')}${extension.isEmpty ? '' : '.$extension'}';
if (targetName == item.file.name) return item;
try {
await _api!.fsRename(item.file.id, targetName);
final updated = item.copyWith(file: item.file.copyWith(name: targetName));
await _writeNfoForRenamedMedia(updated, parsed);
return updated;
} catch (_) {
return item;
}
}
Future<void> _writeNfoForRenamedMedia(
MediaLibraryItem item,
ParsedMediaName parsed,
) async {
try {
final detail = await _api!.fsDetail(item.file.id);
final parentID = _findStringDeep(detail, const [
'parentId',
'parent_id',
'parentFileId',
]);
if (parentID == null || parentID.isEmpty) return;
final isEpisode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode;
final nfoName = isEpisode
? '${_safeCloudName(item.title)}.S${parsed.season!.toString().padLeft(2, '0')}E${parsed.episode!.toString().padLeft(2, '0')}.nfo'
: item.mediaKind == TMDBMediaKind.tv
? 'tvshow.nfo'
: 'movie.nfo';
final root = isEpisode
? 'episodedetails'
: item.mediaKind == TMDBMediaKind.tv
? 'tvshow'
: 'movie';
final technical = <String>[
if (parsed.resolution != null)
'<resolution>${_xml(parsed.resolution!)}</resolution>',
if (parsed.source != null) '<source>${_xml(parsed.source!)}</source>',
if (parsed.videoCodec != null)
'<codec>${_xml(parsed.videoCodec!)}</codec>',
if (parsed.dynamicRange != null)
'<hdr>${_xml(parsed.dynamicRange!)}</hdr>',
if (parsed.audio != null) '<audio>${_xml(parsed.audio!)}</audio>',
].join();
final episodeFields = isEpisode
? '<season>${parsed.season}</season><episode>${parsed.episode}</episode>'
: '';
final xml =
'<?xml version="1.0" encoding="UTF-8"?>'
'<$root><uniqueid type="tmdb" default="true">${item.tmdbID}</uniqueid>'
'<title>${_xml(item.title)}</title>'
'<originaltitle>${_xml(item.originalTitle)}</originaltitle>'
'<year>${_xml(item.year)}</year>'
'<plot>${_xml(item.overview)}</plot>$episodeFields'
'<fileinfo><streamdetails><video>$technical</video></streamdetails></fileinfo>'
'</$root>';
final temp = File('${Directory.systemTemp.path}/$nfoName');
await temp.writeAsString(xml, flush: true);
try {
await _api!.fileUpload(
temp,
parentID: parentID,
contentType: 'application/xml',
);
} finally {
if (await temp.exists()) await temp.delete();
}
} catch (_) {
// File renaming remains successful if sidecar upload is unavailable.
}
}
String _extensionOf(String value) {
final index = value.lastIndexOf('.');
return index <= 0 ? '' : value.substring(index + 1).toLowerCase();
}
String _safeCloudName(String value) {
final cleaned = value
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
final fallback = cleaned.isEmpty ? 'Untitled' : cleaned;
return fallback.length > 180 ? fallback.substring(0, 180).trim() : fallback;
}
String _xml(String value) => value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
MediaLibraryItem _itemFromTMDBCandidate(
MediaLibraryItem fallback,
Map<String, dynamic> candidate,
+70 -5
View File
@@ -18,6 +18,7 @@ Future<void> showMediaPlayerDialog(
BuildContext context,
CloudFile file, {
List<CloudFile> episodeCandidates = const [],
CloudFile? initialSubtitle,
}) async {
Future<void> openExternalPlayer() async {
await Future<void>.delayed(Duration.zero);
@@ -34,6 +35,7 @@ Future<void> showMediaPlayerDialog(
builder: (_) => MediaPlayerDialog(
file: file,
episodeCandidates: episodeCandidates,
initialSubtitle: initialSubtitle,
onPlaybackFailure: openExternalPlayer,
),
);
@@ -45,12 +47,14 @@ Future<void> showMediaPlayerDialog(
class MediaPlayerDialog extends ConsumerStatefulWidget {
final CloudFile file;
final List<CloudFile> episodeCandidates;
final CloudFile? initialSubtitle;
final Future<void> Function()? onPlaybackFailure;
const MediaPlayerDialog({
super.key,
required this.file,
this.episodeCandidates = const [],
this.initialSubtitle,
this.onPlaybackFailure,
});
@@ -131,6 +135,10 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
.read(fileProvider.notifier)
.playbackUrl(_currentFile);
await _player.open(Media(url.toString()), play: true);
final initialSubtitle = widget.initialSubtitle;
if (initialSubtitle != null) {
await _setDirectorySubtitle(initialSubtitle);
}
_videoDimensionFallbackTimer?.cancel();
_videoDimensionFallbackTimer = Timer(const Duration(seconds: 4), () {
if (mounted && _loading && _error == null) {
@@ -236,6 +244,16 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
}
}
Future<void> _searchDirectorySubtitles() async {
final siblings = await ref
.read(fileProvider.notifier)
.siblingFiles(_currentFile);
if (!mounted) return;
setState(() {
_subtitleCandidates = _matchingSubtitles(_currentFile, siblings);
});
}
Future<void> _loadLocalSubtitle() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
@@ -343,6 +361,8 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
player: _player,
directorySubtitles: _subtitleCandidates,
onSelectDirectorySubtitle: _setDirectorySubtitle,
onSearchDirectorySubtitles:
_searchDirectorySubtitles,
onLoadLocalSubtitle: _loadLocalSubtitle,
onToggleFullscreen: videoState.toggleFullscreen,
),
@@ -422,6 +442,7 @@ class _MediaPlaybackControls extends StatefulWidget {
final Player player;
final List<CloudFile> directorySubtitles;
final Future<void> Function(CloudFile subtitle) onSelectDirectorySubtitle;
final Future<void> Function() onSearchDirectorySubtitles;
final Future<void> Function() onLoadLocalSubtitle;
final Future<void> Function() onToggleFullscreen;
@@ -429,6 +450,7 @@ class _MediaPlaybackControls extends StatefulWidget {
required this.player,
required this.directorySubtitles,
required this.onSelectDirectorySubtitle,
required this.onSearchDirectorySubtitles,
required this.onLoadLocalSubtitle,
required this.onToggleFullscreen,
});
@@ -524,6 +546,7 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
final tracks = widget.player.state.tracks.subtitle;
return ShadPopover(
controller: _subtitlePopover,
padding: const EdgeInsets.all(8),
popover: (_) => _subtitlePopoverContent(tracks),
child: _menuButton('字幕', () async => _subtitlePopover.toggle()),
);
@@ -531,13 +554,22 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
Widget _subtitlePopoverContent(List<SubtitleTrack> tracks) {
final selectedID = widget.player.state.track.subtitle.id;
final cs = ShadTheme.of(context).colorScheme;
final itemStyle = TextStyle(fontSize: 13, color: cs.foreground);
return SizedBox(
width: 300,
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('字幕', style: TextStyle(fontWeight: FontWeight.w700)),
Text(
'字幕',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 6),
ConstrainedBox(
@@ -550,6 +582,11 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
final selected = track.id == selectedID;
return ShadButton.ghost(
size: ShadButtonSize.sm,
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 8),
foregroundColor: selected ? cs.primary : cs.foreground,
textStyle: itemStyle,
mainAxisAlignment: MainAxisAlignment.start,
onPressed: () async {
await widget.player.setSubtitleTrack(track);
_subtitlePopover.hide();
@@ -575,10 +612,14 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
),
],
if (widget.directorySubtitles.isNotEmpty) ...[
const Divider(height: 16),
const Divider(height: 14),
Text(
'同目录字幕 (${widget.directorySubtitles.length})',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: cs.mutedForeground,
),
),
const SizedBox(height: 4),
ConstrainedBox(
@@ -590,6 +631,11 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
final subtitle = widget.directorySubtitles[index];
return ShadButton.ghost(
size: ShadButtonSize.sm,
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 8),
foregroundColor: cs.foreground,
textStyle: itemStyle,
mainAxisAlignment: MainAxisAlignment.start,
onPressed: () async {
await widget.onSelectDirectorySubtitle(subtitle);
_subtitlePopover.hide();
@@ -608,9 +654,28 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
),
),
],
const Divider(height: 16),
const Divider(height: 14),
ShadButton.ghost(
size: ShadButtonSize.sm,
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 8),
foregroundColor: cs.foreground,
textStyle: itemStyle,
mainAxisAlignment: MainAxisAlignment.start,
onPressed: () async {
await widget.onSearchDirectorySubtitles();
if (mounted) setState(() {});
},
leading: const Icon(Icons.search_rounded, size: 15),
child: const Text('搜索同目录字幕'),
),
ShadButton.ghost(
size: ShadButtonSize.sm,
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 8),
foregroundColor: cs.foreground,
textStyle: itemStyle,
mainAxisAlignment: MainAxisAlignment.start,
onPressed: () async {
_subtitlePopover.hide();
await widget.onLoadLocalSubtitle();