fix(jellyfin): resolve versions on demand

This commit is contained in:
edde746
2026-05-19 10:54:04 +02:00
parent 565885cc1e
commit 48cf8accfa
4 changed files with 186 additions and 7 deletions
+4 -2
View File
@@ -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<DownloadVersionConfig?> 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);
+34
View File
@@ -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<MediaVersion>? _nonEmptyVersions(List<MediaVersion>? 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<List<MediaVersion>> resolveMediaVersions(
MediaItem metadata,
MediaServerClient client, {
List<MediaVersion>? 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 <MediaVersion>[];
} catch (e, st) {
appLogger.w(
'Failed to resolve media versions for ${metadata.backend.id} item ${metadata.id}',
error: e,
stackTrace: st,
);
return const <MediaVersion>[];
}
}
+5 -5
View File
@@ -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<MediaContextMenu> {
Future<bool> _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<MultiServerProvider>().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) {
+143
View File
@@ -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<MediaVersion>? 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 = <Uri>[];
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);
});
});
}