feat(jellyfin): add actor media browsing

close #1005
This commit is contained in:
edde746
2026-05-11 11:54:37 +02:00
parent 617f200cb5
commit 2bbd31d927
6 changed files with 66 additions and 15 deletions
+3
View File
@@ -219,6 +219,9 @@ abstract class MediaServerClient {
/// "More like this" recommendations for [id].
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10});
/// Media featuring a specific person/actor.
Future<List<MediaItem>> 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
+4 -11
View File
@@ -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<ActorMediaScreen>
super.dispose();
}
PlexClient get _plexClient => context.getPlexClientForServer(widget.serverId);
MediaServerClient get _mediaClient => context.getMediaClientForServer(widget.serverId);
@override
Future<List<MediaItem>> fetchItems() async {
// Plex-only — guarded at the call site in media_detail_screen.dart.
return _plexClient.fetchAllPersonMediaAsMediaItems(widget.personId);
}
Future<List<MediaItem>> fetchItems() => _mediaClient.fetchPersonMedia(widget.personId);
@override
Future<void> loadItems() async {
@@ -104,7 +97,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
ClipRRect(
borderRadius: BorderRadius.circular(40),
child: OptimizedMediaImage(
client: _plexClient,
client: _mediaClient,
imagePath: widget.actorThumb,
width: 80,
height: 80,
-4
View File
@@ -853,10 +853,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
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;
@@ -499,6 +499,26 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
return _mapItems(_itemsArray(response.data));
}
@override
Future<List<MediaItem>> 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<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
// Matches userLibraryApi.getLatestMedia in the Jellyfin SDK.
+3
View File
@@ -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<List<MediaItem>> fetchPersonMedia(String personId) => fetchAllPersonMediaAsMediaItems(personId);
/// Plex-specific: full person-media listing across pages.
Future<List<MediaItem>> fetchAllPersonMediaAsMediaItems(String personId) async {
final raw = await _fetchAllPersonMediaDto(personId);
@@ -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(