feat(jellyfin): add extras support
This commit is contained in:
@@ -431,6 +431,8 @@ sealed class MediaItem with _$MediaItem {
|
||||
return artPath ?? thumbPath;
|
||||
}
|
||||
|
||||
if (kind == MediaKind.clip) return thumbPath ?? artPath;
|
||||
|
||||
return thumbPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user