diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 370e9de63..58a49f768 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -61,6 +61,7 @@ import '../providers/download_provider.dart'; import '../providers/offline_watch_provider.dart'; import '../providers/watch_state_store.dart'; import '../services/catalog/catalog_source.dart'; +import '../services/catalog/seerr_catalog_source.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import '../utils/scroll_utils.dart'; @@ -92,6 +93,7 @@ import '../widgets/hub_section.dart'; import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/rasterized_gradient.dart'; +import '../widgets/seerr_request_sheet.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_background.dart'; @@ -2635,6 +2637,46 @@ class _MediaDetailScreenState extends State ); } + /// Empty episode list, upgraded with a Seerr Request action when a + /// connected Seerr may request TV. [season] scopes the request to that + /// season; null requests the whole show. Falls back to the plain empty + /// state offline, without permission, or on Specials (which the request + /// sheet drops). + Widget _sectionNoEpisodes(BuildContext context, MediaItem? season) { + final seerr = widget.isOffline ? null : Provider.of(context, listen: false)?.seerrSource; + final requestable = + seerr != null && seerr.canRequest(MediaKind.show) && !(season != null && isSpecialSeasonNumber(season.index)); + if (!requestable) return _sectionEmpty(context, t.messages.noEpisodesFoundGeneral); + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + Text( + t.messages.noEpisodesFoundGeneral, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () => unawaited(_requestMissingEpisodes(seerr, season)), + icon: const AppIcon(Symbols.download_rounded, fill: 1), + label: Text(t.seerr.request), + ), + ], + ), + ), + ); + } + + Future _requestMissingEpisodes(SeerrCatalogSource seerr, MediaItem? season) async { + final client = _getMediaClientForMetadata(context); + if (client == null) { + showErrorSnackBar(context, t.seerr.requestsLoadFailed); + return; + } + await showSeerrRequestSheetForLibraryItem(context, source: seerr, client: client, item: season ?? _metadata); + } + /// Retryable error for a section whose fetch threw (vs. [_sectionEmpty], which /// means a successful-but-empty result). Reuses the app-wide [ErrorStateWidget] /// so the Retry button is dpad-focusable. @@ -3304,7 +3346,7 @@ class _MediaDetailScreenState extends State else if (_episodes.isNotEmpty) _buildEpisodesList() else - _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), + _sectionNoEpisodes(context, _seasons[_selectedSeasonIndex]), ], const SizedBox(height: 24), ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ @@ -3318,7 +3360,7 @@ class _MediaDetailScreenState extends State else if (_episodes.isNotEmpty) _buildEpisodesList() else - _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), + _sectionNoEpisodes(context, metadata.isSeason ? metadata : null), const SizedBox(height: 24), ], diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 1df0133a9..04af43e8c 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../providers/watch_state_store.dart'; +import '../media/episode_collection.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; @@ -16,6 +17,7 @@ import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; import '../metadata_edit/metadata_edit_adapters.dart'; import '../media/media_version.dart'; +import '../services/catalog/seerr_catalog_source.dart'; import '../services/plex_client.dart'; import '../services/media_list_playback_launcher.dart'; import '../services/music/music_playback_service.dart'; @@ -28,6 +30,7 @@ import '../utils/download_utils.dart'; import '../utils/quality_preset_labels.dart'; import '../utils/media_version_resolver.dart'; import '../utils/global_key_utils.dart'; +import '../providers/catalog_sources_provider.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/offline_mode_provider.dart'; @@ -60,6 +63,7 @@ import 'pill_input_decoration.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/rating_bottom_sheet.dart'; +import '../widgets/seerr_request_sheet.dart'; import '../i18n/strings.g.dart'; class _MenuAction { @@ -81,6 +85,34 @@ bool isAdminActionAllowedForMediaItem({ return isOwnerOrAdmin && !blockedByPlexHomeRole; } +/// Whether the menu should offer a Seerr "Request" action: Seerr must be +/// connected with request permission for [kind], and the item must be a +/// movie, show, or season. +/// +/// Movies are gated on having no known local file. Plex inlines `Media[]` +/// in browse listings and both backends' detail fetches carry versions, but +/// Jellyfin's slim browse field set omits `MediaSources` (see +/// `_browseFields`), so its movie cards fail open — like shows, whose +/// season completeness no listing exposes; [SeerrRequestSheet] itself +/// reports when everything is already available. Seasons gate on +/// [leafCount] instead: only a regular season with no episodes on the +/// server is offered (the request sheet drops Specials). +bool isSeerrRequestVisible({ + required SeerrCatalogSource? seerrSource, + required MediaKind? kind, + required List? mediaVersions, + required int? seasonNumber, + required int? leafCount, +}) { + if (seerrSource == null || kind == null) return false; + if (kind != MediaKind.movie && kind != MediaKind.show && kind != MediaKind.season) return false; + if (kind == MediaKind.movie && (mediaVersions?.isNotEmpty ?? false)) return false; + if (kind == MediaKind.season && (isSpecialSeasonNumber(seasonNumber) || (leafCount ?? 0) > 0)) { + return false; + } + return seerrSource.canRequest(kind); +} + /// A reusable wrapper widget that adds a context menu (long press / right click) /// to any media item with appropriate actions based on the item type. /// Caller-supplied entry appended to a [MediaContextMenu] (e.g. the @@ -539,6 +571,22 @@ class MediaContextMenuState extends State { } } + // Request via Seerr — offered for movies, shows, and episode-less + // regular seasons when a connected Seerr instance grants request + // permission for this kind; see isSeerrRequestVisible for the + // placeholder-detection caveats. Hidden while the item's server is + // unreachable: resolving the TMDB id needs a live metadata fetch. + if (itemServerOnline && + isSeerrRequestVisible( + seerrSource: Provider.of(context, listen: false)?.seerrSource, + kind: mediaKind, + mediaVersions: mediaItem.mediaVersions, + seasonNumber: mediaItem.index, + leafCount: mediaItem.leafCount, + )) { + menuActions.add(_MenuAction(value: 'request_seerr', icon: Symbols.download_rounded, label: t.seerr.request)); + } + // Add to... (for episodes, movies, shows, and seasons). Plex-only — // uses `buildMetadataUri` + `addToPlaylist` / `addToCollection`. The // Jellyfin item-add API is different and not wired here yet. @@ -757,6 +805,10 @@ class MediaContextMenuState extends State { await _showFileInfo(context); break; + case 'request_seerr': + await _handleRequestSeerr(context); + break; + case 'add_to': await _showAddToSubmenu(context); break; @@ -994,6 +1046,30 @@ class MediaContextMenuState extends State { } } + /// Resolve the item's TMDB id and open the Seerr request sheet. Presented + /// from the menu's own context so a screen-level OverlaySheetHost is found + /// (see _showContextMenu). + Future _handleRequestSeerr(BuildContext context) async { + // Re-read: Seerr can disconnect (session invalidated, or disconnected in + // Settings) between menu build and tap. + final seerrSource = Provider.of(context, listen: false)?.seerrSource; + if (seerrSource == null) { + showErrorSnackBar(context, t.seerr.requestsLoadFailed); + return; + } + + final MediaServerClient client; + try { + client = _getMediaClientForItem(); + } catch (_) { + showErrorSnackBar(context, t.seerr.requestsLoadFailed); + return; + } + + if (!mounted) return; + await showSeerrRequestSheetForLibraryItem(this.context, source: seerrSource, client: client, item: _mediaItem!); + } + Future _handlePlayVersion(BuildContext context) async { final item = _mediaItem!; final itemServerId = serverIdOrNull(_itemServerId); diff --git a/lib/widgets/seerr_request_sheet.dart b/lib/widgets/seerr_request_sheet.dart index be2ce6add..21521f97f 100644 --- a/lib/widgets/seerr_request_sheet.dart +++ b/lib/widgets/seerr_request_sheet.dart @@ -5,7 +5,9 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; +import '../media/media_item.dart'; import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; import '../models/seerr/seerr_details.dart'; import '../models/seerr/seerr_media.dart'; import '../models/seerr/seerr_public_settings.dart'; @@ -15,6 +17,7 @@ import '../services/catalog/seerr_catalog_source.dart'; import '../services/seerr/seerr_constants.dart'; import '../services/seerr/seerr_exceptions.dart'; import '../utils/app_logger.dart'; +import '../utils/dialogs.dart'; import '../utils/snackbar_helper.dart'; import 'app_icon.dart'; import 'app_menu.dart'; @@ -30,15 +33,78 @@ Future showSeerrRequestSheet( required MediaKind kind, required int tmdbId, required String title, + List initialSeasons = const [], }) { return OverlaySheetController.showAdaptive( context, isScrollControlled: true, showDragHandle: true, - builder: (_) => SeerrRequestSheet(source: source, kind: kind, tmdbId: tmdbId, title: title), + builder: (_) => + SeerrRequestSheet(source: source, kind: kind, tmdbId: tmdbId, title: title, initialSeasons: initialSeasons), ); } +/// Resolve a library [item]'s TMDB id through its media server and open the +/// request sheet. Seasons request through their show, preselected in the +/// sheet. Shows a loading dialog while resolving and an error snackbar when +/// the item can't be resolved to a TMDB id. +Future showSeerrRequestSheetForLibraryItem( + BuildContext context, { + required SeerrCatalogSource source, + required MediaServerClient client, + required MediaItem item, +}) async { + // A season's show lands in a backend-dependent field: Plex children carry + // the show in `parentRatingKey` (parentId), while Jellyfin's seasons + // endpoint carries `SeriesId` (grandparentId) and may omit `ParentId`. + final isSeason = item.kind == MediaKind.season; + final lookupId = isSeason ? (item.parentId ?? item.grandparentId) : item.id; + if (lookupId == null) { + showErrorSnackBar(context, t.seerr.requestsLoadFailed); + return; + } + + var loadingShown = false; + try { + showLoadingDialog(context); + loadingShown = true; + + final externalIds = await client.fetchExternalIds(lookupId); + + if (loadingShown && context.mounted) { + Navigator.pop(context); + loadingShown = false; + } + + final tmdbId = externalIds.tmdb; + if (tmdbId == null) { + if (context.mounted) { + showErrorSnackBar(context, t.seerr.requestsLoadFailed); + } + return; + } + + if (context.mounted) { + await showSeerrRequestSheet( + context, + source: source, + kind: isSeason ? MediaKind.show : item.kind, + tmdbId: tmdbId, + title: isSeason ? (item.parentTitle ?? item.grandparentTitle ?? item.displayTitle) : item.displayTitle, + initialSeasons: isSeason ? [?item.index] : const [], + ); + } + } catch (e) { + appLogger.w('Seerr: library request resolution failed for ${item.id}', error: e); + if (loadingShown && context.mounted && Navigator.canPop(context)) { + Navigator.pop(context); + } + if (context.mounted) { + showErrorSnackBar(context, t.seerr.requestsLoadFailed); + } + } +} + /// The full Seerr request flow, mirroring the web UI: per-season selection /// with availability states, a 4K toggle, and the advanced destination /// pickers — every section gated by the same instance settings and user @@ -49,12 +115,17 @@ class SeerrRequestSheet extends StatefulWidget { final int tmdbId; final String title; + /// TV only: seasons to preselect once loaded, where requestable (e.g. a + /// season card's Request action preselects that season). + final List initialSeasons; + const SeerrRequestSheet({ super.key, required this.source, required this.kind, required this.tmdbId, required this.title, + this.initialSeasons = const [], }); @override @@ -147,6 +218,11 @@ class _SeerrRequestSheetState extends State { _seasons = seasons; _allServers = servers; _loading = false; + _selectedSeasons.addAll([ + for (final season in seasons) + if (widget.initialSeasons.contains(season.seasonNumber) && !_seasonBlocked(season.seasonNumber)) + season.seasonNumber, + ]); }); _selectDefaultServer(); } catch (e) { diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index bc2c23b95..55f033145 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -18,22 +18,27 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_playlist.dart'; import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/media_version.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/metadata_edit/metadata_edit_adapters.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/models/seerr/seerr_session.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/catalog/seerr_catalog_source.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/services/seerr/seerr_client.dart'; +import 'package:plezy/services/seerr/seerr_constants.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -91,6 +96,113 @@ void main() { }); }); + group('isSeerrRequestVisible', () { + test('hidden when Seerr is missing, the kind is unsupported, or permission is absent', () { + final tvOnly = _seerrSourceWithPermissions(SeerrPermission.requestTv); + + expect( + isSeerrRequestVisible( + seerrSource: null, + kind: MediaKind.movie, + mediaVersions: null, + seasonNumber: null, + leafCount: null, + ), + isFalse, + ); + expect( + isSeerrRequestVisible( + seerrSource: tvOnly, + kind: MediaKind.episode, + mediaVersions: null, + seasonNumber: null, + leafCount: null, + ), + isFalse, + ); + expect( + isSeerrRequestVisible( + seerrSource: tvOnly, + kind: MediaKind.movie, + mediaVersions: null, + seasonNumber: null, + leafCount: null, + ), + isFalse, + ); + }); + + test('movies gated on having no known file; shows always offered', () { + final source = _seerrSourceWithPermissions(SeerrPermission.request); + + expect( + isSeerrRequestVisible( + seerrSource: source, + kind: MediaKind.movie, + mediaVersions: null, + seasonNumber: null, + leafCount: null, + ), + isTrue, + ); + expect( + isSeerrRequestVisible( + seerrSource: source, + kind: MediaKind.movie, + mediaVersions: const [MediaVersion(id: 'v1')], + seasonNumber: null, + leafCount: null, + ), + isFalse, + ); + expect( + isSeerrRequestVisible( + seerrSource: source, + kind: MediaKind.show, + mediaVersions: null, + seasonNumber: null, + leafCount: 12, + ), + isTrue, + ); + }); + + test('seasons offered only when regular and without episodes', () { + final source = _seerrSourceWithPermissions(SeerrPermission.request); + + expect( + isSeerrRequestVisible( + seerrSource: source, + kind: MediaKind.season, + mediaVersions: null, + seasonNumber: 1, + leafCount: 0, + ), + isTrue, + ); + expect( + isSeerrRequestVisible( + seerrSource: source, + kind: MediaKind.season, + mediaVersions: null, + seasonNumber: 1, + leafCount: 8, + ), + isFalse, + ); + expect( + isSeerrRequestVisible( + seerrSource: source, + kind: MediaKind.season, + mediaVersions: null, + seasonNumber: 0, + leafCount: 0, + ), + isFalse, + ); + }); + }); + group('MediaContextMenu actions', () { testWidgets('audio playlist play and shuffle actions use music playback', (tester) async { LocaleSettings.setLocaleSync(AppLocale.en); @@ -467,6 +579,31 @@ class _RecordingMusicPlaybackService extends StubMusicPlaybackService { } } +SeerrCatalogSource _seerrSourceWithPermissions(int permissions) { + final client = SeerrClient( + SeerrSession( + baseUrl: 'https://seerr.example.com', + method: SeerrAuthMethod.local, + identifier: 'a@b.c', + secret: 'pw', + cookie: 'cookie', + userId: 1, + permissions: permissions, + displayName: 'Alice', + instanceLabel: 'Seerr', + createdAt: 0, + ), + onSessionInvalidated: () {}, + httpClient: MockClient((_) async => http.Response('', 404)), + ); + final source = SeerrCatalogSource(client); + addTearDown(() { + source.dispose(); + client.dispose(); + }); + return source; +} + PlexHomeUser _homeUser({required bool admin}) { return PlexHomeUser( id: 0,