improve media matching toolbar and title parsing

This commit is contained in:
ngfchl
2026-07-18 23:30:10 +08:00
parent e23205166d
commit 9fc69d1832
4 changed files with 504 additions and 260 deletions
+38 -5
View File
@@ -552,15 +552,33 @@ class ParsedMediaName {
}
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 yearMatches = RegExp(
r'\b(19\d{2}|20\d{2})\b',
).allMatches(normalized).toList();
// Some release names carry an upload year before the real title and
// release year. Prefer the later year in that specific form.
final yearMatch = yearMatches.length > 1 && yearMatches.first.start == 0
? yearMatches.last
: yearMatches.firstOrNull;
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',
r'\b(?:19\d{2}|20\d{2}|S\d{1,2}[ ._-]*E\d{1,3}|\d{1,2}x\d{1,3}|\d{3,4}x\d{3,4}|2160p|1080p|720p|480p|4k|web[- ]?(?:dl|rip)?|bluray|bdrip|remux|hdtv|dvd|bd|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);
final boundaryMatches = boundary.allMatches(normalized).toList();
final boundaryMatch =
boundaryMatches.length > 1 &&
boundaryMatches.first.start == 0 &&
RegExp(
r'^(?:19\d{2}|20\d{2})$',
).hasMatch(boundaryMatches.first.group(0) ?? '')
? boundaryMatches[1]
: boundaryMatches.firstOrNull;
var title = boundaryMatch != null
? normalized.substring(0, boundaryMatch.start)
: normalized;
if (yearMatches.length > 1 && yearMatches.first.start == 0) {
title = title.replaceFirst(RegExp(r'^\s*\d{4}[ ._-]+'), '');
}
title = _cleanTitle(title);
final genericName =
title.isEmpty ||
@@ -614,7 +632,7 @@ class ParsedMediaName {
season: season ?? parent?.season,
episode: episode,
isEpisode: (season ?? parent?.season) != null && episode != null,
resolution: first(r'\b(?:2160p|1080p|720p|480p|4k)\b'),
resolution: first(r'\b(?:2160p|1080p|720p|480p|4k|\d{3,4}x\d{3,4})\b'),
source: first(
r'\b(?:WEB[- ]?DL|WEBRip|BluRay|BDRip|REMUX|HDTV|DVD|UHD)\b',
),
@@ -629,6 +647,13 @@ class ParsedMediaName {
static String _cleanTitle(String value) {
var title = value.trim();
// Release collectors often prepend their collection date. It has no
// relation to the movie year and degrades a TMDB query substantially.
title = title.replaceFirst(
RegExp(r'^\s*(?:19|20)\d{2}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]?\s*'),
'',
);
// Release groups frequently put all disc/menu/audio/subtitle information
// in a bracket between the Chinese and original titles. Keep the leading
// title and discard the whole release block instead of joining both sides.
@@ -655,15 +680,23 @@ class ParsedMediaName {
// TMDB receives a compact Chinese query first; the original title remains
// available from TMDB after matching.
final chineseEnglishBoundary = RegExp(
r'[\u4e00-\u9fff\)]\s+(?=[A-Z][A-Za-z])',
r'[\u4e00-\u9fff\)]\s*(?=[A-Z][A-Za-z])',
).firstMatch(title);
if (chineseEnglishBoundary != null) {
title = title.substring(0, chineseEnglishBoundary.start + 1);
}
title = title
.replaceFirst(RegExp(r'^\s*[\[【(]\s*\d{1,3}\s*[\]】)][ ._-]*'), '')
.replaceFirst(RegExp(r'^\s*\d{1,3}[ ._-]+'), '')
.replaceAll(RegExp(r'[\(](?:港台|港版?|台版?|国配|国语|简繁?|中字?)[\)]'), ' ')
.replaceAll(
RegExp(
r'(?:[ ._-]+|^)(?:国[粤英日韩][语字]?|国粤(?:双语)?(?:中字|中英(?:字幕|双字)?)?|国语|粤语|(?:中英|中日|中韩)(?:双语|字幕)?|中文字幕|中字|简繁(?:字幕)?|内封(?:特效)?中英(?:双字|字幕)?|CHINESE|CHN)(?=$|[ ._-])',
caseSensitive: false,
),
' ',
)
.replaceAll(RegExp(r'[\[\]【】{}()()]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
+371 -249
View File
@@ -88,6 +88,8 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
String? _tmdbError;
List<Map<String, dynamic>> _tmdbResults = [];
bool _backupBusy = false;
bool _detailSyncing = false;
bool _manualMatchPreparing = false;
_MediaWork? _detailWork;
_MediaWallFilter _wallFilter = _MediaWallFilter.all;
String? _activeCollectionKey;
@@ -281,8 +283,17 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
Widget _buildToolbar(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
final detailWork = _detailWork;
return Row(
children: [
if (detailWork != null) ...[
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => setState(() => _detailWork = null),
child: const Icon(Icons.arrow_back_rounded, size: 18),
),
const SizedBox(width: 8),
],
Expanded(
child: ShadInput(
controller: _searchController,
@@ -298,54 +309,72 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () => _showCreateLibraryDialog(context, ref),
leading: const Icon(Icons.add_rounded, size: 16),
child: const Text('媒体库'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: _backupBusy || state.isScanning
? null
: _exportScrapedData,
leading: const Icon(Icons.save_alt_rounded, size: 16),
child: const Text('导出数据'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: _backupBusy || state.isScanning
? null
: _importScrapedData,
leading: const Icon(Icons.upload_file_rounded, size: 16),
child: const Text('导入数据'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: state.selectedLibrary == null || state.isScanning
? null
: () => ref
.read(mediaLibraryProvider.notifier)
.rescanSelectedLibrary(),
leading: const Icon(Icons.refresh_rounded, size: 16),
child: const Text('重新扫描'),
),
if (state.isScanning) ...[
if (detailWork != null) ...[
ShadButton.outline(
onPressed: _detailSyncing
? null
: () => unawaited(_refreshAndRecognizeDetail(detailWork)),
leading: const Icon(Icons.auto_awesome_rounded, size: 16),
child: const Text('媒体识别'),
),
const SizedBox(width: 8),
ShadButton.destructive(
onPressed: () =>
ref.read(mediaLibraryProvider.notifier).cancelScan(),
leading: const Icon(Icons.stop_rounded, size: 16),
child: const Text('停止'),
ShadButton.outline(
onPressed: _manualMatchPreparing
? null
: () => unawaited(_showManualTMDBMatch(detailWork)),
leading: const Icon(Icons.manage_search_rounded, size: 16),
child: const Text('手动匹配'),
),
] else ...[
ShadButton.outline(
onPressed: () => _showCreateLibraryDialog(context, ref),
leading: const Icon(Icons.add_rounded, size: 16),
child: const Text('媒体库'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: _backupBusy || state.isScanning
? null
: _exportScrapedData,
leading: const Icon(Icons.save_alt_rounded, size: 16),
child: const Text('导出数据'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: _backupBusy || state.isScanning
? null
: _importScrapedData,
leading: const Icon(Icons.upload_file_rounded, size: 16),
child: const Text('导入数据'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: state.selectedLibrary == null || state.isScanning
? null
: () => ref
.read(mediaLibraryProvider.notifier)
.rescanSelectedLibrary(),
leading: const Icon(Icons.refresh_rounded, size: 16),
child: const Text('重新扫描'),
),
if (state.isScanning) ...[
const SizedBox(width: 8),
ShadButton.destructive(
onPressed: () =>
ref.read(mediaLibraryProvider.notifier).cancelScan(),
leading: const Icon(Icons.stop_rounded, size: 16),
child: const Text('停止'),
),
],
const SizedBox(width: 8),
ShadButton(
onPressed: _tmdbApiKey.isEmpty
? null
: () => _searchTMDB(_searchController.text),
leading: const Icon(Icons.travel_explore_rounded, size: 16),
child: const Text('匹配'),
),
],
const SizedBox(width: 8),
ShadButton(
onPressed: _tmdbApiKey.isEmpty
? null
: () => _searchTMDB(_searchController.text),
leading: const Icon(Icons.travel_explore_rounded, size: 16),
child: const Text('匹配'),
),
],
);
}
@@ -463,63 +492,38 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
),
)
.firstOrNull;
return _MediaDetailPanel(
work: current ?? _detailWork!,
onBack: () => setState(() => _detailWork = null),
onDownload: (item) =>
ref.read(fileProvider.notifier).downloadFile(item.file),
onPlay: (item) => unawaited(
showMediaPlayerDialog(
context,
item.file,
episodeCandidates: (current ?? _detailWork!).resources
.map((resource) => resource.file)
.toList(),
),
),
onExternalPlay: (item) => showShadDialog(
context: context,
builder: (_) => ExternalPlayerDialog(file: item.file),
),
onManualMatch: () => _showManualTMDBMatch(current ?? _detailWork!),
onCancelRefresh: () =>
ref.read(mediaLibraryProvider.notifier).cancelDetailSync(),
onRefreshAndRecognize: () async {
final selected = current ?? _detailWork!;
final pendingMatches = await ref
.read(mediaLibraryProvider.notifier)
.refreshAndRecognizeItems(selected.resources);
for (final request in pendingMatches) {
if (!context.mounted) return;
final candidate = await showShadDialog<Map<String, dynamic>>(
context: context,
builder: (_) => _ManualTMDBMatchDialog(
initialQuery: request.items.first.title,
initialResults: request.candidates,
final selectedWork = current ?? _detailWork!;
return Stack(
children: [
_MediaDetailPanel(
work: selectedWork,
onDownload: (item) =>
ref.read(fileProvider.notifier).downloadFile(item.file),
onPlay: (item) => unawaited(
showMediaPlayerDialog(
context,
item.file,
episodeCandidates: selectedWork.resources
.map((resource) => resource.file)
.toList(),
),
);
if (candidate == null) continue;
final notifier = ref.read(mediaLibraryProvider.notifier);
for (final resource in request.items) {
await notifier.applyTMDBMatch(resource, candidate);
}
}
if (!context.mounted || _detailWork == null) return;
final updatedWorks = _MediaWork.fromItems(
ref.read(mediaLibraryProvider).items,
);
final selectedIDs = selected.resources
.map((resource) => resource.id)
.toSet();
final refreshed = updatedWorks
.where(
(work) => work.resources.any(
(resource) => selectedIDs.contains(resource.id),
),
)
.firstOrNull;
if (refreshed != null) setState(() => _detailWork = refreshed);
},
),
onExternalPlay: (item) => showShadDialog(
context: context,
builder: (_) => ExternalPlayerDialog(file: item.file),
),
),
if (_detailSyncing || _manualMatchPreparing)
_detailLoadingOverlay(
context,
message: _manualMatchPreparing ? '正在同步标题信息' : '正在识别并匹配媒体信息',
onCancel: _detailSyncing
? () => ref
.read(mediaLibraryProvider.notifier)
.cancelDetailSync()
: null,
),
],
);
}
final showingCollectionOverview =
@@ -989,15 +993,107 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
}
}
Future<void> _refreshAndRecognizeDetail(_MediaWork selected) async {
if (_detailSyncing) return;
setState(() => _detailSyncing = true);
try {
final notifier = ref.read(mediaLibraryProvider.notifier);
final pendingMatches = await notifier.refreshAndRecognizeItems(
selected.resources,
);
for (final request in pendingMatches) {
if (!mounted) return;
final candidate = await showShadDialog<Map<String, dynamic>>(
context: context,
builder: (_) => _ManualTMDBMatchDialog(
initialQuery: request.items.first.title,
initialResults: request.candidates,
),
);
if (candidate == null) continue;
for (final resource in request.items) {
await notifier.applyTMDBMatch(resource, candidate);
}
}
if (!mounted || _detailWork == null) return;
final selectedIDs = selected.resources.map((item) => item.id).toSet();
final refreshed =
_MediaWork.fromItems(ref.read(mediaLibraryProvider).items)
.where(
(work) =>
work.resources.any((item) => selectedIDs.contains(item.id)),
)
.firstOrNull;
if (refreshed != null) setState(() => _detailWork = refreshed);
} finally {
if (mounted) setState(() => _detailSyncing = false);
}
}
Widget _detailLoadingOverlay(
BuildContext context, {
required String message,
VoidCallback? onCancel,
}) {
final cs = ShadTheme.of(context).colorScheme;
return Positioned.fill(
child: ColoredBox(
color: cs.background.withValues(alpha: 0.86),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 38,
height: 38,
child: CircularProgressIndicator(strokeWidth: 3),
),
const SizedBox(height: 14),
Text(message),
if (onCancel != null) ...[
const SizedBox(height: 14),
ShadButton.destructive(
size: ShadButtonSize.sm,
onPressed: onCancel,
leading: const Icon(Icons.stop_rounded, size: 16),
child: const Text('取消'),
),
],
],
),
),
),
);
}
Future<void> _showManualTMDBMatch(_MediaWork work) async {
if (_manualMatchPreparing) return;
final notifier = ref.read(mediaLibraryProvider.notifier);
setState(() => _manualMatchPreparing = true);
late final List<MediaLibraryItem> resources;
try {
resources = await notifier.refreshParsedTitles(work.resources);
} finally {
if (mounted) setState(() => _manualMatchPreparing = false);
}
if (!mounted || resources.isEmpty) return;
final parsed = ParsedMediaName.parse(
resources.first.file.name,
directoryName: _parentDirectoryName(resources.first.file.cloudPath),
);
final candidate = await showShadDialog<Map<String, dynamic>>(
context: context,
builder: (_) => _ManualTMDBMatchDialog(initialQuery: work.primary.title),
builder: (_) => _ManualTMDBMatchDialog(
initialQuery: resources.first.title,
initialYear: parsed.year,
initialMediaKind: parsed.isEpisode ? 'tv' : 'auto',
initialSeason: parsed.season,
initialEpisode: parsed.episode,
),
);
if (candidate == null || !mounted) return;
final notifier = ref.read(mediaLibraryProvider.notifier);
final updated = <MediaLibraryItem>[];
for (final resource in work.resources) {
for (final resource in resources) {
updated.add(await notifier.applyTMDBMatch(resource, candidate));
}
if (mounted) {
@@ -1929,23 +2025,15 @@ class _MediaPosterTile extends ConsumerWidget {
class _MediaDetailPanel extends ConsumerStatefulWidget {
final _MediaWork work;
final VoidCallback onBack;
final ValueChanged<MediaLibraryItem> onDownload;
final ValueChanged<MediaLibraryItem> onPlay;
final ValueChanged<MediaLibraryItem> onExternalPlay;
final VoidCallback onManualMatch;
final VoidCallback onCancelRefresh;
final Future<void> Function() onRefreshAndRecognize;
const _MediaDetailPanel({
required this.work,
required this.onBack,
required this.onDownload,
required this.onPlay,
required this.onExternalPlay,
required this.onManualMatch,
required this.onCancelRefresh,
required this.onRefreshAndRecognize,
});
@override
@@ -1957,11 +2045,9 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
Map<String, dynamic>? _tmdbDetails;
int? _loadedTMDBID;
bool _loadingTMDBDetails = false;
bool _refreshingMedia = false;
final _backdropController = PageController();
Timer? _backdropTimer;
var _backdropIndex = 0;
final _updatePopover = ShadPopoverController();
@override
void initState() {
@@ -1989,58 +2075,9 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
void dispose() {
_backdropTimer?.cancel();
_backdropController.dispose();
_updatePopover.dispose();
super.dispose();
}
Widget _updateMediaMenu() {
return ShadPopover(
controller: _updatePopover,
padding: const EdgeInsets.all(8),
popover: (_) => SizedBox(
width: 230,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _refreshingMedia ? null : _refreshAndRecognize,
leading: const Icon(Icons.sync_rounded, size: 16),
child: const Text('同步云盘命名并识别'),
),
const SizedBox(height: 6),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () {
_updatePopover.hide();
widget.onManualMatch();
},
leading: const Icon(Icons.edit_note_rounded, size: 16),
child: const Text('手动选择匹配'),
),
],
),
),
child: ShadButton.outline(
onPressed: _refreshingMedia ? null : () => _updatePopover.toggle(),
leading: const Icon(Icons.expand_more_rounded, size: 16),
child: const Text('更新媒体'),
),
);
}
Future<void> _refreshAndRecognize() async {
if (_refreshingMedia) return;
setState(() => _refreshingMedia = true);
try {
await widget.onRefreshAndRecognize();
await _loadTMDBDetails(force: true);
} finally {
if (mounted) setState(() => _refreshingMedia = false);
}
}
Future<void> _loadTMDBDetails({bool force = false}) async {
final item = widget.work.primary;
final tmdbID = item.tmdbID;
@@ -2114,87 +2151,27 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
final cs = ShadTheme.of(context).colorScheme;
final item = widget.work.primary;
final isSeries = item.mediaKind == TMDBMediaKind.tv;
return Stack(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: widget.onBack,
leading: const Icon(Icons.arrow_back_rounded, size: 16),
child: const Text('返回海报墙'),
),
ShadButton(
onPressed: () => widget.onPlay(_resource),
leading: const Icon(Icons.play_arrow_rounded, size: 16),
child: const Text('播放'),
),
ShadButton.outline(
onPressed: () => widget.onExternalPlay(_resource),
leading: const Icon(Icons.launch_rounded, size: 16),
child: const Text('外部播放'),
),
ShadButton.outline(
onPressed: () => widget.onDownload(_resource),
leading: const Icon(Icons.download_rounded, size: 16),
child: const Text('下载'),
),
_updateMediaMenu(),
_detailInformation(context, item, isSeries),
const SizedBox(height: 22),
_fileInformation(context, item),
if (item.isMatched) ...[
const SizedBox(height: 22),
_tmdbEnrichment(context),
],
const SizedBox(height: 26),
_resourceList(context, cs),
],
),
const SizedBox(height: 14),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailInformation(context, item, isSeries),
if (item.isMatched) ...[
const SizedBox(height: 22),
_tmdbEnrichment(context),
],
const SizedBox(height: 22),
_fileInformation(context, item),
const SizedBox(height: 26),
_resourceList(context, cs),
],
),
),
),
],
),
if (_refreshingMedia)
Positioned.fill(
child: ColoredBox(
color: cs.background.withValues(alpha: 0.86),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 38,
height: 38,
child: CircularProgressIndicator(strokeWidth: 3),
),
const SizedBox(height: 14),
const Text('正在同步、识别并匹配媒体信息'),
const SizedBox(height: 14),
ShadButton.destructive(
size: ShadButtonSize.sm,
onPressed: widget.onCancelRefresh,
leading: const Icon(Icons.stop_rounded, size: 16),
child: const Text('取消'),
),
],
),
),
),
),
),
],
);
}
@@ -2336,6 +2313,31 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ShadButton(
size: ShadButtonSize.sm,
onPressed: () => widget.onPlay(_resource),
leading: const Icon(Icons.play_arrow_rounded, size: 16),
child: const Text('播放'),
),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => widget.onExternalPlay(_resource),
leading: const Icon(Icons.launch_rounded, size: 16),
child: const Text('外部播放'),
),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => widget.onDownload(_resource),
leading: const Icon(Icons.download_rounded, size: 16),
child: const Text('下载'),
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 20,
runSpacing: 10,
@@ -2744,10 +2746,18 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
class _ManualTMDBMatchDialog extends ConsumerStatefulWidget {
final String initialQuery;
final List<Map<String, dynamic>>? initialResults;
final int? initialYear;
final String initialMediaKind;
final int? initialSeason;
final int? initialEpisode;
const _ManualTMDBMatchDialog({
required this.initialQuery,
this.initialResults,
this.initialYear,
this.initialMediaKind = 'auto',
this.initialSeason,
this.initialEpisode,
});
@override
@@ -2758,6 +2768,10 @@ class _ManualTMDBMatchDialog extends ConsumerStatefulWidget {
class _ManualTMDBMatchDialogState
extends ConsumerState<_ManualTMDBMatchDialog> {
late final TextEditingController _queryController;
late final TextEditingController _yearController;
late final TextEditingController _seasonController;
late final TextEditingController _episodeController;
late String _mediaKind;
bool _searching = false;
String? _error;
List<Map<String, dynamic>> _results = const [];
@@ -2766,6 +2780,16 @@ class _ManualTMDBMatchDialogState
void initState() {
super.initState();
_queryController = TextEditingController(text: widget.initialQuery);
_yearController = TextEditingController(
text: widget.initialYear?.toString() ?? '',
);
_seasonController = TextEditingController(
text: widget.initialSeason?.toString() ?? '1',
);
_episodeController = TextEditingController(
text: widget.initialEpisode?.toString() ?? '1',
);
_mediaKind = widget.initialMediaKind;
if (widget.initialResults != null) {
_results = widget.initialResults!;
} else {
@@ -2776,11 +2800,15 @@ class _ManualTMDBMatchDialogState
@override
void dispose() {
_queryController.dispose();
_yearController.dispose();
_seasonController.dispose();
_episodeController.dispose();
super.dispose();
}
Future<void> _search() async {
final query = _queryController.text.trim();
final year = int.tryParse(_yearController.text.trim());
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
if (query.isEmpty || apiKey.isEmpty) {
setState(() {
@@ -2800,6 +2828,8 @@ class _ManualTMDBMatchDialogState
.tmdbSearch(
query,
apiKey: apiKey,
mediaKind: _mediaKind,
year: year,
proxyHost:
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
proxyPort:
@@ -2844,19 +2874,85 @@ class _ManualTMDBMatchDialogState
],
child: SizedBox(
width: 620,
height: 500,
height: 560,
child: Column(
children: [
ShadInput(
controller: _queryController,
placeholder: const Text('输入片名'),
onSubmitted: (_) => _search(),
leading: Icon(
Icons.search_rounded,
size: 16,
color: cs.mutedForeground,
_manualMatchField(
label: '标题',
child: ShadInput(
controller: _queryController,
placeholder: const Text('输入片名'),
onSubmitted: (_) => _search(),
leading: Icon(
Icons.search_rounded,
size: 16,
color: cs.mutedForeground,
),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 12,
runSpacing: 10,
children: [
_manualMatchField(
label: '类型',
child: SizedBox(
width: 150,
child: ShadSelect<String>(
initialValue: _mediaKind,
options: const [
ShadOption(value: 'auto', child: Text('自动')),
ShadOption(value: 'movie', child: Text('电影')),
ShadOption(value: 'tv', child: Text('电视剧')),
],
selectedOptionBuilder: (context, value) =>
Text(switch (value) {
'movie' => '电影',
'tv' => '电视剧',
_ => '自动',
}),
onChanged: (value) {
if (value != null) setState(() => _mediaKind = value);
},
),
),
),
_manualMatchField(
label: '年份',
child: SizedBox(
width: 96,
child: ShadInput(
controller: _yearController,
keyboardType: TextInputType.number,
placeholder: const Text('可选'),
),
),
),
if (_mediaKind == 'tv') ...[
_manualMatchField(
label: '第几季',
child: SizedBox(
width: 88,
child: ShadInput(
controller: _seasonController,
keyboardType: TextInputType.number,
),
),
),
_manualMatchField(
label: '第几集',
child: SizedBox(
width: 88,
child: ShadInput(
controller: _episodeController,
keyboardType: TextInputType.number,
),
),
),
],
],
),
const SizedBox(height: 12),
Expanded(
child: _searching
@@ -2903,7 +2999,7 @@ class _ManualTMDBMatchDialogState
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => Navigator.of(context).pop(candidate),
onTap: () => Navigator.of(context).pop(_selectionFor(candidate)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
@@ -2977,4 +3073,30 @@ class _ManualTMDBMatchDialogState
),
);
}
Map<String, dynamic> _selectionFor(Map<String, dynamic> candidate) {
final selected = Map<String, dynamic>.from(candidate);
if (_mediaKind != 'auto') selected['media_type'] = _mediaKind;
final year = int.tryParse(_yearController.text.trim());
if (year != null) selected['_manualYear'] = year;
if (_mediaKind == 'tv') {
final season = int.tryParse(_seasonController.text.trim());
final episode = int.tryParse(_episodeController.text.trim());
if (season != null && season > 0) selected['_manualSeason'] = season;
if (episode != null && episode > 0) selected['_manualEpisode'] = episode;
}
return selected;
}
Widget _manualMatchField({required String label, required Widget child}) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 12)),
const SizedBox(height: 5),
child,
],
);
}
}
+61 -6
View File
@@ -982,6 +982,8 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
Map<String, dynamic> candidate,
) async {
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
final manualSeason = _toInt(candidate['_manualSeason']);
final manualEpisode = _toInt(candidate['_manualEpisode']);
var updated = _itemFromTMDBCandidate(item, candidate);
if (updated.tmdbID != null && apiKey.isNotEmpty) {
try {
@@ -999,7 +1001,11 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
// A selected candidate is still valid if its detail request fails.
}
}
updated = await _renameMatchedMediaFile(updated);
updated = await _renameMatchedMediaFile(
updated,
manualSeason: manualSeason,
manualEpisode: manualEpisode,
);
await _replaceItemsByPreviousIDs({'${item.libraryID}:${item.id}': updated});
state = state.copyWith(
items: state.items
@@ -1018,6 +1024,51 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
return updated;
}
/// Refresh legacy unmatched records with the current filename parser before
/// showing a manual TMDB search. Matched items retain TMDB's canonical title.
Future<List<MediaLibraryItem>> refreshParsedTitles(
Iterable<MediaLibraryItem> values,
) async {
final updates = <MediaLibraryItem>[];
for (final item in values) {
if (item.tmdbID != null) {
updates.add(item);
continue;
}
final parsed = ParsedMediaName.parse(
item.file.name,
directoryName: _parentDirectoryName(item.file.cloudPath),
);
final title = parsed.title.trim();
if (title.isEmpty) {
updates.add(item);
continue;
}
updates.add(
item.copyWith(
title: title,
originalTitle: title,
releaseDate: parsed.year == null
? item.releaseDate
: '${parsed.year}-01-01',
updatedAt: DateTime.now(),
),
);
}
if (updates.isEmpty) return const [];
final replacements = {
for (final item in updates) '${item.libraryID}:${item.id}': item,
};
await _replaceItemsByPreviousIDs(replacements);
_searchResultsCache.clear();
state = state.copyWith(
items: state.items
.map((item) => replacements['${item.libraryID}:${item.id}'] ?? item)
.toList(),
);
return updates;
}
/// Old scrape backups stored artwork as BLOBs. The compact SQLite schema
/// deliberately drops those images, so restore the durable TMDB paths from
/// their retained IDs in the background after an import or first load.
@@ -1292,8 +1343,10 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
Future<MediaLibraryItem> _renameMatchedMediaFile(
MediaLibraryItem item,
) async {
MediaLibraryItem item, {
int? manualSeason,
int? manualEpisode,
}) async {
if (_api == null || item.tmdbID == null || item.mediaKind == null) {
return item;
}
@@ -1310,9 +1363,11 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
final parsed = ParsedMediaName(
title: fileParsed.title,
year: fileParsed.year ?? parentParsed?.year,
season: fileParsed.season ?? parentParsed?.season,
episode: fileParsed.episode,
isEpisode: fileParsed.isEpisode,
season: manualSeason ?? fileParsed.season ?? parentParsed?.season,
episode: manualEpisode ?? fileParsed.episode,
isEpisode:
(manualSeason ?? fileParsed.season ?? parentParsed?.season) != null &&
(manualEpisode ?? fileParsed.episode) != null,
resolution: fileParsed.resolution ?? parentParsed?.resolution,
source: fileParsed.source ?? parentParsed?.source,
videoCodec: fileParsed.videoCodec ?? parentParsed?.videoCodec,
+34
View File
@@ -73,5 +73,39 @@ void main() {
expect(value.videoCodec, 'HEVC');
expect(value.audio, 'TrueHD');
});
test('removes bracketed numeric prefixes from localized titles', () {
final value = ParsedMediaName.parse('【002】白玉老虎.mkv');
expect(value.title, '白玉老虎');
});
test('removes collection dates and noisy language release tags', () {
final dated = ParsedMediaName.parse(
'2022年11月11日 黑豹2.瓦坎达万岁.2022.1080p.国英双语.中英特效字幕.无水印纯净版.mkv',
);
final bilingual = ParsedMediaName.parse(
'倚天屠龙记之魔教教主.重新调色版.国粤双语中字Kung.Fu.Cult.Master.1993.HKG.BluRay.H265.10Bit.1080P-.mkv',
);
expect(dated.title, '黑豹2 瓦坎达万岁');
expect(dated.year, 2022);
expect(bilingual.title, '倚天屠龙记之魔教教主 重新调色版');
expect(bilingual.year, 1993);
expect(bilingual.resolution, '1080P');
});
test('prefers a later release year and detects nonstandard resolution', () {
final laterYear = ParsedMediaName.parse(
'2025.The.World.Enslaved.By.A.Virus.2021.1080p.WEBRip.x264-RARBG.mp4',
);
final legacy = ParsedMediaName.parse('野兽之瞳.1024x576.国粤双语.中文字幕.mkv');
expect(laterYear.title, 'The World Enslaved By A Virus');
expect(laterYear.year, 2021);
expect(laterYear.resolution, '1080p');
expect(legacy.title, '野兽之瞳');
expect(legacy.resolution, '1024x576');
});
});
}