add media naming and nfo normalization
This commit is contained in:
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
MediaLibraryItem _itemFromTMDBCandidate(
|
||||
MediaLibraryItem fallback,
|
||||
Map<String, dynamic> candidate,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user