fix(playback): skip deleted/inaccessible Plex media versions before play
close #919
This commit is contained in:
@@ -8,11 +8,25 @@ part 'plex_media_version.g.dart';
|
||||
|
||||
int _flexibleIntOrZero(Object? v) => flexibleInt(v) ?? 0;
|
||||
|
||||
Object? _readPartKey(Map json, String key) {
|
||||
Map? _firstPart(Map json) {
|
||||
final parts = flexibleList(json['Part']);
|
||||
return parts != null && parts.isNotEmpty ? parts.first['key']?.toString() ?? '' : '';
|
||||
return (parts != null && parts.isNotEmpty) ? parts.first as Map : null;
|
||||
}
|
||||
|
||||
Object? _readPartKey(Map json, String key) => _firstPart(json)?['key']?.toString() ?? '';
|
||||
Object? _readPartAccessible(Map json, String key) => _firstPart(json)?['accessible'];
|
||||
Object? _readPartExists(Map json, String key) => _firstPart(json)?['exists'];
|
||||
|
||||
/// Tri-state: distinguishes "Plex told us false" from "field absent".
|
||||
/// `flexibleBool` collapses both to false, which would mark every version
|
||||
/// unplayable on servers that omit these fields.
|
||||
bool? _flexibleBoolNullable(Object? v) => switch (v) {
|
||||
final bool b => b,
|
||||
final int n => n == 1,
|
||||
final String s => s == '1',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexMediaVersion {
|
||||
@JsonKey(fromJson: _flexibleIntOrZero)
|
||||
@@ -31,6 +45,10 @@ class PlexMediaVersion {
|
||||
final String? container;
|
||||
@JsonKey(readValue: _readPartKey)
|
||||
final String partKey;
|
||||
@JsonKey(readValue: _readPartAccessible, fromJson: _flexibleBoolNullable)
|
||||
final bool? accessible;
|
||||
@JsonKey(readValue: _readPartExists, fromJson: _flexibleBoolNullable)
|
||||
final bool? exists;
|
||||
|
||||
PlexMediaVersion({
|
||||
required this.id,
|
||||
@@ -41,12 +59,18 @@ class PlexMediaVersion {
|
||||
this.height,
|
||||
this.container,
|
||||
required this.partKey,
|
||||
this.accessible,
|
||||
this.exists,
|
||||
});
|
||||
|
||||
/// Creates a PlexMediaVersion from Plex API Media object.
|
||||
/// Values may be String or int depending on the response format (XML vs JSON).
|
||||
factory PlexMediaVersion.fromJson(Map<String, dynamic> json) => _$PlexMediaVersionFromJson(json);
|
||||
|
||||
/// Defaults to true when fields are absent — only an explicit false from
|
||||
/// Plex (requires `checkFiles=1` on the request) marks the version unplayable.
|
||||
bool get isPlayable => accessible != false && exists != false;
|
||||
|
||||
/// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)"
|
||||
String get displayLabel {
|
||||
final parts = <String>[];
|
||||
|
||||
@@ -16,4 +16,8 @@ PlexMediaVersion _$PlexMediaVersionFromJson(Map<String, dynamic> json) =>
|
||||
height: flexibleInt(json['height']),
|
||||
container: readStringField(json, 'container') as String?,
|
||||
partKey: _readPartKey(json, 'partKey') as String,
|
||||
accessible: _flexibleBoolNullable(
|
||||
_readPartAccessible(json, 'accessible'),
|
||||
),
|
||||
exists: _flexibleBoolNullable(_readPartExists(json, 'exists')),
|
||||
);
|
||||
|
||||
@@ -1304,6 +1304,14 @@ class PlexClient {
|
||||
mediaIndex = 0;
|
||||
}
|
||||
|
||||
if (!availableVersions[mediaIndex].isPlayable) {
|
||||
final fallback = availableVersions.indexWhere((v) => v.isPlayable);
|
||||
if (fallback >= 0) {
|
||||
appLogger.w('Version $mediaIndex inaccessible/missing — falling back to version $fallback');
|
||||
mediaIndex = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
final media = mediaList[mediaIndex];
|
||||
if (media['Part'] != null && (media['Part'] as List).isNotEmpty) {
|
||||
final part = media['Part'][0];
|
||||
@@ -1348,8 +1356,12 @@ class PlexClient {
|
||||
try {
|
||||
data = await _fetchWithCacheFallback<Map<String, dynamic>>(
|
||||
cacheKey: '/library/metadata/$ratingKey',
|
||||
networkCall: () =>
|
||||
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
|
||||
// checkFiles=1 populates Part.accessible/exists so we can skip
|
||||
// deleted-but-still-indexed versions before play.
|
||||
networkCall: () => _http.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1},
|
||||
),
|
||||
parseCache: (cached) => cached as Map<String, dynamic>?,
|
||||
parseResponse: (response) => response.data as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/plex_media_version.dart';
|
||||
|
||||
Map<String, dynamic> _media({
|
||||
int id = 1,
|
||||
String videoCodec = 'h264',
|
||||
String container = 'mkv',
|
||||
Map<String, Object?> partExtras = const {},
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'videoResolution': '1080',
|
||||
'videoCodec': videoCodec,
|
||||
'container': container,
|
||||
'bitrate': 5000,
|
||||
'Part': [
|
||||
{'id': 100 + id, 'key': '/library/parts/$id/file.mkv', ...partExtras},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('PlexMediaVersion accessibility parsing', () {
|
||||
test('accessible/exists are null when Plex did not include them', () {
|
||||
final v = PlexMediaVersion.fromJson(_media());
|
||||
expect(v.accessible, isNull);
|
||||
expect(v.exists, isNull);
|
||||
expect(
|
||||
v.isPlayable,
|
||||
isTrue,
|
||||
reason: 'absent fields must default to playable so older PMS / no-checkFiles servers still work',
|
||||
);
|
||||
});
|
||||
|
||||
test('parses int 0/1 from Plex JSON output', () {
|
||||
final notExists = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 0, 'accessible': 1}));
|
||||
expect(notExists.exists, isFalse);
|
||||
expect(notExists.accessible, isTrue);
|
||||
expect(notExists.isPlayable, isFalse);
|
||||
|
||||
final notAccessible = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 1, 'accessible': 0}));
|
||||
expect(notAccessible.exists, isTrue);
|
||||
expect(notAccessible.accessible, isFalse);
|
||||
expect(notAccessible.isPlayable, isFalse);
|
||||
|
||||
final ok = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 1, 'accessible': 1}));
|
||||
expect(ok.isPlayable, isTrue);
|
||||
});
|
||||
|
||||
test('parses native bool', () {
|
||||
final v = PlexMediaVersion.fromJson(_media(partExtras: {'exists': false, 'accessible': true}));
|
||||
expect(v.exists, isFalse);
|
||||
expect(v.accessible, isTrue);
|
||||
expect(v.isPlayable, isFalse);
|
||||
});
|
||||
|
||||
test('parses string "0"/"1" forms (XML-to-JSON conversion)', () {
|
||||
final v = PlexMediaVersion.fromJson(_media(partExtras: {'exists': '0', 'accessible': '1'}));
|
||||
expect(v.exists, isFalse);
|
||||
expect(v.accessible, isTrue);
|
||||
});
|
||||
|
||||
test('isPlayable truth table mirrors Plex web semantics', () {
|
||||
// Mirrors plex-web.js:28926: !1 !== e.exists && !1 !== e.accessible
|
||||
// Anything but explicit `false` for both fields → playable.
|
||||
bool playable({bool? acc, bool? ex}) {
|
||||
return PlexMediaVersion(id: 1, partKey: '/k', accessible: acc, exists: ex).isPlayable;
|
||||
}
|
||||
|
||||
expect(playable(acc: null, ex: null), isTrue);
|
||||
expect(playable(acc: true, ex: true), isTrue);
|
||||
expect(playable(acc: true, ex: null), isTrue);
|
||||
expect(playable(acc: null, ex: true), isTrue);
|
||||
|
||||
expect(playable(acc: false, ex: true), isFalse);
|
||||
expect(playable(acc: true, ex: false), isFalse);
|
||||
expect(playable(acc: false, ex: false), isFalse);
|
||||
expect(playable(acc: false, ex: null), isFalse);
|
||||
expect(playable(acc: null, ex: false), isFalse);
|
||||
});
|
||||
|
||||
test('handles single-Part-as-object (Plex sometimes returns a Map instead of List)', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'videoResolution': '1080',
|
||||
'videoCodec': 'h264',
|
||||
'container': 'mkv',
|
||||
'Part': {'id': 101, 'key': '/library/parts/1/file.mkv', 'exists': 0},
|
||||
};
|
||||
final v = PlexMediaVersion.fromJson(json);
|
||||
expect(v.exists, isFalse);
|
||||
expect(v.isPlayable, isFalse);
|
||||
});
|
||||
|
||||
test('missing Part array leaves accessibility fields null', () {
|
||||
final json = {'id': 1, 'videoResolution': '1080', 'videoCodec': 'h264', 'container': 'mkv'};
|
||||
final v = PlexMediaVersion.fromJson(json);
|
||||
expect(v.accessible, isNull);
|
||||
expect(v.exists, isNull);
|
||||
expect(v.isPlayable, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user