fix: standardize bool parsing with flexibleBool helper

close #844
This commit is contained in:
edde746
2026-04-11 21:22:15 +02:00
parent b92842f9ac
commit 1b2ecd9de4
10 changed files with 192 additions and 53 deletions
+7
View File
@@ -56,6 +56,7 @@ import 'package:package_info_plus/package_info_plus.dart';
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false); const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
const String gitCommit = String.fromEnvironment('GIT_COMMIT'); const String gitCommit = String.fromEnvironment('GIT_COMMIT');
const String _plexTokenDefine = String.fromEnvironment('PLEX_TOKEN');
// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events // Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
// at (0,0) as barrier taps, causing modals to dismiss immediately. // at (0,0) as barrier taps, causing modals to dismiss immediately.
@@ -143,6 +144,12 @@ Future<void> _bootstrapApp() async {
// Wait for all parallel services to complete // Wait for all parallel services to complete
await Future.wait(futures); await Future.wait(futures);
// Seed Plex token from dart-define (used by screenshot automation)
if (_plexTokenDefine.isNotEmpty) {
final storage = await StorageService.getInstance();
await storage.savePlexToken(_plexTokenDefine);
}
// Initialize logger level based on debug setting // Initialize logger level based on debug setting
final debugEnabled = settings.getEnableDebugLogging(); final debugEnabled = settings.getEnableDebugLogging();
setLoggerLevel(debugEnabled); setLoggerLevel(debugEnabled);
+4 -2
View File
@@ -1,3 +1,5 @@
import '../utils/json_utils.dart';
/// Represents a Live TV channel from the EPG /// Represents a Live TV channel from the EPG
class LiveTvChannel { class LiveTvChannel {
final String key; final String key;
@@ -41,10 +43,10 @@ class LiveTvChannel {
thumb: json['thumb'] as String?, thumb: json['thumb'] as String?,
art: json['art'] as String?, art: json['art'] as String?,
number: json['number'] as String? ?? json['channelNumber'] as String? ?? json['channelVcn']?.toString() ?? json['vcn']?.toString(), number: json['number'] as String? ?? json['channelNumber'] as String? ?? json['channelVcn']?.toString() ?? json['vcn']?.toString(),
hd: json['hd'] == true || json['hd'] == 1, hd: flexibleBool(json['hd']),
lineup: json['lineup'] as String?, lineup: json['lineup'] as String?,
slug: json['slug'] as String?, slug: json['slug'] as String?,
drm: json['drm'] == true || json['drm'] == 1, drm: flexibleBool(json['drm']),
); );
} }
+3 -1
View File
@@ -1,3 +1,5 @@
import '../utils/json_utils.dart';
/// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider) /// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider)
class LiveTvDvr { class LiveTvDvr {
final String key; final String key;
@@ -74,7 +76,7 @@ class ChannelMapping {
return ChannelMapping( return ChannelMapping(
channelKey: json['channelKey'] as String?, channelKey: json['channelKey'] as String?,
deviceIdentifier: json['deviceIdentifier'] as String?, deviceIdentifier: json['deviceIdentifier'] as String?,
enabled: json['enabled'] == true || json['enabled'] == 1 || json['enabled'] == '1', enabled: flexibleBool(json['enabled']),
lineupIdentifier: json['lineupIdentifier'] as String?, lineupIdentifier: json['lineupIdentifier'] as String?,
); );
} }
+4 -2
View File
@@ -1,3 +1,5 @@
import '../utils/json_utils.dart';
/// Represents an EPG program entry (what's on a channel at a given time) /// Represents an EPG program entry (what's on a channel at a given time)
class LiveTvProgram { class LiveTvProgram {
final String? key; final String? key;
@@ -66,8 +68,8 @@ class LiveTvProgram {
channelIdentifier: channelIdentifier:
json['channelIdentifier'] as String? ?? media?['channelIdentifier']?.toString() ?? channel?['id']?.toString(), json['channelIdentifier'] as String? ?? media?['channelIdentifier']?.toString() ?? channel?['id']?.toString(),
channelCallSign: json['channelCallSign'] as String? ?? media?['channelCallSign'] as String?, channelCallSign: json['channelCallSign'] as String? ?? media?['channelCallSign'] as String?,
live: json['live'] == true || json['live'] == 1 || json['live'] == '1', live: flexibleBool(json['live']),
premiere: json['premiere'] == true || json['premiere'] == 1 || json['premiere'] == '1', premiere: flexibleBool(json['premiere']),
); );
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import '../utils/json_utils.dart';
import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText;
import 'plex_metadata.dart'; import 'plex_metadata.dart';
@@ -64,7 +65,7 @@ class PlexHub {
type: json['type'] as String? ?? 'hub', type: json['type'] as String? ?? 'hub',
hubIdentifier: json['hubIdentifier'] as String?, hubIdentifier: json['hubIdentifier'] as String?,
size: (json['size'] as num?)?.toInt() ?? metadataList.length, size: (json['size'] as num?)?.toInt() ?? metadataList.length,
more: json['more'] == true || json['more'] == 1, more: flexibleBool(json['more']),
items: metadataList, items: metadataList,
); );
} }
+4 -3
View File
@@ -1,4 +1,5 @@
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/json_utils.dart';
import '../utils/codec_utils.dart'; import '../utils/codec_utils.dart';
import '../utils/track_label_builder.dart' show buildTrackLabel; import '../utils/track_label_builder.dart' show buildTrackLabel;
@@ -45,7 +46,7 @@ class PlexMediaInfo {
title: s['title'] as String?, title: s['title'] as String?,
displayTitle: s['displayTitle'] as String?, displayTitle: s['displayTitle'] as String?,
channels: s['channels'] as int?, channels: s['channels'] as int?,
selected: s['selected'] == 1 || s['selected'] == true, selected: flexibleBool(s['selected']),
)); ));
} else if (streamType == 3) { } else if (streamType == 3) {
subtitleTracks.add(PlexSubtitleTrack( subtitleTracks.add(PlexSubtitleTrack(
@@ -56,8 +57,8 @@ class PlexMediaInfo {
languageCode: s['languageCode'] as String?, languageCode: s['languageCode'] as String?,
title: s['title'] as String?, title: s['title'] as String?,
displayTitle: s['displayTitle'] as String?, displayTitle: s['displayTitle'] as String?,
selected: s['selected'] == 1 || s['selected'] == true, selected: flexibleBool(s['selected']),
forced: s['forced'] == 1 || s['forced'] == true, forced: flexibleBool(s['forced']),
key: s['key'] as String?, key: s['key'] as String?,
)); ));
} }
+6 -4
View File
@@ -1,3 +1,5 @@
import '../utils/json_utils.dart';
class PlexSubtitleSearchResult { class PlexSubtitleSearchResult {
final int id; final int id;
final String key; final String key;
@@ -40,10 +42,10 @@ class PlexSubtitleSearchResult {
providerTitle: json['providerTitle']?.toString(), providerTitle: json['providerTitle']?.toString(),
title: json['title']?.toString(), title: json['title']?.toString(),
displayTitle: json['displayTitle']?.toString(), displayTitle: json['displayTitle']?.toString(),
hearingImpaired: json['hearingImpaired'] == 1 || json['hearingImpaired'] == true, hearingImpaired: flexibleBool(json['hearingImpaired']),
perfectMatch: json['perfectMatch'] == 1 || json['perfectMatch'] == true, perfectMatch: flexibleBool(json['perfectMatch']),
downloaded: json['downloaded'] == 1 || json['downloaded'] == true, downloaded: flexibleBool(json['downloaded']),
forced: json['forced'] == 1 || json['forced'] == true, forced: flexibleBool(json['forced']),
); );
} }
+116 -30
View File
@@ -59,6 +59,8 @@ import '../utils/deletion_notifier.dart';
import '../widgets/episode_card.dart'; import '../widgets/episode_card.dart';
import 'actor_media_screen.dart'; import 'actor_media_screen.dart';
import '../widgets/focusable_tab_chip.dart'; import '../widgets/focusable_tab_chip.dart';
import '../widgets/hub_section.dart';
import '../models/plex_hub.dart';
class MediaDetailScreen extends StatefulWidget { class MediaDetailScreen extends StatefulWidget {
final PlexMetadata metadata; final PlexMetadata metadata;
@@ -87,6 +89,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
PlexVideoPlaybackData? _playbackData; PlexVideoPlaybackData? _playbackData;
bool _isLoadingMetadata = true; bool _isLoadingMetadata = true;
List<PlexMetadata>? _extras; List<PlexMetadata>? _extras;
List<PlexHub> _relatedHubs = [];
List<GlobalKey<HubSectionState>> _relatedHubKeys = [];
late final ScrollController _scrollController; late final ScrollController _scrollController;
final ScrollController _extrasScrollController = ScrollController(); final ScrollController _extrasScrollController = ScrollController();
bool _watchStateChanged = false; bool _watchStateChanged = false;
@@ -1193,6 +1197,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Load extras (trailers, behind-the-scenes, etc.) // Load extras (trailers, behind-the-scenes, etc.)
_loadExtras(); _loadExtras();
_loadRelatedHubs();
return; return;
} }
@@ -1480,6 +1485,58 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} }
} }
/// Load related hubs (collections, similar, "more from" director/actor)
Future<void> _loadRelatedHubs() async {
if (!widget.metadata.isMovie && !widget.metadata.isShow) {
return;
}
if (widget.isOffline) {
return;
}
try {
final client = _getClientForMetadata(context);
if (client == null) return;
final hubs = await client.getRelatedHubs(widget.metadata.ratingKey);
setStateIfMounted(() {
_relatedHubs = hubs;
_relatedHubKeys = List.generate(hubs.length, (_) => GlobalKey<HubSectionState>());
});
} catch (e) {
// Silently fail - related sections won't appear if fetch fails
}
}
/// Focus the first visible section above cast: season tabs → overview → play button.
/// Shared by cast UP, extras UP, and related hub UP handlers.
void _focusSectionAboveCast() {
final metadata = _fullMetadata ?? widget.metadata;
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
} else {
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
_playButtonFocusNode.requestFocus();
}
}
/// Focus the first visible section above extras: cast → season tabs → overview → play button.
void _focusSectionAboveExtras() {
final metadata = _fullMetadata ?? widget.metadata;
if (metadata.role != null && metadata.role!.isNotEmpty) {
_castFocusNode.requestFocus();
_scrollSectionIntoView(_castSectionKey);
} else {
_focusSectionAboveCast();
}
}
/// Scroll the main scroll view so the section with the given key is centered /// Scroll the main scroll view so the section with the given key is centered
void _scrollSectionIntoView(GlobalKey key) { void _scrollSectionIntoView(GlobalKey key) {
scrollContextToCenter(key.currentContext); scrollContextToCenter(key.currentContext);
@@ -1535,6 +1592,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
return KeyEventResult.handled;
}
return KeyEventResult.handled; // consume to prevent unwanted traversal return KeyEventResult.handled; // consume to prevent unwanted traversal
} }
@@ -1561,7 +1623,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// DOWN: season tabs → cast → extras
if (key.isDownKey) { if (key.isDownKey) {
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
@@ -1575,6 +1636,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} else if (_extras != null && _extras!.isNotEmpty) { } else if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus(); _extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey); _scrollSectionIntoView(_extrasSectionKey);
} else if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1744,27 +1807,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// UP: cast → season tabs → overview → play button
if (key.isUpKey) { if (key.isUpKey) {
final metadata = _fullMetadata ?? widget.metadata; _focusSectionAboveExtras();
if (metadata.role != null && metadata.role!.isNotEmpty) {
_castFocusNode.requestFocus();
_scrollSectionIntoView(_castSectionKey);
} else if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
} else {
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
_playButtonFocusNode.requestFocus();
}
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// DOWN: consume (nothing below extras to focus) // DOWN: related hubs → consume
if (key.isDownKey) { if (key.isDownKey) {
if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
}
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1798,33 +1850,24 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// UP: season tabs → overview → play button
if (key.isUpKey) { if (key.isUpKey) {
// If episodes are visible, focus the last episode (cast is right below episodes) // If episodes are visible, focus the last episode (cast is right below episodes)
if (_episodes.isNotEmpty) { if (_episodes.isNotEmpty) {
// For single episode, _lastEpisodeFocusNode isn't attached — use first
final target = _episodes.length == 1 ? _firstEpisodeFocusNode : _lastEpisodeFocusNode; final target = _episodes.length == 1 ? _firstEpisodeFocusNode : _lastEpisodeFocusNode;
target.requestFocus(); target.requestFocus();
return KeyEventResult.handled;
}
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
} else { } else {
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); _focusSectionAboveCast();
_playButtonFocusNode.requestFocus();
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// DOWN: extras (if available) → consume // DOWN: extras → related hubs → consume
if (key.isDownKey) { if (key.isDownKey) {
if (_extras != null && _extras!.isNotEmpty) { if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus(); _extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey); _scrollSectionIntoView(_extrasSectionKey);
} else if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1841,6 +1884,38 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
/// Handle vertical navigation between related hub sections
bool _handleRelatedHubNavigation(int hubIndex, bool isUp) {
if (_relatedHubKeys.isEmpty) return false;
if (isUp && hubIndex == 0) {
if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey);
} else {
_focusSectionAboveExtras();
}
return true;
}
final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1;
if (targetIndex < 0 || targetIndex >= _relatedHubKeys.length) {
return true; // at boundary, consume
}
_relatedHubKeys[targetIndex].currentState?.requestFocusFromMemory();
return true;
}
IconData _getRelatedHubIcon(PlexHub hub) {
final lower = hub.title.toLowerCase();
if (lower.contains('collection')) return Symbols.video_library_rounded;
if (lower.contains('similar')) return Symbols.auto_awesome_rounded;
if (lower.contains('more from') || lower.contains('more with')) return Symbols.person_rounded;
if (lower.contains('genre') || lower.contains('director')) return Symbols.movie_rounded;
return Symbols.recommend_rounded;
}
/// Build episode list directly when the library hides seasons for single-season shows /// Build episode list directly when the library hides seasons for single-season shows
Widget _buildEpisodesList() { Widget _buildEpisodesList() {
final client = _getClientForMetadata(context); final client = _getClientForMetadata(context);
@@ -2570,6 +2645,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
// Related Hubs (Collections, Similar, More From...)
for (int i = 0; i < _relatedHubs.length; i++) ...[
HubSection(
key: _relatedHubKeys[i],
hub: _relatedHubs[i],
icon: _getRelatedHubIcon(_relatedHubs[i]),
onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp),
),
const SizedBox(height: 8),
],
// Additional info // Additional info
if (metadata.studio != null) ...[ if (metadata.studio != null) ...[
_buildInfoRow(t.discover.studio, metadata.studio!), _buildInfoRow(t.discover.studio, metadata.studio!),
+36 -10
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import '../utils/isolate_helper.dart'; import '../utils/isolate_helper.dart';
import '../utils/json_utils.dart';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -44,22 +45,28 @@ class LibraryContentResult {
/// Process hub response in an isolate. /// Process hub response in an isolate.
/// Top-level function so it can be passed to [Isolate.run]. /// Top-level function so it can be passed to [Isolate.run].
List<PlexHub> _processHubResponse(Map<String, dynamic> decoded, String serverId, String? serverName) { List<PlexHub> _processHubResponse(
Map<String, dynamic> decoded,
String serverId,
String? serverName, {
bool Function(PlexMetadata)? filter,
}) {
final container = decoded['MediaContainer'] as Map<String, dynamic>?; final container = decoded['MediaContainer'] as Map<String, dynamic>?;
if (container == null || container['Hub'] == null) return []; if (container == null || container['Hub'] == null) return [];
final itemFilter = filter ?? (PlexMetadata item) => item.isVideoContent;
final hubs = <PlexHub>[]; final hubs = <PlexHub>[];
for (final hubJson in container['Hub'] as List) { for (final hubJson in container['Hub'] as List) {
try { try {
final hub = PlexHub.fromJson(hubJson as Map<String, dynamic>); final hub = PlexHub.fromJson(hubJson as Map<String, dynamic>);
if (hub.items.isEmpty) continue; if (hub.items.isEmpty) continue;
final videoItems = hub.items final filteredItems = hub.items
.where((item) => item.isVideoContent) .where(itemFilter)
.map((item) => item.copyWith(serverId: serverId, serverName: serverName)) .map((item) => item.copyWith(serverId: serverId, serverName: serverName))
.toList(); .toList();
if (videoItems.isNotEmpty) { if (filteredItems.isNotEmpty) {
hubs.add( hubs.add(
PlexHub( PlexHub(
hubKey: hub.hubKey, hubKey: hub.hubKey,
@@ -68,7 +75,7 @@ List<PlexHub> _processHubResponse(Map<String, dynamic> decoded, String serverId,
hubIdentifier: hub.hubIdentifier, hubIdentifier: hub.hubIdentifier,
size: hub.size, size: hub.size,
more: hub.more, more: hub.more,
items: videoItems, items: filteredItems,
serverId: serverId, serverId: serverId,
serverName: serverName, serverName: serverName,
), ),
@@ -839,7 +846,7 @@ class PlexClient {
title: stream['title'] as String?, title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?, displayTitle: stream['displayTitle'] as String?,
channels: stream['channels'] as int?, channels: stream['channels'] as int?,
selected: stream['selected'] == 1 || stream['selected'] == true, selected: flexibleBool(stream['selected']),
), ),
); );
} else if (streamType == PlexStreamType.subtitle) { } else if (streamType == PlexStreamType.subtitle) {
@@ -852,8 +859,8 @@ class PlexClient {
languageCode: stream['languageCode'] as String?, languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?, title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?, displayTitle: stream['displayTitle'] as String?,
selected: stream['selected'] == 1 || stream['selected'] == true, selected: flexibleBool(stream['selected']),
forced: stream['forced'] == 1, forced: flexibleBool(stream['forced']),
key: stream['key'] as String?, key: stream['key'] as String?,
), ),
); );
@@ -1297,8 +1304,8 @@ class PlexClient {
audioCodec: media['audioCodec'] as String?, audioCodec: media['audioCodec'] as String?,
audioProfile: media['audioProfile'] as String?, audioProfile: media['audioProfile'] as String?,
audioChannels: media['audioChannels'] as int?, audioChannels: media['audioChannels'] as int?,
optimizedForStreaming: media['optimizedForStreaming'] as bool?, optimizedForStreaming: flexibleBool(media['optimizedForStreaming']),
has64bitOffsets: media['has64bitOffsets'] as bool?, has64bitOffsets: flexibleBool(media['has64bitOffsets']),
// Part level properties (file) // Part level properties (file)
filePath: part?['file'] as String?, filePath: part?['file'] as String?,
fileSize: part?['size'] as int?, fileSize: part?['size'] as int?,
@@ -1594,6 +1601,25 @@ class PlexClient {
return []; return [];
} }
/// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor)
Future<List<PlexHub>> getRelatedHubs(String ratingKey, {int count = 10}) async {
try {
final response = await _getWithFailover(
'/hubs/metadata/$ratingKey/related',
queryParameters: {'count': count},
);
final sid = serverId;
final sname = serverName;
return await tryIsolateRun(() => _processHubResponse(
response.data as Map<String, dynamic>, sid, sname,
filter: (item) => item.isVideoContent || item.isCollection,
));
} catch (e) {
appLogger.e('Failed to get related hubs: $e');
}
return [];
}
/// Get full content from a hub using its hub key /// Get full content from a hub using its hub key
/// Returns the complete list of metadata items in the hub /// Returns the complete list of metadata items in the hub
Future<List<PlexMetadata>> getHubContent(String hubKey) async { Future<List<PlexMetadata>> getHubContent(String hubKey) async {
+10
View File
@@ -7,3 +7,13 @@ int? flexibleInt(Object? v) => switch (v) {
String s => int.tryParse(s), String s => int.tryParse(s),
_ => null, _ => null,
}; };
/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool].
/// Returns `false` for `null` or unrecognised values.
/// Handles Plex API responses where boolean fields may arrive as integers.
bool flexibleBool(Object? v) => switch (v) {
bool b => b,
int n => n == 1,
String s => s == '1',
_ => false,
};