fix: make PlexMetadata type/key/title nullable for folder items
Plex API returns Directory entries without type field, causing TypeError crash in fromJson. Made fields nullable and migrated callers to use mediaType enum and displayTitle getter instead of raw string access.
This commit is contained in:
@@ -62,11 +62,11 @@ enum PlexMediaType {
|
||||
class PlexMetadata with MultiServerFields {
|
||||
@JsonKey(readValue: _readRatingKey)
|
||||
final String ratingKey;
|
||||
final String key;
|
||||
final String? key;
|
||||
final String? guid;
|
||||
final String? studio;
|
||||
final String type;
|
||||
final String title;
|
||||
final String? type;
|
||||
final String? title;
|
||||
final String? titleSort;
|
||||
final String? contentRating;
|
||||
final String? summary;
|
||||
@@ -135,7 +135,8 @@ class PlexMetadata with MultiServerFields {
|
||||
|
||||
/// Parsed media type enum for type-safe comparisons
|
||||
PlexMediaType get mediaType {
|
||||
return switch (type.toLowerCase()) {
|
||||
if (type == null) return PlexMediaType.unknown;
|
||||
return switch (type!.toLowerCase()) {
|
||||
'movie' => PlexMediaType.movie,
|
||||
'show' => PlexMediaType.show,
|
||||
'season' => PlexMediaType.season,
|
||||
@@ -153,11 +154,11 @@ class PlexMetadata with MultiServerFields {
|
||||
|
||||
PlexMetadata({
|
||||
required this.ratingKey,
|
||||
required this.key,
|
||||
this.key,
|
||||
this.guid,
|
||||
this.studio,
|
||||
required this.type,
|
||||
required this.title,
|
||||
this.type,
|
||||
this.title,
|
||||
this.titleSort,
|
||||
this.contentRating,
|
||||
this.summary,
|
||||
@@ -366,7 +367,7 @@ class PlexMetadata with MultiServerFields {
|
||||
|
||||
// Helper to get the display title (show name for episodes/seasons, title otherwise)
|
||||
String get displayTitle {
|
||||
final itemType = type.toLowerCase();
|
||||
final itemType = type?.toLowerCase();
|
||||
|
||||
// For episodes and seasons, prefer grandparent title (show name)
|
||||
if ((itemType == 'episode' || itemType == 'season') && grandparentTitle != null) {
|
||||
@@ -376,12 +377,12 @@ class PlexMetadata with MultiServerFields {
|
||||
if (itemType == 'season' && parentTitle != null) {
|
||||
return parentTitle!;
|
||||
}
|
||||
return title;
|
||||
return title ?? '';
|
||||
}
|
||||
|
||||
// Helper to get the subtitle (episode/season title)
|
||||
String? get displaySubtitle {
|
||||
final itemType = type.toLowerCase();
|
||||
final itemType = type?.toLowerCase();
|
||||
|
||||
if (itemType == 'episode' || itemType == 'season') {
|
||||
// If we showed grandparent/parent as title, show this item's title as subtitle
|
||||
@@ -401,7 +402,7 @@ class PlexMetadata with MultiServerFields {
|
||||
/// For movies/shows/seasons in mixed hub context: returns art (16:9 background)
|
||||
/// For other types: returns thumb
|
||||
String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) {
|
||||
final itemType = type.toLowerCase();
|
||||
final itemType = type?.toLowerCase();
|
||||
|
||||
if (itemType == 'episode') {
|
||||
switch (mode) {
|
||||
@@ -436,7 +437,7 @@ class PlexMetadata with MultiServerFields {
|
||||
/// Clips (trailers, extras) always use 16:9.
|
||||
/// Movies, shows, and seasons use 16:9 in mixed hub context with episodeThumbnail mode.
|
||||
bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) {
|
||||
final itemType = type.toLowerCase();
|
||||
final itemType = type?.toLowerCase();
|
||||
// Clips (trailers, extras) are always 16:9
|
||||
if (itemType == 'clip') return true;
|
||||
if (itemType == 'episode' && mode == EpisodePosterMode.episodeThumbnail) {
|
||||
|
||||
@@ -8,11 +8,11 @@ part of 'plex_metadata.dart';
|
||||
|
||||
PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
ratingKey: _readRatingKey(json, 'ratingKey') as String,
|
||||
key: json['key'] as String,
|
||||
key: json['key'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
studio: json['studio'] as String?,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
type: json['type'] as String?,
|
||||
title: json['title'] as String?,
|
||||
titleSort: json['titleSort'] as String?,
|
||||
contentRating: json['contentRating'] as String?,
|
||||
summary: json['summary'] as String?,
|
||||
|
||||
@@ -515,10 +515,10 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// We have metadata, check type
|
||||
final type = meta.type.toLowerCase();
|
||||
if (type == 'show') {
|
||||
final mt = meta.mediaType;
|
||||
if (mt == PlexMediaType.show) {
|
||||
return getAggregateProgressForShow(serverId, ratingKey);
|
||||
} else if (type == 'season') {
|
||||
} else if (mt == PlexMediaType.season) {
|
||||
return getAggregateProgressForSeason(serverId, ratingKey);
|
||||
}
|
||||
|
||||
@@ -606,19 +606,19 @@ class DownloadProvider extends ChangeNotifier {
|
||||
_queueing.add(globalKey);
|
||||
notifyListeners();
|
||||
|
||||
final type = metadata.type.toLowerCase();
|
||||
final mt = metadata.mediaType;
|
||||
|
||||
if (type == 'movie' || type == 'episode') {
|
||||
if (mt == PlexMediaType.movie || mt == PlexMediaType.episode) {
|
||||
// Direct download of a single item
|
||||
await _queueSingleDownload(metadata, client);
|
||||
return 1;
|
||||
} else if (type == 'show') {
|
||||
} else if (mt == PlexMediaType.show) {
|
||||
// Store show metadata so getProgress() can identify it as a show
|
||||
_metadata[globalKey] = metadata;
|
||||
|
||||
// Download all episodes from all seasons
|
||||
return await _queueShowDownload(metadata, client);
|
||||
} else if (type == 'season') {
|
||||
} else if (mt == PlexMediaType.season) {
|
||||
// Store season metadata so getProgress() can identify it as a season
|
||||
_metadata[globalKey] = metadata;
|
||||
|
||||
@@ -765,11 +765,11 @@ class DownloadProvider extends ChangeNotifier {
|
||||
/// Used for resuming partial downloads
|
||||
/// Returns the number of episodes queued
|
||||
Future<int> queueMissingEpisodes(PlexMetadata metadata, PlexClient client) async {
|
||||
final type = metadata.type.toLowerCase();
|
||||
final mt = metadata.mediaType;
|
||||
|
||||
if (type == 'show') {
|
||||
if (mt == PlexMediaType.show) {
|
||||
return await _queueMissingShowEpisodes(metadata, client);
|
||||
} else if (type == 'season') {
|
||||
} else if (mt == PlexMediaType.season) {
|
||||
return await _queueMissingSeasonEpisodes(metadata, client);
|
||||
} else {
|
||||
throw Exception('queueMissingEpisodes only supports shows/seasons');
|
||||
|
||||
@@ -31,7 +31,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
PlexMetadata get mediaItem => widget.collection;
|
||||
|
||||
@override
|
||||
String get title => widget.collection.title;
|
||||
String get title => widget.collection.title!;
|
||||
|
||||
@override
|
||||
String get emptyMessage => t.collections.empty;
|
||||
@@ -93,7 +93,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.collections.deleteCollection,
|
||||
message: t.collections.deleteConfirm(title: widget.collection.title),
|
||||
message: t.collections.deleteConfirm(title: widget.collection.displayTitle),
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
@@ -134,7 +134,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
body: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.collection.title), actions: buildFocusableAppBarActions()),
|
||||
CustomAppBar(title: Text(widget.collection.title!), actions: buildFocusableAppBarActions()),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
buildFocusableGrid(
|
||||
|
||||
@@ -1263,7 +1263,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) {
|
||||
final isEpisode = heroItem.isEpisode;
|
||||
final showName = heroItem.grandparentTitle ?? heroItem.title;
|
||||
final showName = heroItem.grandparentTitle ?? heroItem.displayTitle;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
switch (sortKey) {
|
||||
case 'titleSort':
|
||||
case 'title':
|
||||
comparison = a.title.compareTo(b.title);
|
||||
comparison = (a.title ?? '').compareTo(b.title ?? '');
|
||||
break;
|
||||
case 'addedAt':
|
||||
comparison = (a.addedAt ?? 0).compareTo(b.addedAt ?? 0);
|
||||
@@ -193,7 +193,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
comparison = (a.rating ?? 0).compareTo(b.rating ?? 0);
|
||||
break;
|
||||
default:
|
||||
comparison = a.title.compareTo(b.title);
|
||||
comparison = (a.title ?? '').compareTo(b.title ?? '');
|
||||
}
|
||||
|
||||
return _isSortDescending ? -comparison : comparison;
|
||||
|
||||
@@ -42,21 +42,14 @@ class FolderTreeItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
// File icons based on type
|
||||
final type = item.type.toLowerCase();
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
return Symbols.movie_rounded;
|
||||
case 'show':
|
||||
return Symbols.tv_rounded;
|
||||
case 'season':
|
||||
return Symbols.video_library_rounded;
|
||||
case 'episode':
|
||||
return Symbols.play_circle_rounded;
|
||||
case 'collection':
|
||||
return Symbols.collections_rounded;
|
||||
default:
|
||||
return Symbols.insert_drive_file_rounded;
|
||||
}
|
||||
return switch (item.mediaType) {
|
||||
PlexMediaType.movie => Symbols.movie_rounded,
|
||||
PlexMediaType.show => Symbols.tv_rounded,
|
||||
PlexMediaType.season => Symbols.video_library_rounded,
|
||||
PlexMediaType.episode => Symbols.play_circle_rounded,
|
||||
PlexMediaType.collection => Symbols.collections_rounded,
|
||||
_ => Symbols.insert_drive_file_rounded,
|
||||
};
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
@@ -104,7 +97,7 @@ class FolderTreeItem extends StatelessWidget {
|
||||
// Item title
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
item.displayTitle,
|
||||
style: TextStyle(fontSize: 14, fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -87,32 +87,32 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
|
||||
Future<void> _loadFolderChildren(PlexMetadata folder) async {
|
||||
// Already loading this folder
|
||||
if (_loadingFolders.contains(folder.key)) return;
|
||||
if (_loadingFolders.contains(folder.key!)) return;
|
||||
|
||||
// Already loaded and cached
|
||||
if (_childrenCache.containsKey(folder.key)) {
|
||||
if (_childrenCache.containsKey(folder.key!)) {
|
||||
setState(() {
|
||||
_expandedFolders.add(folder.key);
|
||||
_expandedFolders.add(folder.key!);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingFolders.add(folder.key);
|
||||
_loadingFolders.add(folder.key!);
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.getClientForServer(widget.serverId!);
|
||||
|
||||
// Items are automatically tagged with server info by PlexClient
|
||||
final children = await client.getFolderChildren(folder.key);
|
||||
final children = await client.getFolderChildren(folder.key!);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_childrenCache[folder.key] = children;
|
||||
_expandedFolders.add(folder.key);
|
||||
_loadingFolders.remove(folder.key);
|
||||
_childrenCache[folder.key!] = children;
|
||||
_expandedFolders.add(folder.key!);
|
||||
_loadingFolders.remove(folder.key!);
|
||||
});
|
||||
|
||||
appLogger.d('Loaded ${children.length} children for folder: ${folder.title}');
|
||||
@@ -121,7 +121,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
|
||||
appLogger.e('Failed to load folder children', error: e);
|
||||
setState(() {
|
||||
_loadingFolders.remove(folder.key);
|
||||
_loadingFolders.remove(folder.key!);
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
@@ -131,9 +131,9 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
|
||||
void _toggleFolder(PlexMetadata folder) {
|
||||
if (_expandedFolders.contains(folder.key)) {
|
||||
if (_expandedFolders.contains(folder.key!)) {
|
||||
setState(() {
|
||||
_expandedFolders.remove(folder.key);
|
||||
_expandedFolders.remove(folder.key!);
|
||||
});
|
||||
} else {
|
||||
_loadFolderChildren(folder);
|
||||
@@ -147,19 +147,19 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
Future<void> _handleFolderPlay(PlexMetadata folder) async {
|
||||
final client = context.getClientForServer(widget.serverId!);
|
||||
final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
await launcher.launchFromFolder(folderKey: folder.key, shuffle: false);
|
||||
await launcher.launchFromFolder(folderKey: folder.key!, shuffle: false);
|
||||
}
|
||||
|
||||
Future<void> _handleFolderShuffle(PlexMetadata folder) async {
|
||||
final client = context.getClientForServer(widget.serverId!);
|
||||
final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
await launcher.launchFromFolder(folderKey: folder.key, shuffle: true);
|
||||
await launcher.launchFromFolder(folderKey: folder.key!, shuffle: true);
|
||||
}
|
||||
|
||||
bool _isFolder(PlexMetadata item) {
|
||||
// Folders typically don't have a specific type or might have special indicators
|
||||
// Check for common folder indicators
|
||||
return item.key.contains('/folder') || item.type.isEmpty || item.type.toLowerCase() == 'folder';
|
||||
return item.key?.contains('/folder') == true || item.type == null || item.type!.isEmpty || item.mediaType == PlexMediaType.unknown;
|
||||
}
|
||||
|
||||
List<Widget> _buildTreeItems(List<PlexMetadata> items, int depth, [String parentPath = '']) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../focus/locked_hub_controller.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../models/livetv_channel.dart';
|
||||
import '../../../models/livetv_hub_result.dart';
|
||||
import '../../../models/plex_metadata.dart';
|
||||
import '../../../providers/multi_server_provider.dart';
|
||||
import '../../../providers/settings_provider.dart';
|
||||
import '../../../services/settings_service.dart' show LibraryDensity;
|
||||
@@ -168,12 +169,12 @@ class WhatsOnTabState extends State<WhatsOnTab> {
|
||||
if (entry.program.isCurrentlyAiring && channel != null) {
|
||||
// Live → play directly
|
||||
_tuneChannel(channel);
|
||||
} else if (entry.metadata.type.toLowerCase() == 'show') {
|
||||
} else if (entry.metadata.mediaType == PlexMediaType.show) {
|
||||
// Show with upcoming episodes → show full schedule
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LiveTvShowScheduleScreen(
|
||||
showTitle: entry.metadata.title,
|
||||
showTitle: entry.metadata.displayTitle,
|
||||
serverId: entry.metadata.serverId ?? '',
|
||||
channels: widget.channels,
|
||||
),
|
||||
|
||||
@@ -740,7 +740,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: metadata.title),
|
||||
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
|
||||
);
|
||||
|
||||
if (confirmed && context.mounted) {
|
||||
@@ -1579,7 +1579,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
onSecondaryTapDown: (details) => tapPosition = details.globalPosition,
|
||||
onSecondaryTap: () => _showSeasonTabContextMenu(index, position: tapPosition),
|
||||
child: FocusableTabChip(
|
||||
label: season.title,
|
||||
label: season.title!,
|
||||
isSelected: index == _selectedSeasonIndex,
|
||||
focusNode: _seasonTabFocusNodes.length > index ? _seasonTabFocusNodes[index] : null,
|
||||
onSelect: () {
|
||||
@@ -2251,11 +2251,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildTitleText(context, metadata.title),
|
||||
_buildTitleText(context, metadata.displayTitle),
|
||||
);
|
||||
}
|
||||
// Offline but no local file - show title text
|
||||
return _buildTitleText(context, metadata.title);
|
||||
return _buildTitleText(context, metadata.displayTitle);
|
||||
}
|
||||
|
||||
// Online - use network image
|
||||
@@ -2280,7 +2280,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
placeholder: (context, url) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
metadata.title,
|
||||
metadata.displayTitle,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -2293,7 +2293,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return _buildTitleText(context, metadata.title);
|
||||
return _buildTitleText(context, metadata.displayTitle);
|
||||
},
|
||||
),
|
||||
sigma: 10,
|
||||
@@ -2304,7 +2304,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
)
|
||||
else
|
||||
Text(
|
||||
metadata.title,
|
||||
metadata.displayTitle,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -237,9 +237,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
}
|
||||
|
||||
String _buildSubtitle() {
|
||||
final itemType = widget.item.type.toLowerCase();
|
||||
final itemType = widget.item.mediaType;
|
||||
|
||||
if (itemType == 'episode') {
|
||||
if (itemType == PlexMediaType.episode) {
|
||||
// For episodes, show "S#E# - Episode Title"
|
||||
final season = widget.item.parentIndex;
|
||||
final episode = widget.item.index;
|
||||
@@ -247,7 +247,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}';
|
||||
}
|
||||
return widget.item.displaySubtitle ?? t.discover.tvShow;
|
||||
} else if (itemType == 'movie') {
|
||||
} else if (itemType == PlexMediaType.movie) {
|
||||
// For movies, show year and edition
|
||||
final year = widget.item.year?.toString();
|
||||
if (year != null && widget.item.editionTitle != null) {
|
||||
@@ -257,6 +257,6 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
}
|
||||
|
||||
// Default to type
|
||||
return widget.item.type;
|
||||
return widget.item.mediaType.name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1485,7 +1485,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
watchTogether.setCurrentMedia(
|
||||
ratingKey: targetMetadata.ratingKey,
|
||||
serverId: targetMetadata.serverId!,
|
||||
mediaTitle: targetMetadata.title,
|
||||
mediaTitle: targetMetadata.displayTitle,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -2898,7 +2898,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
)
|
||||
else
|
||||
Text(
|
||||
_nextEpisode!.title,
|
||||
_nextEpisode!.title!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
|
||||
@@ -351,7 +351,7 @@ class DiscordRPCService {
|
||||
timestamps: _buildTimestamps(),
|
||||
statusDisplayType: DiscordStatusDisplayType.details,
|
||||
largeAsset: _cachedThumbnailUrl != null
|
||||
? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title)
|
||||
? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title!)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
@@ -392,14 +392,14 @@ class DiscordRPCService {
|
||||
switch (metadata.mediaType) {
|
||||
case PlexMediaType.movie:
|
||||
final year = metadata.year != null ? ' (${metadata.year})' : '';
|
||||
return metadata.title + year;
|
||||
return metadata.title! + year;
|
||||
|
||||
case PlexMediaType.episode:
|
||||
// Show: "Show Name" or just episode title if no show name
|
||||
return metadata.grandparentTitle ?? metadata.title;
|
||||
return metadata.grandparentTitle ?? metadata.title!;
|
||||
|
||||
default:
|
||||
return metadata.title;
|
||||
return metadata.title!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,9 +411,9 @@ class DiscordRPCService {
|
||||
final season = metadata.parentIndex;
|
||||
final episode = metadata.index;
|
||||
if (season != null && episode != null) {
|
||||
return 'S$season E$episode - ${metadata.title}';
|
||||
return 'S$season E$episode - ${metadata.title!}';
|
||||
}
|
||||
return metadata.title;
|
||||
return metadata.title!;
|
||||
|
||||
case PlexMediaType.movie:
|
||||
return metadata.studio;
|
||||
|
||||
@@ -341,7 +341,7 @@ class DownloadManagerService {
|
||||
serverId: metadata.serverId!,
|
||||
ratingKey: metadata.ratingKey,
|
||||
globalKey: globalKey,
|
||||
type: metadata.type,
|
||||
type: metadata.type ?? '',
|
||||
parentRatingKey: metadata.parentRatingKey,
|
||||
grandparentRatingKey: metadata.grandparentRatingKey,
|
||||
status: DownloadStatus.queued.index,
|
||||
@@ -446,8 +446,8 @@ class DownloadManagerService {
|
||||
|
||||
// Build display name for notifications
|
||||
final displayName = metadata.type == 'episode'
|
||||
? '${metadata.grandparentTitle ?? metadata.title} - ${metadata.title}'
|
||||
: metadata.title;
|
||||
? '${metadata.grandparentTitle ?? metadata.displayTitle} - ${metadata.displayTitle}'
|
||||
: metadata.displayTitle;
|
||||
|
||||
// Get WiFi-only setting for native enforcement
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -1234,7 +1234,7 @@ class DownloadManagerService {
|
||||
|
||||
// Emit initial progress
|
||||
_emitDeletionProgress(
|
||||
DeletionProgress(globalKey: globalKey, itemTitle: metadata.title, currentItem: 0, totalItems: totalItems),
|
||||
DeletionProgress(globalKey: globalKey, itemTitle: metadata.displayTitle, currentItem: 0, totalItems: totalItems),
|
||||
);
|
||||
|
||||
// Delete files from storage (with progress updates)
|
||||
@@ -1250,7 +1250,7 @@ class DownloadManagerService {
|
||||
_emitDeletionProgress(
|
||||
DeletionProgress(
|
||||
globalKey: globalKey,
|
||||
itemTitle: metadata.title,
|
||||
itemTitle: metadata.displayTitle,
|
||||
currentItem: totalItems,
|
||||
totalItems: totalItems,
|
||||
),
|
||||
@@ -1264,17 +1264,14 @@ class DownloadManagerService {
|
||||
|
||||
/// Calculate total items to delete (for progress tracking)
|
||||
Future<int> _getTotalItemsToDelete(PlexMetadata metadata, String _) async {
|
||||
switch (metadata.type.toLowerCase()) {
|
||||
case 'episode':
|
||||
return 1; // Single episode
|
||||
case 'movie':
|
||||
return 1; // Single movie
|
||||
case 'season':
|
||||
// Count episodes in season
|
||||
switch (metadata.mediaType) {
|
||||
case PlexMediaType.episode:
|
||||
case PlexMediaType.movie:
|
||||
return 1;
|
||||
case PlexMediaType.season:
|
||||
final episodes = await _database.getEpisodesBySeason(metadata.ratingKey);
|
||||
return episodes.length;
|
||||
case 'show':
|
||||
// Count all episodes in show
|
||||
case PlexMediaType.show:
|
||||
final episodes = await _database.getEpisodesByShow(metadata.ratingKey);
|
||||
return episodes.length;
|
||||
default:
|
||||
@@ -1301,17 +1298,17 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
// Delete based on type
|
||||
switch (metadata.type.toLowerCase()) {
|
||||
case 'episode':
|
||||
switch (metadata.mediaType) {
|
||||
case PlexMediaType.episode:
|
||||
await _deleteEpisodeFiles(metadata, serverId);
|
||||
break;
|
||||
case 'season':
|
||||
case PlexMediaType.season:
|
||||
await _deleteSeasonFiles(metadata, serverId);
|
||||
break;
|
||||
case 'show':
|
||||
case PlexMediaType.show:
|
||||
await _deleteShowFiles(metadata, serverId);
|
||||
break;
|
||||
case 'movie':
|
||||
case PlexMediaType.movie:
|
||||
await _deleteMovieFiles(metadata, serverId);
|
||||
break;
|
||||
default:
|
||||
@@ -1447,7 +1444,7 @@ class DownloadManagerService {
|
||||
episodes: episodesInSeason,
|
||||
serverId: serverId,
|
||||
parentKey: season.ratingKey,
|
||||
parentTitle: season.title,
|
||||
parentTitle: season.displayTitle,
|
||||
);
|
||||
|
||||
final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear);
|
||||
@@ -1510,7 +1507,7 @@ class DownloadManagerService {
|
||||
episodes: episodesInShow,
|
||||
serverId: serverId,
|
||||
parentKey: show.ratingKey,
|
||||
parentTitle: show.title,
|
||||
parentTitle: show.displayTitle,
|
||||
);
|
||||
|
||||
final showDir = await _storageService.getShowDirectory(show);
|
||||
|
||||
@@ -65,7 +65,7 @@ class DownloadStorageService {
|
||||
String _formatEpisodeFileName(PlexMetadata episode) {
|
||||
final season = padNumber(episode.parentIndex ?? 0, 2);
|
||||
final ep = padNumber(episode.index ?? 0, 2);
|
||||
final episodeName = _sanitizeFileName(episode.title);
|
||||
final episodeName = _sanitizeFileName(episode.title!);
|
||||
return 'S${season}E$ep - $episodeName';
|
||||
}
|
||||
|
||||
@@ -246,13 +246,13 @@ class DownloadStorageService {
|
||||
|
||||
/// Get the folder name for a movie: "Movie Name (YYYY)"
|
||||
String _getMovieFolderName(PlexMetadata movie) {
|
||||
return _formatTitleWithYear(movie.title, movie.year);
|
||||
return _formatTitleWithYear(movie.title!, movie.year);
|
||||
}
|
||||
|
||||
/// Get the folder name for a TV show: "Show Name (YYYY)"
|
||||
/// [showYear]: Pass explicitly for episodes (episode.year may differ from show's year)
|
||||
String _getShowFolderName(PlexMetadata metadata, {int? showYear}) {
|
||||
final title = metadata.grandparentTitle ?? metadata.title;
|
||||
final title = metadata.grandparentTitle ?? metadata.title!;
|
||||
final year = showYear ?? metadata.year;
|
||||
return _formatTitleWithYear(title, year);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class MediaControlsManager {
|
||||
// Update OS media controls
|
||||
await OsMediaControls.setMetadata(
|
||||
MediaMetadata(
|
||||
title: metadata.title,
|
||||
title: metadata.title!,
|
||||
artist: _buildArtist(metadata),
|
||||
artworkUrl: artworkUrl,
|
||||
duration: duration,
|
||||
|
||||
@@ -134,7 +134,7 @@ class WatchNextService {
|
||||
title = item.grandparentTitle!;
|
||||
episodeTitle = item.title;
|
||||
} else {
|
||||
title = item.title;
|
||||
title = item.title!;
|
||||
episodeTitle = null;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ class WatchNextService {
|
||||
'episodeTitle': episodeTitle,
|
||||
'description': item.summary,
|
||||
'posterUri': posterUri,
|
||||
'type': item.type.toLowerCase(),
|
||||
'type': item.mediaType.name,
|
||||
'duration': item.duration ?? 0,
|
||||
'lastPlaybackPosition': item.viewOffset ?? 0,
|
||||
'lastEngagementTime': lastEngagementTime,
|
||||
|
||||
@@ -86,7 +86,7 @@ String formatContentRating(String? contentRating) {
|
||||
|
||||
/// Extension on PlexMetadata for type checking convenience methods
|
||||
extension PlexMetadataType on PlexMetadata {
|
||||
String get _lowerType => type.toLowerCase();
|
||||
String get _lowerType => type?.toLowerCase() ?? '';
|
||||
|
||||
bool get isShow => _lowerType == ContentTypes.show;
|
||||
bool get isMovie => _lowerType == ContentTypes.movie;
|
||||
|
||||
@@ -80,7 +80,7 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
|
||||
ratingKey: metadata.ratingKey,
|
||||
serverId: metadata.serverId ?? '',
|
||||
parentChain: _buildParentChain(metadata),
|
||||
mediaType: metadata.type,
|
||||
mediaType: metadata.type ?? '',
|
||||
leafCount: metadata.leafCount ?? 1,
|
||||
isDownloadOnly: isDownloadOnly,
|
||||
),
|
||||
|
||||
@@ -86,7 +86,7 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
|
||||
serverId: metadata.serverId ?? '',
|
||||
changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched,
|
||||
parentChain: _buildParentChain(metadata),
|
||||
mediaType: metadata.type,
|
||||
mediaType: metadata.type ?? '',
|
||||
isNowWatched: isNowWatched,
|
||||
),
|
||||
);
|
||||
@@ -103,7 +103,7 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
|
||||
serverId: metadata.serverId ?? '',
|
||||
changeType: WatchStateChangeType.progressUpdate,
|
||||
parentChain: _buildParentChain(metadata),
|
||||
mediaType: metadata.type,
|
||||
mediaType: metadata.type ?? '',
|
||||
viewOffset: viewOffset,
|
||||
isNowWatched: isNowWatched,
|
||||
),
|
||||
|
||||
@@ -145,7 +145,7 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
movies.add(
|
||||
DownloadTreeNode(
|
||||
key: globalKey,
|
||||
title: meta.title,
|
||||
title: meta.displayTitle,
|
||||
type: DownloadNodeType.movie,
|
||||
progress: download.progressPercent,
|
||||
status: download.status,
|
||||
@@ -202,7 +202,7 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
if (meta == null) continue;
|
||||
|
||||
final episodeNumber = meta.index;
|
||||
final episodeTitle = episodeNumber != null ? 'Episode $episodeNumber - ${meta.title}' : meta.title;
|
||||
final episodeTitle = episodeNumber != null ? 'Episode $episodeNumber - ${meta.title!}' : meta.title!;
|
||||
|
||||
episodeNodes.add(
|
||||
DownloadTreeNode(
|
||||
|
||||
@@ -357,7 +357,7 @@ class _EpisodeCardState extends State<EpisodeCard> {
|
||||
// Episode title
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.episode.title,
|
||||
widget.episode.title!,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -518,7 +518,7 @@ class _MediaCardList extends StatelessWidget {
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
||||
fontSize: _subtitleFontSize,
|
||||
);
|
||||
final episodeTitle = metadata.displaySubtitle ?? metadata.title;
|
||||
final episodeTitle = metadata.displaySubtitle ?? metadata.displayTitle;
|
||||
final episodeNum = metadata.index != null ? ' E${metadata.index}' : '';
|
||||
return Row(
|
||||
children: [
|
||||
@@ -783,7 +783,7 @@ class _MediaCardHelpers {
|
||||
|
||||
// For episodes, show "S# · Episode Title" with clickable season link
|
||||
if (metadata.isEpisode && metadata.parentIndex != null) {
|
||||
final episodeTitle = metadata.displaySubtitle ?? metadata.title;
|
||||
final episodeTitle = metadata.displaySubtitle ?? metadata.displayTitle;
|
||||
if (metadata.parentRatingKey != null) {
|
||||
return Row(
|
||||
children: [
|
||||
|
||||
@@ -327,7 +327,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _FocusableContextMenuSheet(
|
||||
title: widget.item.title,
|
||||
title: widget.item.displayTitle,
|
||||
actions: menuActions,
|
||||
focusFirstItem: openedFromKeyboard,
|
||||
),
|
||||
@@ -597,7 +597,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await OverlaySheetController.showAdaptive(
|
||||
context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
|
||||
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.displayTitle),
|
||||
);
|
||||
} else if (context.mounted) {
|
||||
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
|
||||
@@ -667,7 +667,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
try {
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
final itemType = metadata.type.toLowerCase();
|
||||
final itemType = metadata.mediaType.name;
|
||||
|
||||
// Load playlists
|
||||
final playlists = await client.getPlaylists(playlistType: 'video');
|
||||
@@ -752,7 +752,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
try {
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
final itemType = metadata.type.toLowerCase();
|
||||
final itemType = metadata.mediaType;
|
||||
|
||||
// Get the library section ID from the item
|
||||
// First try from the metadata itself
|
||||
@@ -776,8 +776,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
|
||||
// If still not found, try to extract from the key field
|
||||
if (sectionId == null) {
|
||||
final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key);
|
||||
if (sectionId == null && metadata.key != null) {
|
||||
final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key!);
|
||||
if (keyMatch != null) {
|
||||
sectionId = int.tryParse(keyMatch.group(1)!);
|
||||
appLogger.d(' - Extracted from key: $sectionId');
|
||||
@@ -841,18 +841,20 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
// Determine the collection type based on the item type
|
||||
int? collectionType;
|
||||
switch (itemType) {
|
||||
case 'movie':
|
||||
case PlexMediaType.movie:
|
||||
collectionType = 1;
|
||||
break;
|
||||
case 'show':
|
||||
case PlexMediaType.show:
|
||||
collectionType = 2;
|
||||
break;
|
||||
case 'season':
|
||||
case PlexMediaType.season:
|
||||
collectionType = 3;
|
||||
break;
|
||||
case 'episode':
|
||||
case PlexMediaType.episode:
|
||||
collectionType = 4;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
appLogger.d('Creating collection "$collectionName" with type $collectionType');
|
||||
@@ -951,7 +953,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.collections.removeFromCollection,
|
||||
message: t.collections.removeFromCollectionConfirm(title: metadata.title),
|
||||
message: t.collections.removeFromCollectionConfirm(title: metadata.displayTitle),
|
||||
);
|
||||
|
||||
if (!confirmed || !context.mounted) return;
|
||||
@@ -1008,7 +1010,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
Future<void> _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async {
|
||||
final client = _getClientForItem();
|
||||
|
||||
final itemTitle = widget.item.title;
|
||||
final itemTitle = widget.item.displayTitle;
|
||||
final itemTypeLabel = isCollection ? t.collections.collection : t.playlists.playlist;
|
||||
|
||||
// Show confirmation dialog
|
||||
@@ -1110,7 +1112,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: metadata.title),
|
||||
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
|
||||
);
|
||||
|
||||
if (!confirmed || !context.mounted) return;
|
||||
@@ -1264,7 +1266,7 @@ class _CollectionSelectionDialog extends StatelessWidget {
|
||||
final collection = collections[index - 1];
|
||||
return ListTile(
|
||||
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
|
||||
title: Text(collection.title),
|
||||
title: Text(collection.title!),
|
||||
subtitle: collection.childCount != null ? Text('${collection.childCount} items') : null,
|
||||
onTap: () => Navigator.pop(context, collection.ratingKey),
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ class QueueSheet extends StatelessWidget {
|
||||
return FocusableListTile(
|
||||
leading: _buildThumbnail(context, item, isCurrent, isTablet: isTablet),
|
||||
title: Text(
|
||||
item.title,
|
||||
item.title!,
|
||||
style: TextStyle(
|
||||
color: isCurrent ? primaryColor : null,
|
||||
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
|
||||
@@ -139,7 +139,7 @@ class QueueSheet extends StatelessWidget {
|
||||
if (item.year != null) {
|
||||
return item.editionTitle != null ? '${item.year} · ${item.editionTitle}' : '${item.year}';
|
||||
}
|
||||
return item.type;
|
||||
return item.mediaType.name;
|
||||
}
|
||||
|
||||
static dynamic _tryGetClient(BuildContext context, PlexMetadata item) {
|
||||
|
||||
@@ -474,7 +474,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34),
|
||||
)
|
||||
: null,
|
||||
title: item.title,
|
||||
title: item.title!,
|
||||
subtitle: _buildQueueSubtitle(item),
|
||||
onTap: onTap,
|
||||
);
|
||||
@@ -511,7 +511,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
}
|
||||
if (item.grandparentTitle != null) return item.grandparentTitle!;
|
||||
if (item.year != null) return item.editionTitle != null ? '${item.year} · ${item.editionTitle}' : '${item.year}';
|
||||
return item.type;
|
||||
return item.mediaType.name;
|
||||
}
|
||||
|
||||
Widget _buildStripItem({
|
||||
|
||||
@@ -54,14 +54,14 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
|
||||
Widget _buildSingleLineTitle() {
|
||||
// Build single-line title combining series and episode info
|
||||
final seriesName = metadata.grandparentTitle ?? metadata.title;
|
||||
final seriesName = metadata.grandparentTitle ?? metadata.title!;
|
||||
final hasEpisodeInfo = metadata.parentIndex != null && metadata.index != null;
|
||||
|
||||
List<String> parts = [seriesName];
|
||||
|
||||
if (hasEpisodeInfo) {
|
||||
parts.add('S${metadata.parentIndex}E${metadata.index}');
|
||||
parts.add(metadata.title);
|
||||
parts.add(metadata.title!);
|
||||
}
|
||||
|
||||
return Text(
|
||||
@@ -78,7 +78,7 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
if (metadata.parentIndex != null && metadata.index != null) {
|
||||
secondLineParts.add('S${metadata.parentIndex}');
|
||||
secondLineParts.add('E${metadata.index}');
|
||||
secondLineParts.add(metadata.title);
|
||||
secondLineParts.add(metadata.title!);
|
||||
}
|
||||
|
||||
if (metadata.duration != null) {
|
||||
@@ -89,7 +89,7 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
metadata.grandparentTitle ?? metadata.title,
|
||||
metadata.grandparentTitle ?? metadata.title!,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
Reference in New Issue
Block a user