From 8c8fc13bafdbe847bfbd2870014f145c6700ba02 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:38:39 +0200 Subject: [PATCH] fix(playback): skip deleted/inaccessible Plex media versions before play close #919 --- lib/models/plex_media_version.dart | 28 +++++- lib/models/plex_media_version.g.dart | 4 + lib/services/plex_client.dart | 16 +++- test/models/plex_media_version_test.dart | 103 +++++++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 test/models/plex_media_version_test.dart diff --git a/lib/models/plex_media_version.dart b/lib/models/plex_media_version.dart index 7da08349..2d9925d0 100644 --- a/lib/models/plex_media_version.dart +++ b/lib/models/plex_media_version.dart @@ -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 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 = []; diff --git a/lib/models/plex_media_version.g.dart b/lib/models/plex_media_version.g.dart index 7d1e7105..097e7b46 100644 --- a/lib/models/plex_media_version.g.dart +++ b/lib/models/plex_media_version.g.dart @@ -16,4 +16,8 @@ PlexMediaVersion _$PlexMediaVersionFromJson(Map 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')), ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 30c9fccb..84b80f46 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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>( 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?, parseResponse: (response) => response.data as Map?, ); diff --git a/test/models/plex_media_version_test.dart b/test/models/plex_media_version_test.dart new file mode 100644 index 00000000..b15e9cfe --- /dev/null +++ b/test/models/plex_media_version_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex_media_version.dart'; + +Map _media({ + int id = 1, + String videoCodec = 'h264', + String container = 'mkv', + Map 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); + }); + }); +}