diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 753447d8..b9e15e90 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -219,6 +219,9 @@ abstract class MediaServerClient { /// "More like this" recommendations for [id]. Future> fetchRelatedHubs(String id, {int count = 10}); + /// Media featuring a specific person/actor. + Future> fetchPersonMedia(String personId); + /// Page through items in [hubId] when the hub previewed only the first N /// items (`MediaHub.more == true`). Plex hits `/hubs/{key}` (the same /// id used in [fetchGlobalHubs]); Jellyfin re-runs the synthesised query diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index 2a0a7c9c..2d750ba1 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -3,7 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; -import '../services/plex_client.dart'; +import '../media/media_server_client.dart'; import '../utils/provider_extensions.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/optimized_media_image.dart'; @@ -15,10 +15,6 @@ import '../mixins/grid_focus_node_mixin.dart'; import '../focus/focusable_action_bar.dart'; /// Screen to browse all media featuring a specific actor. -/// -/// Plex-only today: uses `fetchAllPersonMediaAsMediaItems` which has no -/// Jellyfin counterpart yet. Callers must guard the navigation by backend -/// (see `_navigateToActorMedia` in media_detail_screen.dart). class ActorMediaScreen extends StatefulWidget { final String actorName; final String personId; @@ -75,13 +71,10 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen super.dispose(); } - PlexClient get _plexClient => context.getPlexClientForServer(widget.serverId); + MediaServerClient get _mediaClient => context.getMediaClientForServer(widget.serverId); @override - Future> fetchItems() async { - // Plex-only — guarded at the call site in media_detail_screen.dart. - return _plexClient.fetchAllPersonMediaAsMediaItems(widget.personId); - } + Future> fetchItems() => _mediaClient.fetchPersonMedia(widget.personId); @override Future loadItems() async { @@ -104,7 +97,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen ClipRRect( borderRadius: BorderRadius.circular(40), child: OptimizedMediaImage( - client: _plexClient, + client: _mediaClient, imagePath: widget.actorThumb, width: 80, height: 80, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index feecc543..b072b08d 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -853,10 +853,6 @@ class _MediaDetailScreenState extends State } void _navigateToActorMedia(MediaRole actor) { - // Plex-only today — Jellyfin's `/Persons/{id}/Items` isn't wired yet. - // Cast cards still render for parity, but tapping is a no-op until the - // Jellyfin path lands. - if (_metadata.backend != MediaBackend.plex) return; final personId = actor.id; if (personId == null || _metadata.serverId == null) return; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index decf1add..15dcabc2 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -499,6 +499,26 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _mapItems(_itemsArray(response.data)); } + @override + Future> fetchPersonMedia(String personId) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'PersonIds': personId, + 'IncludeItemTypes': 'Movie,Series', + 'Recursive': 'true', + 'Fields': _browseFields, + 'SortBy': 'PremiereDate,ProductionYear,SortName', + 'SortOrder': 'Descending,Descending,Ascending', + 'CollapseBoxSetItems': 'false', + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + @override Future> fetchRecentlyAdded({int limit = 50}) async { // Matches userLibraryApi.getLatestMedia in the Jellyfin SDK. diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 2044da16..1cbd35a6 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3209,6 +3209,9 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize); } + @override + Future> fetchPersonMedia(String personId) => fetchAllPersonMediaAsMediaItems(personId); + /// Plex-specific: full person-media listing across pages. Future> fetchAllPersonMediaAsMediaItems(String personId) async { final raw = await _fetchAllPersonMediaDto(personId); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 846dba0a..378955d0 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -746,6 +746,42 @@ void main() { expect(starts, ['0', '200']); }); + test('fetchPersonMedia queries items by person id', () async { + Uri? captured; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + captured = req.url; + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, + ], + 'TotalRecordCount': 1, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.fetchPersonMedia('person-1'); + + expect(result.single.id, 'movie-1'); + expect(captured, isNotNull); + expect(captured!.path, '/Items'); + expect(captured!.queryParameters['userId'], 'user-1'); + expect(captured!.queryParameters['PersonIds'], 'person-1'); + expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series'); + expect(captured!.queryParameters['Recursive'], 'true'); + expect(captured!.queryParameters['SortBy'], 'PremiereDate,ProductionYear,SortName'); + expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Ascending'); + expect(captured!.queryParameters['CollapseBoxSetItems'], 'false'); + expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(captured!.queryParameters['ImageTypeLimit'], '1'); + }); + test('fetchItemWithOnDeck keeps resumable NextUp semantics for show detail lookup', () async { Uri? capturedNextUp; final scoped = JellyfinClient.forTesting(