feat: support manual TMDB media matching

This commit is contained in:
ngfchl
2026-07-18 13:57:34 +08:00
parent b235852395
commit ae268a7482
3 changed files with 336 additions and 27 deletions
+37
View File
@@ -294,6 +294,43 @@ class MediaLibraryItem {
String get year => releaseDate.length >= 4 ? releaseDate.substring(0, 4) : '';
bool get isMatched => mediaKind != null && tmdbID != null;
MediaLibraryItem copyWith({
CloudFile? file,
int? tmdbID,
bool clearTMDBID = false,
String? title,
String? originalTitle,
TMDBMediaKind? mediaKind,
bool clearMediaKind = false,
String? releaseDate,
String? overview,
String? posterPath,
String? backdropPath,
bool? hasChineseAudio,
bool? hasChineseSubtitle,
int? collectionID,
String? collectionName,
DateTime? updatedAt,
}) {
return MediaLibraryItem(
libraryID: libraryID,
file: file ?? this.file,
tmdbID: clearTMDBID ? null : (tmdbID ?? this.tmdbID),
title: title ?? this.title,
originalTitle: originalTitle ?? this.originalTitle,
mediaKind: clearMediaKind ? null : (mediaKind ?? this.mediaKind),
releaseDate: releaseDate ?? this.releaseDate,
overview: overview ?? this.overview,
posterPath: posterPath ?? this.posterPath,
backdropPath: backdropPath ?? this.backdropPath,
hasChineseAudio: hasChineseAudio ?? this.hasChineseAudio,
hasChineseSubtitle: hasChineseSubtitle ?? this.hasChineseSubtitle,
collectionID: collectionID ?? this.collectionID,
collectionName: collectionName ?? this.collectionName,
updatedAt: updatedAt ?? this.updatedAt,
);
}
factory MediaLibraryItem.fromFile(String libraryID, CloudFile file) {
final parsed = ParsedMediaName.parse(file.name);
final kind = parsed.isEpisode ? TMDBMediaKind.tv : TMDBMediaKind.movie;
+253
View File
@@ -364,6 +364,7 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
onBack: () => setState(() => _detailWork = null),
onDownload: (item) =>
ref.read(fileProvider.notifier).downloadFile(item.file),
onManualMatch: () => _showManualTMDBMatch(current ?? _detailWork!),
onRescan: state.isScanning
? null
: () =>
@@ -681,6 +682,24 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
});
}
}
Future<void> _showManualTMDBMatch(_MediaWork work) async {
final candidate = await showShadDialog<Map<String, dynamic>>(
context: context,
builder: (_) => _ManualTMDBMatchDialog(initialQuery: work.primary.title),
);
if (candidate == null || !mounted) return;
final notifier = ref.read(mediaLibraryProvider.notifier);
final updated = <MediaLibraryItem>[];
for (final resource in work.resources) {
updated.add(await notifier.applyTMDBMatch(resource, candidate));
}
if (mounted) {
setState(() {
_detailWork = _MediaWork.fromItems(updated).first;
});
}
}
}
class _CreateMediaLibraryDialog extends ConsumerStatefulWidget {
@@ -1452,12 +1471,14 @@ class _MediaDetailPanel extends ConsumerStatefulWidget {
final _MediaWork work;
final VoidCallback onBack;
final ValueChanged<MediaLibraryItem> onDownload;
final VoidCallback onManualMatch;
final VoidCallback? onRescan;
const _MediaDetailPanel({
required this.work,
required this.onBack,
required this.onDownload,
required this.onManualMatch,
this.onRescan,
});
@@ -1493,6 +1514,12 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
child: const Text('返回海报墙'),
),
const Spacer(),
ShadButton.outline(
onPressed: widget.onManualMatch,
leading: const Icon(Icons.manage_search_rounded, size: 16),
child: const Text('手动匹配'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: widget.onRescan,
leading: const Icon(Icons.refresh_rounded, size: 16),
@@ -1752,3 +1779,229 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
);
}
}
class _ManualTMDBMatchDialog extends ConsumerStatefulWidget {
final String initialQuery;
const _ManualTMDBMatchDialog({required this.initialQuery});
@override
ConsumerState<_ManualTMDBMatchDialog> createState() =>
_ManualTMDBMatchDialogState();
}
class _ManualTMDBMatchDialogState
extends ConsumerState<_ManualTMDBMatchDialog> {
late final TextEditingController _queryController;
bool _searching = false;
String? _error;
List<Map<String, dynamic>> _results = const [];
@override
void initState() {
super.initState();
_queryController = TextEditingController(text: widget.initialQuery);
Future.microtask(_search);
}
@override
void dispose() {
_queryController.dispose();
super.dispose();
}
Future<void> _search() async {
final query = _queryController.text.trim();
final apiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
if (query.isEmpty || apiKey.isEmpty) {
setState(() {
_results = const [];
_error = apiKey.isEmpty ? '请先在设置中配置 TMDB API Key。' : null;
});
return;
}
setState(() {
_searching = true;
_error = null;
});
try {
final result = await ref
.read(authProvider.notifier)
.api
.tmdbSearch(
query,
apiKey: apiKey,
proxyHost:
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '',
proxyPort:
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '',
);
final values =
(result['results'] as List?)
?.whereType<Map>()
.map(Map<String, dynamic>.from)
.where(
(item) =>
item['id'] != null &&
(item['media_type'] == 'movie' ||
item['media_type'] == 'tv'),
)
.toList() ??
const <Map<String, dynamic>>[];
if (mounted) setState(() => _results = values);
} catch (error) {
if (mounted) setState(() => _error = error.toString());
} finally {
if (mounted) setState(() => _searching = false);
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadDialog(
title: const Text('手动匹配 TMDB'),
description: const Text('选择一个结果后,会应用到该作品的全部资源版本。'),
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ShadButton(
onPressed: _searching ? null : _search,
leading: const Icon(Icons.search_rounded, size: 16),
child: const Text('搜索'),
),
],
child: SizedBox(
width: 620,
height: 500,
child: Column(
children: [
ShadInput(
controller: _queryController,
placeholder: const Text('输入片名'),
onSubmitted: (_) => _search(),
leading: Icon(
Icons.search_rounded,
size: 16,
color: cs.mutedForeground,
),
),
const SizedBox(height: 12),
Expanded(
child: _searching
? const Center(child: ShadProgress())
: _error != null
? Center(
child: Text(
_error!,
style: TextStyle(color: cs.destructive),
),
)
: _results.isEmpty
? Center(
child: Text(
'没有匹配结果',
style: TextStyle(color: cs.mutedForeground),
),
)
: ListView.separated(
itemCount: _results.length,
separatorBuilder: (_, _) =>
Divider(height: 1, color: cs.border),
itemBuilder: (context, index) =>
_candidateRow(context, cs, _results[index]),
),
),
],
),
),
);
}
Widget _candidateRow(
BuildContext context,
ShadColorScheme cs,
Map<String, dynamic> candidate,
) {
final title = (candidate['title'] ?? candidate['name'] ?? '未知标题')
.toString();
final release =
(candidate['release_date'] ?? candidate['first_air_date'] ?? '')
.toString();
final posterPath = candidate['poster_path']?.toString();
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => Navigator.of(context).pop(candidate),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
SizedBox(
width: 44,
height: 64,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: posterPath == null || posterPath.isEmpty
? Container(
color: cs.muted,
child: Icon(
Icons.movie_rounded,
size: 20,
color: cs.mutedForeground,
),
)
: Image.network(
'https://image.tmdb.org/t/p/w154$posterPath',
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
color: cs.muted,
child: Icon(
Icons.movie_rounded,
size: 20,
color: cs.mutedForeground,
),
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
),
const SizedBox(height: 3),
Text(
'${release.length >= 4 ? release.substring(0, 4) : '未知年份'} · ${candidate['media_type'] == 'tv' ? '剧集' : '电影'}',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
const SizedBox(height: 3),
Text(
candidate['overview']?.toString() ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
],
),
),
Icon(Icons.chevron_right_rounded, color: cs.mutedForeground),
],
),
),
),
);
}
}
+46 -27
View File
@@ -398,6 +398,22 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
});
}
Future<MediaLibraryItem> applyTMDBMatch(
MediaLibraryItem item,
Map<String, dynamic> candidate,
) async {
final updated = _itemFromTMDBCandidate(item, candidate);
await _store.upsertItems([updated]);
state = state.copyWith(
items: state.items
.map((current) => current.id == updated.id ? updated : current)
.toList(),
statusMessage: '已匹配《${updated.title}',
clearError: true,
);
return updated;
}
void clearError() {
state = state.copyWith(clearError: true);
}
@@ -429,38 +445,41 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
}
if (candidate == null) return fallback;
final type = candidate['media_type']?.toString();
final title = (candidate['title'] ?? candidate['name'])
?.toString()
.trim();
final originalTitle =
(candidate['original_title'] ?? candidate['original_name'])
?.toString()
.trim();
final releaseDate =
(candidate['release_date'] ?? candidate['first_air_date'])
?.toString() ??
'';
return MediaLibraryItem(
libraryID: fallback.libraryID,
file: fallback.file,
tmdbID: _toInt(candidate['id']),
title: title == null || title.isEmpty ? fallback.title : title,
originalTitle: originalTitle == null || originalTitle.isEmpty
? fallback.originalTitle
: originalTitle,
mediaKind: type == 'tv' ? TMDBMediaKind.tv : TMDBMediaKind.movie,
releaseDate: releaseDate,
overview: candidate['overview']?.toString() ?? '',
posterPath: candidate['poster_path']?.toString(),
backdropPath: candidate['backdrop_path']?.toString(),
updatedAt: DateTime.now(),
);
return _itemFromTMDBCandidate(fallback, candidate);
} catch (_) {
return fallback;
}
}
MediaLibraryItem _itemFromTMDBCandidate(
MediaLibraryItem fallback,
Map<String, dynamic> candidate,
) {
final type = candidate['media_type']?.toString();
final title = (candidate['title'] ?? candidate['name'])?.toString().trim();
final originalTitle =
(candidate['original_title'] ?? candidate['original_name'])
?.toString()
.trim();
final releaseDate =
(candidate['release_date'] ?? candidate['first_air_date'])
?.toString() ??
'';
return fallback.copyWith(
tmdbID: _toInt(candidate['id']),
title: title == null || title.isEmpty ? fallback.title : title,
originalTitle: originalTitle == null || originalTitle.isEmpty
? fallback.originalTitle
: originalTitle,
mediaKind: type == 'tv' ? TMDBMediaKind.tv : TMDBMediaKind.movie,
releaseDate: releaseDate,
overview: candidate['overview']?.toString() ?? '',
posterPath: candidate['poster_path']?.toString(),
backdropPath: candidate['backdrop_path']?.toString(),
updatedAt: DateTime.now(),
);
}
int? _toInt(dynamic value) {
if (value is int) return value;
return int.tryParse(value?.toString() ?? '');