feat(jellyfin): add extras support

This commit is contained in:
edde746
2026-06-01 08:34:38 +02:00
parent 273f129b68
commit 8f9d20a2e4
6 changed files with 126 additions and 14 deletions
+2
View File
@@ -431,6 +431,8 @@ sealed class MediaItem with _$MediaItem {
return artPath ?? thumbPath;
}
if (kind == MediaKind.clip) return thumbPath ?? artPath;
return thumbPath;
}
+6
View File
@@ -237,6 +237,12 @@ abstract class MediaServerClient {
/// "More like this" recommendations for [id].
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10});
/// Playable extras attached to [id] (trailers, featurettes, deleted scenes,
/// behind-the-scenes clips). Backends return only items that can be opened by
/// the normal video playback flow; external/remote trailer URLs are out of
/// scope for this neutral surface.
Future<List<MediaItem>> fetchExtras(String id);
/// Media featuring a specific person/actor.
Future<List<MediaItem>> fetchPersonMedia(String personId);
+14 -14
View File
@@ -20,7 +20,6 @@ import '../focus/focusable_wrapper.dart';
import '../focus/key_event_utils.dart';
import '../focus/input_mode_tracker.dart';
import '../widgets/focus_builders.dart';
import '../media/media_backend.dart';
import '../media/media_hub.dart';
import '../utils/provider_extensions.dart';
import '../utils/plex_season_display.dart';
@@ -1852,8 +1851,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
});
}
/// Load extras (trailers, behind-the-scenes, etc.). Plex-only — Jellyfin
/// has no equivalent of `fetchExtras`.
/// Load extras (trailers, featurettes, behind-the-scenes, etc.).
Future<void> _loadExtras() async {
void markLoaded() {
setStateIfMounted(() {
@@ -1873,13 +1871,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return;
}
if (_metadata.backend != MediaBackend.plex) {
markLoaded();
return;
}
try {
final client = getServerBoundPlexClient(context);
final client = getServerBoundMediaClient(context);
if (client == null) {
markLoaded();
return;
@@ -3856,7 +3849,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
}
/// Get the primary trailer from the extras list
/// Get the primary trailer from the extras list.
MediaItem? _getPrimaryTrailer() {
if (_extras == null || _extras!.isEmpty) return null;
@@ -3872,17 +3865,24 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
}
// Otherwise, find the first item with subtype 'trailer'. Extras are
// always Plex-sourced so the cast is safe; non-Plex backends route
// around this method entirely.
try {
return _extras!.firstWhere((extra) => extra is PlexMediaItem && extra.subtype == 'trailer');
return _extras!.firstWhere(_isTrailerExtra);
} catch (_) {
// No trailer found, return null (button won't appear)
return null;
}
}
bool _isTrailerExtra(MediaItem extra) {
if (extra case PlexMediaItem(:final subtype?)) {
return subtype.toLowerCase() == 'trailer';
}
final raw = extra.raw;
final extraType = raw?['ExtraType'] as String?;
final type = raw?['Type'] as String?;
return extraType?.toLowerCase() == 'trailer' || type?.toLowerCase() == 'trailer';
}
/// Build the cast section with locked focus pattern for D-pad navigation
/// Uses same layout pattern as seasons/extras (ListView.builder + Padding(horizontal: 2))
Widget _buildCastSection(MediaItem metadata) {
@@ -1133,6 +1133,40 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
].where((h) => h.items.isNotEmpty).toList();
}
/// Jellyfin exposes local trailers separately from special features. Combine
/// both into Plezy's existing extras row, but keep remote/YouTube trailers
/// out of scope because they are external URLs, not playable Jellyfin items.
@override
Future<List<MediaItem>> fetchExtras(String id) async {
if (isOfflineMode) return const [];
final results = await Future.wait([
_safeFetchItemsArray('/Items/${_segment(id)}/LocalTrailers', {
'userId': connection.userId,
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/Items/${_segment(id)}/SpecialFeatures', {
'userId': connection.userId,
...jellyfinImageQueryParameters,
}),
]);
return _playableExtrasFromRaw(results.expand((items) => items));
}
List<MediaItem> _playableExtrasFromRaw(Iterable<Map<String, dynamic>> rawExtras) {
final extras = <MediaItem>[];
final seenIds = <String>{};
for (final raw in rawExtras) {
final item = _mapItem(raw);
if (item == null || !item.kind.isVideo || !seenIds.add(item.id)) continue;
extras.add(item);
}
return extras;
}
List<MediaItem> _mergeContinueWatchingAndNextUp({
required List<MediaItem> resume,
required List<MediaItem> nextUp,
+1
View File
@@ -3821,6 +3821,7 @@ class PlexClient
}
/// Plex-specific: extras (trailers, behind-the-scenes) for a media item.
@override
Future<List<MediaItem>> fetchExtras(String ratingKey) async {
final raw = await _getExtras(ratingKey);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
@@ -103,6 +103,75 @@ void main() {
]);
});
test('fetchExtras combines local trailers and special features as playable videos', () async {
const itemId = 'movie/id #1?x';
final encodedItemId = Uri.encodeComponent(itemId);
final requests = <Uri>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
requests.add(request.url);
if (request.url.path == '/Items/$encodedItemId/LocalTrailers') {
return http.Response(
jsonEncode([
{
'Id': 'trailer-1',
'Name': 'Trailer',
'Type': 'Trailer',
'ExtraType': 'Trailer',
'RunTimeTicks': 900000000,
'ImageTags': {'Primary': 'trailer-tag'},
},
{'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'},
]),
200,
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == '/Items/$encodedItemId/SpecialFeatures') {
return http.Response(
jsonEncode([
{'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'},
{
'Id': 'featurette-1',
'Name': 'Making Of',
'Type': 'Video',
'ExtraType': 'Featurette',
'RunTimeTicks': 1800000000,
'BackdropImageTags': ['featurette-backdrop'],
},
]),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('unexpected ${request.url}', 500);
}),
);
addTearDown(scoped.close);
final extras = await scoped.fetchExtras(itemId);
expect(requests.map((uri) => uri.path).toSet(), {
'/Items/$encodedItemId/LocalTrailers',
'/Items/$encodedItemId/SpecialFeatures',
});
expect(requests.every((uri) => uri.queryParameters['userId'] == 'user-1'), isTrue);
expect(requests.every((uri) => uri.queryParameters['EnableImageTypes'] == 'Primary,Backdrop,Thumb,Logo'), isTrue);
expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '1'), isTrue);
expect(extras.map((item) => item.id).toList(), ['trailer-1', 'featurette-1']);
expect(extras.every((item) => item.kind.isVideo), isTrue);
expect(extras.every((item) => item.serverId == 'srv-1'), isTrue);
expect(extras.every((item) => item.serverName == 'Home'), isTrue);
expect(extras[0].kind, MediaKind.clip);
expect(extras[0].raw?['ExtraType'], 'Trailer');
expect(extras[1].kind, MediaKind.clip);
expect(extras[1].raw?['ExtraType'], 'Featurette');
expect(extras[1].thumbPath, isNull);
expect(extras[1].artPath, isNotNull);
expect(extras[1].posterThumb(), extras[1].artPath);
});
test('reportPlaybackProgress sends media source and stream indexes', () async {
Uri? capturedUri;
String? capturedBody;