Files
plezy/lib/screens/plex_match_screen.dart
edde746 d7500c25c1 fix(plex): stop double-encoding removed tags and surfacing failures as empty lists
Removing a tag containing a space or symbol silently did nothing: the removed values were pre-encoded and the transport encoded them again, so Plex matched nothing while the UI showed the tag gone. Folder listings and the shared list wrapper also converted every transport failure (401, 500, timeout) into a successful empty list, so an unreachable server rendered 'no folders'/'no results' with no retry while Jellyfin surfaced an error. Failures now propagate to the existing error paths, and the match screen gained one.
2026-08-21 19:23:40 +02:00

276 lines
9.3 KiB
Dart

import 'package:flutter/material.dart';
import '../media/ids.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/input_mode_tracker.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../models/plex/plex_match_result.dart';
import '../services/plex_client.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../utils/snackbar_helper.dart';
import '../widgets/app_icon.dart';
import '../widgets/focusable_list_tile.dart';
import '../widgets/focused_scroll_scaffold.dart';
import '../widgets/pill_input_decoration.dart';
import '../widgets/optimized_media_image.dart';
import '../widgets/loading_indicator_box.dart';
/// Fix / apply a metadata match on a movie or show. Plex-only feature; the
/// underlying [PlexClient.findMatches]/[PlexClient.applyMatch] endpoints
/// are not part of the neutral [MediaServerClient] surface.
///
/// On success, pops `true` so the caller can refresh its view.
class PlexMatchScreen extends StatefulWidget {
final MediaItem metadata;
const PlexMatchScreen({super.key, required this.metadata});
@override
State<PlexMatchScreen> createState() => _PlexMatchScreenState();
}
class _PlexMatchScreenState extends State<PlexMatchScreen> with ControllerDisposerMixin {
late final PlexClient _client;
late final TextEditingController _nameController = createTextEditingController(text: widget.metadata.title);
late final TextEditingController _yearController = createTextEditingController(
text: widget.metadata.year?.toString() ?? '',
);
final _nameFocus = FocusNode();
final _yearFocus = FocusNode();
final _searchFocus = FocusNode();
List<PlexMatchResult>? _results;
bool _isSearching = false;
String? _applyingGuid;
bool get _isApplying => _applyingGuid != null;
/// Treat the item as unmatched if its [MediaItem.guid] is missing or
/// references the Plex no-agent marker.
bool get _isUnmatched {
final guid = widget.metadata.guid;
return guid == null || guid.isEmpty || guid.contains('agents.none://');
}
@override
void initState() {
super.initState();
_client = context.getPlexClientWithFallback(serverIdOrNull(widget.metadata.serverId));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (InputModeTracker.isKeyboardMode(context)) {
_nameFocus.requestFocus();
}
_search();
});
}
@override
void dispose() {
_nameFocus.dispose();
_yearFocus.dispose();
_searchFocus.dispose();
super.dispose();
}
Future<void> _search() async {
if (_isSearching) return;
setState(() => _isSearching = true);
List<PlexMatchResult>? results;
try {
results = await _client.findMatches(
widget.metadata.id,
title: _nameController.text.trim(),
year: _yearController.text.trim(),
);
} catch (e, st) {
// [PlexClient._wrapListApiCall] rethrows — catch here so the screen
// doesn't get stuck on the loading spinner.
appLogger.e('Failed to search for matches', error: e, stackTrace: st);
if (mounted) showErrorSnackBar(context, t.errors.searchFailed(error: e));
}
if (!mounted) return;
setState(() {
// On failure keep previous results; fall back to the empty state so the
// spinner never sticks on a first-search failure.
_results = results ?? _results ?? const [];
_isSearching = false;
});
}
Future<void> _applyMatch(PlexMatchResult result) async {
if (_isApplying) return;
setState(() => _applyingGuid = result.guid);
bool success = false;
try {
success = await _client.applyMatch(
widget.metadata.id,
guid: result.guid,
name: result.name,
year: result.year?.toString(),
);
} catch (e, st) {
// [PlexClient._wrapBoolApiCall] rethrows — catch here so
// `_applyingGuid` doesn't get stuck non-null.
appLogger.e('Failed to apply match', error: e, stackTrace: st);
}
if (!mounted) return;
setState(() => _applyingGuid = null);
if (success) {
showSuccessSnackBar(context, t.matchScreen.matchApplied);
Navigator.pop(context, true);
} else {
showErrorSnackBar(context, t.matchScreen.matchFailed);
}
}
@override
Widget build(BuildContext context) {
return FocusedScrollScaffold(
title: Text(_isUnmatched ? t.matchScreen.match : t.matchScreen.fixMatch),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
sliver: SliverToBoxAdapter(child: _buildSearchForm(context)),
),
if (_isSearching || _results == null)
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
else if (_results!.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
t.matchScreen.noMatchesFound,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
)
else
SliverPadding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 24),
sliver: SliverList.builder(
itemCount: _results!.length,
itemBuilder: (context, index) => _buildResultTile(_results![index]),
),
),
],
);
}
Widget _buildSearchForm(BuildContext context) {
return Column(
crossAxisAlignment: .stretch,
children: [
Row(
children: [
Expanded(
child: FocusableTextField(
controller: _nameController,
focusNode: _nameFocus,
decoration: pillInputDecoration(
context,
hintText: t.matchScreen.titleHint,
prefixIcon: const AppIcon(Symbols.search_rounded),
),
textInputAction: TextInputAction.next,
onSubmitted: (_) => _yearFocus.requestFocus(),
onNavigateRight: _yearFocus.requestFocus,
),
),
const SizedBox(width: 12),
SizedBox(
width: 140,
child: FocusableTextField(
controller: _yearController,
focusNode: _yearFocus,
decoration: pillInputDecoration(context, hintText: t.matchScreen.yearHint),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(4)],
textInputAction: TextInputAction.search,
onSubmitted: (_) => _search(),
onNavigateLeft: _nameFocus.requestFocus,
),
),
],
),
const SizedBox(height: 12),
FocusableButton(
focusNode: _searchFocus,
useBackgroundFocus: true,
onPressed: _isSearching ? null : _search,
child: FilledButton.icon(
onPressed: _isSearching ? null : _search,
icon: const AppIcon(Symbols.search_rounded),
label: Text(t.matchScreen.search),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
shape: const StadiumBorder(),
),
),
),
],
);
}
Widget _buildResultTile(PlexMatchResult result) {
final isApplyingThis = _isApplying && _applyingGuid == result.guid;
final yearLabel = result.year?.toString() ?? '';
final titleText = yearLabel.isEmpty ? result.name : '${result.name} (${result.year})';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4),
child: FocusableListTile(
enabled: !_isApplying,
onTap: () => _applyMatch(result),
leading: SizedBox(
width: 48,
height: 72,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: OptimizedMediaImage(
client: _client,
imagePath: result.thumb,
fit: BoxFit.cover,
fallbackIcon: Symbols.movie_rounded,
),
),
),
title: Text(titleText, style: const TextStyle(fontWeight: .w600)),
subtitle: result.summary != null && result.summary!.isNotEmpty
? Text(result.summary!, maxLines: 2, overflow: .ellipsis)
: null,
trailing: isApplyingThis
? const LoadingIndicatorBox(size: 24)
: result.score != null
? _ScoreChip(score: result.score!)
: null,
),
);
}
}
class _ScoreChip extends StatelessWidget {
final int score;
const _ScoreChip({required this.score});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(100)),
child: Text(
'$score',
style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: .w600),
),
);
}
}