Merge the deduplication and dead-code removal pass

Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
This commit is contained in:
edde746
2026-07-26 19:41:23 +02:00
425 changed files with 12045 additions and 17543 deletions
+20 -426
View File
@@ -3,38 +3,13 @@ part of '../../plex_client.dart';
const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels';
const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'};
mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport, LiveTvDvrSupport {
mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, LiveTvDvrSupport {
PlexConfig get config;
MediaServerHttpClient get _http;
@override
ServerId get serverId;
@override
String? get serverName;
List<({String identifier, String gridEndpoint})> get _providerEpg;
Future<MediaServerResponse> _getWithFailover(
String path, {
Map<String, dynamic>? queryParameters,
// ignore: unused_element_parameter
Map<String, String>? headers,
Duration? timeout,
// ignore: unused_element_parameter
AbortController? abort,
bool allowEndpointFailover = true,
});
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response);
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json);
Future<List<T>> _wrapListApiCall<T>(
Future<MediaServerResponse> Function() apiCall,
List<T> Function(MediaServerResponse response) parseResponse,
String errorMessage,
);
/// POST the tune endpoint with one retry on transient HTTP failure.
Future<MediaServerResponse> _postTuneWithRetry(String path, String sessionIdentifier) async {
final query = {'X-Plex-Session-Identifier': sessionIdentifier};
@@ -96,7 +71,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
for (final entry in request.prefs.entries) 'prefs[${entry.key}]': entry.value,
for (final entry in request.params.entries) 'params[${entry.key}]': entry.value,
};
final encoded = MediaServerHttpClient.encodeQueryParameters(flat);
final encoded = encodeQueryParameters(flat);
if (encoded.isNotEmpty) parts.add(encoded);
return parts.join('&');
}
@@ -197,73 +172,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return dvrs.isNotEmpty;
}
@override
Future<LiveTvServerStatus> fetchLiveTvServerStatus() async {
final response = await _getWithFailover('/');
final container = _getMediaContainer(response);
return LiveTvServerStatus.fromJson(container ?? const <String, dynamic>{});
}
@override
Future<LiveTvDvr?> fetchDvr(String dvrId) async {
final response = await _getWithFailover('/livetv/dvrs/$dvrId');
final container = _getMediaContainer(response);
final rootMappings = container?['ChannelMapping'];
return _extractFirst(response, const ['Dvr'], (json) {
final map = Map<String, dynamic>.from(json)..putIfAbsent('ChannelMapping', () => rootMappings);
return LiveTvDvr.fromJson(map);
});
}
@override
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
}) async {
final response = await _http.post(
'/livetv/dvrs',
queryParameters: {
'device': devices,
'lineup': lineups,
...?(language == null ? null : {'language': language}),
...?(country == null ? null : {'country': country}),
...?(postalCode == null ? null : {'postalCode': postalCode}),
},
timeout: MediaServerTimeouts.receive,
);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractFirst(response, const ['Dvr'], LiveTvDvr.fromJson),
activityUuid: _activityUuid(response),
);
}
@override
Future<void> deleteDvr(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId'));
@override
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/prefs', queryParameters: prefs));
@override
Future<void> attachDeviceToDvr(String dvrId, String deviceId) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/devices/$deviceId'));
@override
Future<void> detachDeviceFromDvr(String dvrId, String deviceId) =>
_expectOk(() => _http.delete('/livetv/dvrs/$dvrId/devices/$deviceId'));
@override
Future<void> addLineupToDvr(String dvrId, String lineupUri) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri}));
@override
Future<void> removeLineupFromDvr(String dvrId, String lineupUri) =>
_expectOk(() => _http.delete('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri}));
@override
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) async {
final response = await _http.post('/livetv/dvrs/$dvrId/reloadGuide', timeout: MediaServerTimeouts.receive);
@@ -271,229 +179,23 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return LiveTvActivityResult(value: null, activityUuid: _activityUuid(response));
}
@override
Future<void> cancelGuideReload(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/reloadGuide'));
@override
Future<List<MediaGrabber>> fetchGrabbers({String? protocol}) async {
final response = await _getWithFailover(
'/media/grabbers',
queryParameters: {
...?(protocol == null ? null : {'protocol': protocol}),
},
);
return _extractContainerList(response, const ['MediaGrabber'], MediaGrabber.fromJson);
}
@override
Future<List<MediaGrabberDevice>> fetchGrabberDevices() async {
final response = await _getWithFailover('/media/grabbers/devices');
return _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices() async {
final response = await _http.post('/media/grabbers/devices/discover', timeout: MediaServerTimeouts.receive);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson),
activityUuid: _activityUuid(response),
);
}
@override
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId) async {
final response = await _getWithFailover('/media/grabbers/devices/$deviceId');
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId}) async {
final path = grabberId == null ? '/media/grabbers/devices' : '/media/grabbers/$grabberId/devices';
final response = await _http.post(path, queryParameters: {'uri': uri});
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _expectOk(
() => _http.put(
'/media/grabbers/devices/$deviceId',
queryParameters: {
...?(enabled == null ? null : {'enabled': enabled ? 1 : 0}),
...?(title == null ? null : {'title': title}),
},
),
);
@override
Future<void> deleteGrabberDevice(String deviceId) =>
_expectOk(() => _http.delete('/media/grabbers/devices/$deviceId'));
@override
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId) async {
final response = await _getWithFailover('/media/grabbers/devices/$deviceId/channels');
return _extractContainerList(response, const ['DeviceChannel'], MediaGrabberDeviceChannel.fromJson);
}
@override
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
}) async {
final response = await _http.post(
'/media/grabbers/devices/$deviceId/scan',
queryParameters: {
...?(source == null ? null : {'source': source}),
for (final entry in prefs.entries) 'prefs[${entry.key}]': entry.value,
...?(network == null ? null : {'network': network}),
...?(country == null ? null : {'country': country}),
},
timeout: MediaServerTimeouts.receive,
);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson),
activityUuid: _activityUuid(response),
);
}
@override
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId) async {
final response = await _http.delete('/media/grabbers/devices/$deviceId/scan');
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(
String deviceId,
MediaGrabberChannelMapRequest request,
) async {
final response = await _http.put(
'/media/grabbers/devices/$deviceId/channelmap',
queryParameters: {
if (request.channelsEnabled.isNotEmpty) 'channelsEnabled': request.channelsEnabled.join(','),
for (final entry in request.channelMapping.entries) 'channelMapping[${entry.key}]': entry.value,
for (final entry in request.channelMappingByKey.entries) 'channelMappingByKey[${entry.key}]': entry.value,
},
);
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
@override
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs) =>
_expectOk(() => _http.put('/media/grabbers/devices/$deviceId/prefs', queryParameters: prefs));
@override
String buildGrabberDeviceThumbUrl(String deviceId, int version) =>
'${config.baseUrl}/media/grabbers/devices/$deviceId/thumb/$version'.withPlexToken(config.token);
@override
Future<List<LiveTvCountry>> fetchEpgCountries() async {
final response = await _getWithFailover('/livetv/epg/countries');
return _extractContainerList(response, const ['Country'], LiveTvCountry.fromJson);
}
@override
Future<List<LiveTvLanguage>> fetchEpgLanguages() async {
final response = await _getWithFailover('/livetv/epg/languages');
return _extractContainerList(response, const ['Language'], LiveTvLanguage.fromJson);
}
@override
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId) async {
final response = await _getWithFailover('/livetv/epg/countries/$country/$epgId/regions');
return _extractContainerList(response, const ['Region'], LiveTvRegion.fromJson);
}
@override
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) async {
final path = region == null
? '/livetv/epg/countries/$country/$epgId/lineups'
: '/livetv/epg/countries/$country/$epgId/regions/$region/lineups';
final response = await _getWithFailover(
path,
queryParameters: {
...?(postalCode == null ? null : {'postalCode': postalCode}),
},
);
final container = _getMediaContainer(response);
return LiveTvLineupResult(
lineupGroupUuid: container?['uuid'] as String?,
lineups: _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson),
);
}
@override
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri) async {
final response = await _getWithFailover('/livetv/epg/channels', queryParameters: {'lineup': lineupUri});
return _extractContainerList(response, const [
'Channel',
], (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName));
}
@override
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris) async {
final response = await _getWithFailover('/livetv/epg/lineupchannels', queryParameters: {'lineup': lineupUris});
return _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson);
}
@override
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri}) async {
final response = await _getWithFailover(
'/livetv/epg/channelmap',
queryParameters: {'device': deviceUri, 'lineup': lineupUri},
);
return _extractContainerList(response, const ['ChannelMapping'], ChannelMapping.fromJson);
}
@override
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
}) async {
final response = await _getWithFailover(
'/livetv/epg/lineup',
queryParameters: {'device': deviceUri, 'lineupGroup': lineupGroupUri},
timeout: MediaServerTimeouts.receive,
);
return LiveTvActivityResult(value: _getMediaContainer(response), activityUuid: _activityUuid(response));
}
/// Get EPG channels using provider lineup endpoints (matches official Plex web client)
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
List<LiveTvChannel> parseChannels(MediaServerResponse response) {
final container = _getMediaContainer(response);
if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) {
appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}');
if (container == null || (container['Channel'] == null && container['Metadata'] == null)) {
appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}');
return [];
}
if (container != null && container['Channel'] != null) {
return (container['Channel'] as List)
.map(
(json) => LiveTvChannel.fromJson(
json as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName),
)
.where((ch) => ch.key.isNotEmpty)
.toList();
final rawChannels = container['Channel'];
if (rawChannels is List && rawChannels.isNotEmpty) {
appLogger.d('EPG channel sample: ${rawChannels.first}');
}
if (container != null && container['Metadata'] != null) {
return (container['Metadata'] as List)
.map(
(json) => LiveTvChannel.fromJson(
json as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName),
)
.where((ch) => ch.key.isNotEmpty)
.toList();
}
appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}');
return [];
return _extractContainerList(
response,
const ['Channel', 'Metadata'],
(json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName),
).where((ch) => ch.key.isNotEmpty).toList();
}
final allChannels = <LiveTvChannel>[];
@@ -744,19 +446,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
}) async {
final response = await _getWithFailover(
'/media/subscriptions/$subscriptionId',
queryParameters: {'includeGrabs': includeGrabs ? 1 : 0, 'includeStorage': includeStorage ? 1 : 0},
);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) async {
final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request)));
@@ -778,18 +467,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
Future<void> deleteRecordingRule(String subscriptionId) =>
_expectOk(() => _http.delete('/media/subscriptions/$subscriptionId'));
@override
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) async {
final response = await _http.put(
'/media/subscriptions/$subscriptionId/move',
queryParameters: {
...?(afterSubscriptionId == null ? null : {'after': afterSubscriptionId}),
},
);
_throwIfFailed(response);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override
Future<void> processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process'));
@@ -824,23 +501,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
@override
Future<List<MediaProviderInfo>> fetchMediaProviders() async {
final response = await _getWithFailover('/media/providers');
return _extractContainerList(response, const ['MediaProvider'], MediaProviderInfo.fromJson);
}
@override
Future<void> registerMediaProvider(String url) =>
_expectOk(() => _http.post('/media/providers', queryParameters: {'url': url}));
@override
Future<void> refreshMediaProviders() => _expectOk(() => _http.post('/media/providers/refresh'));
@override
Future<void> unregisterMediaProvider(String providerId) =>
_expectOk(() => _http.delete('/media/providers/$providerId'));
/// Tune to a live TV channel.
///
/// POSTs to the tune endpoint and extracts metadata, session info, and
@@ -873,11 +533,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
if (container == null) return null;
final containerStatus = container['status'];
final statusInt = containerStatus is num
? containerStatus.toInt()
: containerStatus is String
? int.tryParse(containerStatus)
: null;
final statusInt = flexibleInt(containerStatus);
if (statusInt != null && statusInt != 0 && statusInt != 200) {
final msg = container['message'] ?? t.liveTv.unknownError;
appLogger.w('Tune channel error: $msg (status: $containerStatus)');
@@ -907,14 +563,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
if (op is Map) {
if (op['Metadata'] case [final Map firstMetadata, ...]) {
if (firstMetadata['Media'] case [final Map firstMedia, ...]) {
final rawBeginsAt = firstMedia['beginsAt'];
beginsAt = switch (rawBeginsAt) {
final num n => n.toInt(),
final String s => int.tryParse(s),
_ => null,
};
beginsAt = flexibleInt(firstMedia['beginsAt']);
appLogger.d('beginsAt=$beginsAt');
}
}
@@ -980,12 +629,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
if (media is List && media.isNotEmpty) {
final firstMedia = media.first;
if (firstMedia is Map<String, dynamic>) {
final rawBeginsAt = firstMedia['beginsAt'];
beginsAt = switch (rawBeginsAt) {
final num n => n.toInt(),
final String s => int.tryParse(s),
_ => null,
};
beginsAt = flexibleInt(firstMedia['beginsAt']);
}
}
}
@@ -1053,10 +697,9 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
if (config.token != null) 'X-Plex-Token': config.token!,
};
// Manual query encoding — use '%20' for spaces as Plex requires.
final queryString = allParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
// '%20' for spaces as Plex requires — not `Uri.queryParameters`, which
// emits `+`.
final queryString = encodeQueryParameters(allParams);
// Decision — wrapper around the same transport so no default X-Plex-*
// HTTP headers leak through (everything travels in the query string).
@@ -1086,9 +729,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
// Token is added by the caller via .withPlexToken()
final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
final startQuery = startParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
final startQuery = encodeQueryParameters(startParams);
return '$_plexVideoHlsStartEndpoint?$startQuery';
} catch (e, st) {
@@ -1106,53 +747,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
return '${config.baseUrl}$streamPath'.withPlexToken(config.token);
}
@override
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed() async {
final response = await _getWithFailover('/livetv/sessions');
return _extractContainerList(response, const [
'LiveTVSession',
'LiveTvSession',
'Session',
'Metadata',
], LiveTvSession.fromJson);
}
@override
Future<LiveTvSession?> fetchLiveTvSession(String sessionId) async {
final response = await _getWithFailover('/livetv/sessions/$sessionId');
return _extractFirst(response, const [
'LiveTVSession',
'LiveTvSession',
'Session',
'Metadata',
], LiveTvSession.fromJson);
}
@override
Uri buildNotificationWebSocketUri({List<String>? filters}) {
final base = Uri.parse(config.baseUrl);
return base.replace(
scheme: base.scheme == 'https' ? 'wss' : 'ws',
path: '/:/websocket/notifications',
queryParameters: {
if (config.token != null) 'X-Plex-Token': config.token!,
if (filters != null) 'filters': filters.join(','),
},
);
}
@override
Uri buildNotificationEventSourceUri({List<String>? filters}) {
final base = Uri.parse(config.baseUrl);
return base.replace(
path: '/:/eventsource/notifications',
queryParameters: {
if (config.token != null) 'X-Plex-Token': config.token!,
if (filters != null) 'filters': filters.join(','),
},
);
}
/// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}`
@override
Future<String> buildFavoriteChannelSource({String? lineup}) async {