diff --git a/lib/utils/download_version_utils.dart b/lib/utils/download_version_utils.dart index 8c0f29eb..af1aa339 100644 --- a/lib/utils/download_version_utils.dart +++ b/lib/utils/download_version_utils.dart @@ -6,6 +6,7 @@ import '../media/media_server_client.dart'; import '../media/media_version.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; +import '../utils/media_version_resolver.dart'; import '../i18n/strings.g.dart'; /// Configuration for download version selection, threaded through the queue pipeline. @@ -42,8 +43,9 @@ Future resolveDownloadVersion( final kind = metadata.kind; if (kind == MediaKind.movie || kind == MediaKind.episode) { - final versions = metadata.mediaVersions ?? fallbackVersions; - if (versions != null && versions.length > 1) { + final versions = await resolveMediaVersions(metadata, client, fallbackVersions: fallbackVersions); + if (!context.mounted) return null; + if (versions.length > 1) { final selectedIndex = await showVersionPickerDialog(context, versions, t.downloads.selectVersion); if (selectedIndex == null || !context.mounted) return null; return DownloadVersionConfig(mediaIndex: selectedIndex); diff --git a/lib/utils/media_version_resolver.dart b/lib/utils/media_version_resolver.dart new file mode 100644 index 00000000..6318867c --- /dev/null +++ b/lib/utils/media_version_resolver.dart @@ -0,0 +1,34 @@ +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; +import '../media/media_version.dart'; +import 'app_logger.dart'; + +List? _nonEmptyVersions(List? versions) { + return versions == null || versions.isEmpty ? null : versions; +} + +/// Resolve media versions for item-level actions without making every browse +/// row carry heavy backend-specific media-source payloads. +Future> resolveMediaVersions( + MediaItem metadata, + MediaServerClient client, { + List? fallbackVersions, +}) async { + final inlineVersions = _nonEmptyVersions(metadata.mediaVersions); + if (inlineVersions != null) return inlineVersions; + + final fallback = _nonEmptyVersions(fallbackVersions); + if (fallback != null) return fallback; + + try { + final fullMetadata = await client.fetchItem(metadata.id); + return _nonEmptyVersions(fullMetadata?.mediaVersions) ?? const []; + } catch (e, st) { + appLogger.w( + 'Failed to resolve media versions for ${metadata.backend.id} item ${metadata.id}', + error: e, + stackTrace: st, + ); + return const []; + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index cc09eb77..fc4961bc 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -20,6 +20,7 @@ import '../models/transcode_quality_preset.dart'; import '../utils/download_version_utils.dart'; 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/download_provider.dart'; import '../providers/multi_server_provider.dart'; @@ -860,13 +861,12 @@ class MediaContextMenuState extends State { Future _handlePlayVersion(BuildContext context) async { final item = _mediaItem!; + final client = context.tryGetMediaClientForServer(_itemServerId); // Same flag the in-player Version & Quality sheet reads — keeps both // surfaces honest about what the active backend can actually do. - final canTranscode = _itemServerId == null - ? false - : (context.read().getClientForServer(_itemServerId!)?.capabilities.videoTranscoding ?? - false); - final versions = item.mediaVersions ?? const []; + final canTranscode = client?.capabilities.videoTranscoding ?? false; + final versions = client == null ? item.mediaVersions ?? const [] : await resolveMediaVersions(item, client); + if (!context.mounted) return false; int selectedVersionIndex = 0; if (versions.length > 1) { diff --git a/test/utils/media_version_resolver_test.dart b/test/utils/media_version_resolver_test.dart new file mode 100644 index 00000000..eed93697 --- /dev/null +++ b/test/utils/media_version_resolver_test.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_version.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/utils/media_version_resolver.dart'; + +JellyfinConnection _conn() => JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: 'tok-abc', + deviceId: 'dev-xyz', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +MediaItem _item({List? mediaVersions}) => MediaItem.jellyfin( + id: 'movie-1', + kind: MediaKind.movie, + title: 'Movie', + mediaVersions: mediaVersions, + serverId: 'srv-1', + serverName: 'Home', +); + +MediaVersion _version(String id) => MediaVersion(id: id, videoResolution: '1080', videoCodec: 'h264'); + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('resolveMediaVersions', () { + test('fetches full metadata when the browse row has no versions', () async { + final requests = []; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add(request.url); + return http.Response( + jsonEncode({ + 'Id': 'movie-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1080', + 'Container': 'mkv', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + + final versions = await resolveMediaVersions(_item(), client); + + expect(versions.map((version) => version.id), ['src-1080', 'src-4k']); + expect(requests, hasLength(1)); + expect(requests.single.path, '/Users/user-1/Items/movie-1'); + expect(requests.single.queryParameters['Fields'], contains('MediaSources')); + }); + + test('uses inline versions without fetching full metadata', () async { + var requested = false; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requested = true; + return http.Response('unexpected', 500); + }), + ); + addTearDown(client.close); + final inlineVersions = [_version('inline')]; + + final versions = await resolveMediaVersions(_item(mediaVersions: inlineVersions), client); + + expect(versions, same(inlineVersions)); + expect(requested, isFalse); + }); + + test('uses fallback versions without fetching full metadata', () async { + var requested = false; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requested = true; + return http.Response('unexpected', 500); + }), + ); + addTearDown(client.close); + final fallbackVersions = [_version('fallback')]; + + final versions = await resolveMediaVersions(_item(), client, fallbackVersions: fallbackVersions); + + expect(versions, same(fallbackVersions)); + expect(requested, isFalse); + }); + + test('returns an empty list when detail lookup fails', () async { + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async => http.Response('server error', 500)), + ); + addTearDown(client.close); + + final versions = await resolveMediaVersions(_item(), client); + + expect(versions, isEmpty); + }); + }); +}