improve media matching toolbar and title parsing
This commit is contained in:
@@ -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
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user