diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 92d54ecd..e4bf2259 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -307,6 +307,8 @@ abstract class MediaServerClient { int? start, int? size, AbortController? abort, + String? libraryId, + String? libraryTitle, }); /// Create a new collection in [libraryId] seeded with [items]. Returns the diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 24c2f47a..f17fc5c8 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -965,9 +965,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // was the same pattern as collectEpisodes*, just inlined. final episodes = []; if (item.isShow) { - await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes); + await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item); } else { - await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes); + await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item); } for (final ep in episodes) { await queueItem(ep); @@ -1022,6 +1022,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin metadataToStore = fullMetadata.copyWith( serverId: metadata.serverId ?? fullMetadata.serverId, serverName: metadata.serverName ?? fullMetadata.serverName, + libraryId: fullMetadata.libraryId ?? metadata.libraryId, + libraryTitle: fullMetadata.libraryTitle ?? metadata.libraryTitle, ); } } catch (e) { @@ -1191,9 +1193,21 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final unwatchedOnly = filter == DownloadFilter.unwatched; final episodes = []; if (container.kind == MediaKind.show) { - await collectEpisodesForShow(client, container.id, unwatchedOnly: unwatchedOnly, out: episodes); + await collectEpisodesForShow( + client, + container.id, + unwatchedOnly: unwatchedOnly, + out: episodes, + fallback: container, + ); } else { - await collectEpisodesForSeason(client, container.id, unwatchedOnly: unwatchedOnly, out: episodes); + await collectEpisodesForSeason( + client, + container.id, + unwatchedOnly: unwatchedOnly, + out: episodes, + fallback: container, + ); } int count = 0; diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 5b15432e..1513c9eb 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -17,6 +17,7 @@ import '../i18n/strings.g.dart'; import 'base_media_list_detail_screen.dart'; import 'focusable_detail_screen_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart'; +import '../services/playlist_items_loader.dart'; /// Screen to display the contents of a collection class CollectionDetailScreen extends StatefulWidget { @@ -59,7 +60,14 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen> fetchPage(int start, int size, AbortController? abort) { - return mediaClient.fetchCollectionPage(widget.collection.id, start: start, size: size, abort: abort); + return mediaClient.fetchCollectionPage( + widget.collection.id, + start: start, + size: size, + abort: abort, + libraryId: widget.collection.libraryId, + libraryTitle: widget.collection.libraryTitle, + ); } @override @@ -147,10 +155,12 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen(); try { - // [fetchChildren] is the neutral equivalent of the Plex-only - // `fetchAllCollectionItemsAsMediaItems` — both backends return the - // collection's full contents. - final allItems = await mediaClient.fetchChildren(widget.collection.id); + final allItems = await fetchAllCollectionItemsPaged( + mediaClient, + widget.collection.id, + libraryId: widget.collection.libraryId, + libraryTitle: widget.collection.libraryTitle, + ); if (!mounted) return; final result = await showCollectionDownloadOptionsAndQueue( context, diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index f0790d6f..d3c336e6 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -116,7 +116,11 @@ class FolderTreeViewState extends State { final client = context.getPlexClientForServer(widget.serverId!); // Items are automatically tagged with server info by PlexClient. - final children = await client.fetchFolderChildren(folderKey); + final children = await client.fetchFolderChildren( + folderKey, + libraryId: folder.libraryId, + libraryTitle: folder.libraryTitle, + ); if (!mounted) return; @@ -162,7 +166,12 @@ class FolderTreeViewState extends State { if (folderKey == null) return; final client = context.getPlexClientForServer(widget.serverId!); final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder(folderKey: folderKey, shuffle: false); + await launcher.launchFromFolder( + folderKey: folderKey, + shuffle: false, + libraryId: folder.libraryId, + libraryTitle: folder.libraryTitle, + ); } Future _handleFolderShuffle(MediaItem folder) async { @@ -170,7 +179,12 @@ class FolderTreeViewState extends State { if (folderKey == null) return; final client = context.getPlexClientForServer(widget.serverId!); final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder(folderKey: folderKey, shuffle: true); + await launcher.launchFromFolder( + folderKey: folderKey, + shuffle: true, + libraryId: folder.libraryId, + libraryTitle: folder.libraryTitle, + ); } bool _isFolder(MediaItem item) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 105d7bc5..cd4dedde 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -117,6 +117,14 @@ class _MediaDetailScreenState extends State final Map> _episodeCache = {}; bool _isLoadingSeasonEpisodes = false; List _seasonTabFocusNodes = []; + + MediaItem _withFallbackLibrary(MediaItem item, MediaItem fallback) { + return item.copyWith( + libraryId: item.libraryId ?? fallback.libraryId, + libraryTitle: item.libraryTitle ?? fallback.libraryTitle, + ); + } + final Map> _seasonContextMenuKeys = {}; final ScrollController _seasonTabsScrollController = ScrollController(); final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode'); @@ -400,15 +408,23 @@ class _MediaDetailScreenState extends State final metadata = result.item; final onDeckEpisode = result.onDeckEpisode; if (metadata != null) { - setStateIfMounted(() { - _fullMetadata = _applyLocalProgress( + final refreshedMetadata = _applyLocalProgress( + _withFallbackLibrary( metadata.copyWith(serverId: serverId, serverName: serverName ?? metadata.serverName), - ); - _onDeckEpisode = onDeckEpisode == null - ? null - : _applyLocalProgress( + _metadata, + ), + ); + final refreshedOnDeck = onDeckEpisode == null + ? null + : _applyLocalProgress( + _withFallbackLibrary( onDeckEpisode.copyWith(serverId: serverId, serverName: serverName ?? onDeckEpisode.serverName), - ); + refreshedMetadata, + ), + ); + setStateIfMounted(() { + _fullMetadata = refreshedMetadata; + _onDeckEpisode = refreshedOnDeck; }); } @@ -417,7 +433,12 @@ class _MediaDetailScreenState extends State _episodeCache.clear(); setStateIfMounted(() { _seasons = seasons - .map((s) => s.copyWith(serverId: serverId, serverName: serverName ?? s.serverName)) + .map( + (s) => _withFallbackLibrary( + s.copyWith(serverId: serverId, serverName: serverName ?? s.serverName), + _metadata, + ), + ) .toList(); }); if (_showEpisodesDirectly) { @@ -1002,14 +1023,20 @@ class _MediaDetailScreenState extends State final serverName = _metadata.serverName; final source = metadata ?? _metadata; final base = _applyLocalProgress( - source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName), + _withFallbackLibrary( + source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName), + _metadata, + ), ); final onDeckWithServerId = onDeckEpisode == null ? null : _applyLocalProgress( - onDeckEpisode.copyWith( - serverId: serverId ?? onDeckEpisode.serverId, - serverName: serverName ?? onDeckEpisode.serverName, + _withFallbackLibrary( + onDeckEpisode.copyWith( + serverId: serverId ?? onDeckEpisode.serverId, + serverName: serverName ?? onDeckEpisode.serverName, + ), + base, ), ); @@ -1081,7 +1108,12 @@ class _MediaDetailScreenState extends State // Preserve serverId for each season. final seasonsWithServerId = seasons - .map((season) => season.copyWith(serverId: serverId, serverName: _metadata.serverName ?? season.serverName)) + .map( + (season) => _withFallbackLibrary( + season.copyWith(serverId: serverId, serverName: _metadata.serverName ?? season.serverName), + _metadata, + ), + ) .toList(); // Plex can override the library season mode per show; Jellyfin falls @@ -1157,6 +1189,8 @@ class _MediaDetailScreenState extends State leafCount: entry.value.length, thumbPath: firstEp.parentThumbPath, parentId: firstEp.grandparentId, + libraryId: firstEp.libraryId ?? _metadata.libraryId, + libraryTitle: firstEp.libraryTitle ?? _metadata.libraryTitle, serverId: _metadata.serverId, serverName: _metadata.serverName, ); @@ -1270,11 +1304,14 @@ class _MediaDetailScreenState extends State final episodes = await mediaClient.fetchChildren(season.id); final episodesWithServerId = episodes .map( - (e) => e.copyWith( - serverId: _metadata.serverId ?? e.serverId, - serverName: _metadata.serverName ?? e.serverName, - grandparentId: _metadata.id, - grandparentTitle: _metadata.title ?? e.grandparentTitle, + (e) => _withFallbackLibrary( + e.copyWith( + serverId: _metadata.serverId ?? e.serverId, + serverName: _metadata.serverName ?? e.serverName, + grandparentId: _metadata.id, + grandparentTitle: _metadata.title ?? e.grandparentTitle, + ), + season.libraryId != null ? season : _metadata, ), ) .map(_applyLocalProgress) @@ -2002,11 +2039,14 @@ class _MediaDetailScreenState extends State : _metadata.title; final enriched = episodes .map( - (e) => e.copyWith( - serverId: serverId, - serverName: _metadata.serverName ?? e.serverName, - grandparentId: e.grandparentId ?? fallbackGrandparentId, - grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle, + (e) => _withFallbackLibrary( + e.copyWith( + serverId: serverId, + serverName: _metadata.serverName ?? e.serverName, + grandparentId: e.grandparentId ?? fallbackGrandparentId, + grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle, + ), + _metadata, ), ) .map(_applyLocalProgress) @@ -2092,6 +2132,8 @@ class _MediaDetailScreenState extends State final episodeWithServerId = firstEpisode.copyWith( serverId: _metadata.serverId ?? firstEpisode.serverId, serverName: _metadata.serverName ?? firstEpisode.serverName, + libraryId: firstEpisode.libraryId ?? _metadata.libraryId, + libraryTitle: firstEpisode.libraryTitle ?? _metadata.libraryTitle, ); if (mounted) { appLogger.d('Playing first episode: ${episodeWithServerId.title}'); diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index 5ea2b743..baf197e3 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -53,11 +53,21 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { showRatingKey: showRatingKey, shuffle: 0, startingEpisodeKey: _currentMetadata.id, + librarySectionID: _currentMetadata.libraryId, + librarySectionTitle: _currentMetadata.libraryTitle, ); if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) { await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey); - playbackState.setPlayQueueWindowFetcher(client.getPlayQueue); + playbackState.setPlayQueueWindowFetcher( + (id, {center, window = 50}) => client.getPlayQueue( + id, + center: center, + window: window, + librarySectionID: _currentMetadata.libraryId, + librarySectionTitle: _currentMetadata.libraryTitle, + ), + ); appLogger.d('Sequential play queue created with ${playQueue.items!.length} items'); } diff --git a/lib/services/jellyfin_client/parts/collections.dart b/lib/services/jellyfin_client/parts/collections.dart index 5e65bd9a..0d3b7420 100644 --- a/lib/services/jellyfin_client/parts/collections.dart +++ b/lib/services/jellyfin_client/parts/collections.dart @@ -50,6 +50,8 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { int? start, int? size, AbortController? abort, + String? libraryId, + String? libraryTitle, }) async { final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId); final s = start ?? 0; diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 6402b1ce..2fa20780 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -106,6 +106,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { actionLabel: t.common.shuffle, execute: (dismissLoading) async { PlayQueueResponse? playQueue; + final sourceLibraryId = facts.isCollection && item is MediaItem ? item.libraryId : null; + final sourceLibraryTitle = facts.isCollection && item is MediaItem ? item.libraryTitle : null; // Plex's `key` param positions the queue's selected item — passed // through when the caller wants playback to start at a specific // entry. Ignored on shuffle (the server picks a random head). @@ -124,6 +126,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { type: 'video', shuffle: shuffle ? 1 : 0, key: selectedKey, + librarySectionID: sourceLibraryId, + librarySectionTitle: sourceLibraryTitle, ); } else { // For playlists, use playlistID parameter @@ -137,7 +141,11 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { // If the queue is empty, try fetching it again with getPlayQueue if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) { - final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID); + final fetchedQueue = await client.getPlayQueue( + playQueue.playQueueID, + librarySectionID: sourceLibraryId, + librarySectionTitle: sourceLibraryTitle, + ); if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) { playQueue = fetchedQueue; } @@ -151,6 +159,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ratingKey: ratingKey, serverId: itemServerId, serverName: itemServerName, + libraryId: sourceLibraryId, + libraryTitle: sourceLibraryTitle, selectedItem: selectedKey != null ? _resolveSelectedMediaItem(playQueue) : null, ); }, @@ -217,7 +227,12 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { showRatingKey = metadata.parentId!; } - final playQueue = await client.createShowPlayQueue(showRatingKey: showRatingKey, shuffle: 1); + final playQueue = await client.createShowPlayQueue( + showRatingKey: showRatingKey, + shuffle: 1, + librarySectionID: metadata.libraryId, + librarySectionTitle: metadata.libraryTitle, + ); // Close loading dialog before navigating to the player await dismissLoading(); @@ -227,6 +242,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ratingKey: showRatingKey, serverId: metadata.serverId ?? serverId, serverName: metadata.serverName ?? serverName, + libraryId: metadata.libraryId, + libraryTitle: metadata.libraryTitle, copyServerInfo: true, ); }, @@ -237,6 +254,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { Future launchFromFolder({ required String folderKey, required bool shuffle, + String? libraryId, + String? libraryTitle, bool showLoadingIndicator = true, }) async { return executeWithLoading( @@ -246,10 +265,20 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { execute: (dismissLoading) async { final folderUri = await client.buildFolderUri(folderKey); - var playQueue = await client.createPlayQueue(uri: folderUri, type: 'video', shuffle: shuffle ? 1 : 0); + var playQueue = await client.createPlayQueue( + uri: folderUri, + type: 'video', + shuffle: shuffle ? 1 : 0, + librarySectionID: libraryId, + librarySectionTitle: libraryTitle, + ); if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) { - final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID); + final fetchedQueue = await client.getPlayQueue( + playQueue.playQueueID, + librarySectionID: libraryId, + librarySectionTitle: libraryTitle, + ); if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) { playQueue = fetchedQueue; } @@ -257,7 +286,14 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { await dismissLoading(); - return _launchFromQueue(playQueue: playQueue, ratingKey: folderKey, serverId: serverId, serverName: serverName); + return _launchFromQueue( + playQueue: playQueue, + ratingKey: folderKey, + serverId: serverId, + serverName: serverName, + libraryId: libraryId, + libraryTitle: libraryTitle, + ); }, ); } @@ -268,6 +304,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { required String ratingKey, String? serverId, String? serverName, + String? libraryId, + String? libraryTitle, MediaItem? selectedItem, bool copyServerInfo = false, }) async { @@ -278,7 +316,17 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { if (!context.mounted) return const PlayQueueError('Context not mounted'); final playbackState = context.read(); - playbackState.setPlayQueueWindowFetcher(client.getPlayQueue); + playbackState.setPlayQueueWindowFetcher( + libraryId == null + ? (id, {center, window = 50}) => client.getPlayQueue(id, center: center, window: window) + : (id, {center, window = 50}) => client.getPlayQueue( + id, + center: center, + window: window, + librarySectionID: libraryId, + librarySectionTitle: libraryTitle, + ), + ); await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey); if (!context.mounted) return const PlayQueueError('Context not mounted'); @@ -286,7 +334,12 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { var itemToPlay = selectedItem ?? playQueue.items!.first; if (copyServerInfo && serverId != null) { - itemToPlay = itemToPlay.copyWith(serverId: serverId, serverName: serverName ?? itemToPlay.serverName); + itemToPlay = itemToPlay.copyWith( + serverId: serverId, + serverName: serverName ?? itemToPlay.serverName, + libraryId: itemToPlay.libraryId ?? libraryId, + libraryTitle: itemToPlay.libraryTitle ?? libraryTitle, + ); } await navigateToVideoPlayer(context, metadata: itemToPlay); diff --git a/lib/services/playlist_items_loader.dart b/lib/services/playlist_items_loader.dart index 0b68dfe7..a682202b 100644 --- a/lib/services/playlist_items_loader.dart +++ b/lib/services/playlist_items_loader.dart @@ -14,3 +14,29 @@ Future> fetchAllPlaylistItems(MediaServerClient client, String p } return all; } + +/// Page through every item in a collection via the backend-neutral client API. +Future> fetchAllCollectionItemsPaged( + MediaServerClient client, + String collectionId, { + int pageSize = 100, + String? libraryId, + String? libraryTitle, +}) async { + final all = []; + var offset = 0; + while (true) { + final page = await client.fetchCollectionPage( + collectionId, + start: offset, + size: pageSize, + libraryId: libraryId, + libraryTitle: libraryTitle, + ); + if (page.items.isEmpty) break; + all.addAll(page.items); + if (all.length >= page.totalCount || page.items.length < pageSize) break; + offset += page.items.length; + } + return all; +} diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index d915efa8..aeb42cbf 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -8,6 +8,7 @@ import '../media/media_item.dart'; import '../utils/global_key_utils.dart'; import '../utils/isolate_helper.dart'; import '../utils/plex_cache_parser.dart'; +import '../utils/plex_library_section_utils.dart'; import 'api_cache.dart'; import 'plex_mappers.dart'; @@ -78,9 +79,21 @@ class PlexApiCache extends ApiCache { @override Future getMetadata(String serverId, String ratingKey) async { final cached = await get(serverId, '/library/metadata/$ratingKey'); + final container = PlexCacheParser.extractMediaContainer(cached); final json = PlexCacheParser.extractFirstMetadata(cached); if (json == null) return null; - return PlexMappers.mediaItemFromCacheJson(json, serverId: serverId); + return PlexMappers.mediaItemFromCacheJson(_withContainerLibrary(json, container), serverId: serverId); + } + + static Map _withContainerLibrary(Map json, Map? container) { + final sectionId = plexLibrarySectionIdFromJson(json) ?? plexLibrarySectionIdFromJson(container); + final sectionTitle = plexLibrarySectionTitleFromJson(json) ?? plexLibrarySectionTitleFromJson(container); + if (sectionId == null && sectionTitle == null) return json; + + final enriched = Map.from(json); + if (sectionId != null) enriched['librarySectionID'] ??= sectionId; + if (sectionTitle != null) enriched['librarySectionTitle'] ??= sectionTitle; + return enriched; } /// Persist a watched/unwatched flip into the cached metadata JSON. Mirrors @@ -135,10 +148,11 @@ class PlexApiCache extends ApiCache { for (final entry in entries) { try { final data = jsonDecode(entry.data) as Map; + final container = PlexCacheParser.extractMediaContainer(data); final json = PlexCacheParser.extractFirstMetadata(data); if (json == null) continue; result[buildGlobalKey(entry.serverId, entry.id)] = PlexMappers.mediaItemFromCacheJson( - json, + _withContainerLibrary(json, container), serverId: entry.serverId, ); } catch (_) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 9842906c..baed6fa2 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -55,6 +55,7 @@ import '../utils/media_server_retry.dart'; import '../utils/media_server_timeouts.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/plex_cache_parser.dart'; +import '../utils/plex_library_section_utils.dart'; import '../utils/plex_url_helper.dart'; import '../utils/session_identifier.dart' as session_id; import '../utils/watch_state_notifier.dart'; @@ -81,16 +82,27 @@ List _processHubResponse( Map decoded, String serverId, String? serverName, { + int? librarySectionID, + String? librarySectionTitle, bool Function(PlexMetadataDto)? filter, }) { final container = decoded['MediaContainer'] as Map?; if (container == null || container['Hub'] == null) return []; + final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID; + final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle; final itemFilter = filter ?? (PlexMetadataDto item) => ContentTypes.videoTypes.contains(item.type?.toLowerCase()); final hubs = []; for (final hubJson in container['Hub'] as List) { try { - final hub = PlexHubDto.fromJson(hubJson as Map, serverId: serverId, serverName: serverName); + final hubMap = hubJson as Map; + final hubSectionID = _librarySectionIdFromJson(hubMap) ?? containerSectionID; + final hubSectionTitle = _librarySectionTitleFromJson(hubMap) ?? containerSectionTitle; + final hub = _plexHubWithLibrarySection( + PlexHubDto.fromJson(hubMap, serverId: serverId, serverName: serverName), + librarySectionID: hubSectionID, + librarySectionTitle: hubSectionTitle, + ); if (hub.items.isEmpty) continue; final filteredItems = hub.items.where(itemFilter).toList(); @@ -117,6 +129,48 @@ List _processHubResponse( return hubs; } +int? _librarySectionIdFromJson(Map? json) => plexLibrarySectionIdFromJson(json); + +int? _librarySectionIdFromString(String? sectionId) => plexLibrarySectionIdFromString(sectionId); + +String? _librarySectionTitleFromJson(Map? json) => plexLibrarySectionTitleFromJson(json); + +PlexMetadataDto _plexMetadataWithLibrarySection( + PlexMetadataDto metadata, { + int? librarySectionID, + String? librarySectionTitle, +}) { + final nextSectionID = metadata.librarySectionID ?? librarySectionID; + final nextSectionTitle = metadata.librarySectionTitle ?? librarySectionTitle; + if (nextSectionID == metadata.librarySectionID && nextSectionTitle == metadata.librarySectionTitle) { + return metadata; + } + return metadata.copyWith(librarySectionID: nextSectionID, librarySectionTitle: nextSectionTitle); +} + +PlexHubDto _plexHubWithLibrarySection(PlexHubDto hub, {int? librarySectionID, String? librarySectionTitle}) { + if (librarySectionID == null && librarySectionTitle == null) return hub; + return PlexHubDto( + hubKey: hub.hubKey, + title: hub.title, + type: hub.type, + hubIdentifier: hub.hubIdentifier, + size: hub.size, + more: hub.more, + items: hub.items + .map( + (item) => _plexMetadataWithLibrarySection( + item, + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, + ), + ) + .toList(), + serverId: hub.serverId, + serverName: hub.serverName, + ); +} + // PlexStreamType moved to plex_constants.dart to break a would-be circular // import once plex_mappers.dart started referencing the same names. @@ -614,14 +668,54 @@ class PlexClient PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) => metadata.copyWith(serverId: serverId, serverName: serverName); + PlexMetadataDto _tagMetadataWithLibrary( + PlexMetadataDto metadata, { + int? librarySectionID, + String? librarySectionTitle, + }) { + return _plexMetadataWithLibrarySection( + _tagMetadata(metadata), + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, + ); + } + @override PlexMetadataDto _createTaggedMetadata(Map json) => _tagMetadata(PlexMetadataDto.fromJson(json)); + PlexMetadataDto _createTaggedMetadataWithLibrary( + Map json, { + int? librarySectionID, + String? librarySectionTitle, + }) { + return _tagMetadataWithLibrary( + PlexMetadataDto.fromJson(json), + librarySectionID: _librarySectionIdFromJson(json) ?? librarySectionID, + librarySectionTitle: _librarySectionTitleFromJson(json) ?? librarySectionTitle, + ); + } + @override - List _extractMetadataList(MediaServerResponse response) { + List _extractMetadataList(MediaServerResponse response) => _extractMetadataListWithLibrary(response); + + List _extractMetadataListWithLibrary( + MediaServerResponse response, { + int? librarySectionID, + String? librarySectionTitle, + }) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { - return (container['Metadata'] as List).map((json) => _createTaggedMetadata(json)).toList(); + final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID; + final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle; + return (container['Metadata'] as List) + .map( + (json) => _createTaggedMetadataWithLibrary( + json as Map, + librarySectionID: containerSectionID, + librarySectionTitle: containerSectionTitle, + ), + ) + .toList(); } return []; } @@ -745,7 +839,7 @@ class PlexClient if (filters != null) queryParams.addAll(filters); final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all'; final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort); - return _extractLibraryContentResult(response); + return _extractLibraryContentResult(response, librarySectionID: _librarySectionIdFromString(sectionId)); } Map _buildPaginationParams(int? start, int? size) { @@ -755,8 +849,16 @@ class PlexClient return params; } - _LibraryContentResult _extractLibraryContentResult(MediaServerResponse response) { - final items = _extractMetadataList(response); + _LibraryContentResult _extractLibraryContentResult( + MediaServerResponse response, { + int? librarySectionID, + String? librarySectionTitle, + }) { + final items = _extractMetadataListWithLibrary( + response, + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, + ); final container = _getMediaContainer(response); final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length; return _LibraryContentResult(items: items, totalSize: totalSize); @@ -767,16 +869,35 @@ class PlexClient int? start, int? size, AbortController? abort, + int? librarySectionID, + String? librarySectionTitle, }) async { final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort); - return _extractLibraryContentResult(response); + return _extractLibraryContentResult( + response, + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, + ); } /// Parse list of PlexMetadataDto from a cached response List _parseMetadataListFromCachedResponse(Map cached) { + final container = cached['MediaContainer'] is Map + ? cached['MediaContainer'] as Map + : null; + final containerSectionID = _librarySectionIdFromJson(container); + final containerSectionTitle = _librarySectionTitleFromJson(container); final metadataList = PlexCacheParser.extractMetadataList(cached); if (metadataList != null) { - return metadataList.map((json) => _createTaggedMetadata(json)).toList(); + return metadataList + .map( + (json) => _createTaggedMetadataWithLibrary( + json as Map, + librarySectionID: containerSectionID, + librarySectionTitle: containerSectionTitle, + ), + ) + .toList(); } return []; } @@ -840,10 +961,17 @@ class PlexClient PlexMetadataDto? metadata; PlexMetadataDto? onDeckEpisode; + final container = _getMediaContainer(response); + final containerSectionID = _librarySectionIdFromJson(container); + final containerSectionTitle = _librarySectionTitleFromJson(container); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null) { - metadata = _tagMetadata(PlexMetadataDto.fromJsonWithImages(metadataJson)); + metadata = _tagMetadataWithLibrary( + PlexMetadataDto.fromJsonWithImages(metadataJson), + librarySectionID: _librarySectionIdFromJson(metadataJson) ?? containerSectionID, + librarySectionTitle: _librarySectionTitleFromJson(metadataJson) ?? containerSectionTitle, + ); // Check if OnDeck is nested inside Metadata if (metadataJson.containsKey('OnDeck') && metadataJson['OnDeck'] != null) { @@ -853,7 +981,11 @@ class PlexClient if (onDeckData is Map && onDeckData.containsKey('Metadata')) { final onDeckMetadata = onDeckData['Metadata']; if (onDeckMetadata != null) { - onDeckEpisode = _createTaggedMetadata(onDeckMetadata); + onDeckEpisode = _createTaggedMetadataWithLibrary( + onDeckMetadata as Map, + librarySectionID: metadata.librarySectionID ?? containerSectionID, + librarySectionTitle: metadata.librarySectionTitle ?? containerSectionTitle, + ); } } } @@ -878,17 +1010,32 @@ class PlexClient _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData), parseResponse: (response) { + final container = _getMediaContainer(response); final metadataJson = _getFirstMetadataJson(response); - return metadataJson != null ? _tagMetadata(PlexMetadataDto.fromJsonWithImages(metadataJson)) : null; + return metadataJson != null + ? _tagMetadataWithLibrary( + PlexMetadataDto.fromJsonWithImages(metadataJson), + librarySectionID: _librarySectionIdFromJson(metadataJson) ?? _librarySectionIdFromJson(container), + librarySectionTitle: + _librarySectionTitleFromJson(metadataJson) ?? _librarySectionTitleFromJson(container), + ) + : null; }, ); } /// Parse PlexMetadataDto with images from a cached response PlexMetadataDto? _parseMetadataWithImagesFromCachedResponse(Map cached) { + final container = cached['MediaContainer'] is Map + ? cached['MediaContainer'] as Map + : null; final firstMetadata = PlexCacheParser.extractFirstMetadata(cached); if (firstMetadata != null) { - return _tagMetadata(PlexMetadataDto.fromJsonWithImages(firstMetadata)); + return _tagMetadataWithLibrary( + PlexMetadataDto.fromJsonWithImages(firstMetadata), + librarySectionID: _librarySectionIdFromJson(firstMetadata) ?? _librarySectionIdFromJson(container), + librarySectionTitle: _librarySectionTitleFromJson(firstMetadata) ?? _librarySectionTitleFromJson(container), + ); } return null; } @@ -1574,7 +1721,7 @@ class PlexClient /// Get library hubs (recommendations for a specific library section) /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. - Future> _getLibraryHubs(String sectionId, {int limit = 10}) async { + Future> _getLibraryHubs(String sectionId, {int limit = 10, String? libraryName}) async { try { final response = await retryTransientMediaServerCall( operation: 'Plex library hubs', @@ -1590,7 +1737,15 @@ class PlexClient final sid = serverId; final sname = serverName; final data = response.data as Map; - return await tryIsolateRun(() => _processHubResponse(data, sid, sname)); + return await tryIsolateRun( + () => _processHubResponse( + data, + sid, + sname, + librarySectionID: _librarySectionIdFromString(sectionId), + librarySectionTitle: libraryName, + ), + ); } catch (e) { appLogger.e('Failed to get library hubs: $e'); } @@ -1655,8 +1810,9 @@ class PlexClient /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub Future> _getHubContent(String hubKey) async { + final hubSectionID = _librarySectionIdFromString(hubKey); return _wrapListApiCall(() => _http.get(hubKey), (response) { - final allItems = _extractMetadataList(response); + final allItems = _extractMetadataListWithLibrary(response, librarySectionID: hubSectionID); // Filter to only video content (movies, shows, seasons, episodes) return allItems.where((item) { return ContentTypes.videoTypes.contains(item.type?.toLowerCase()); @@ -1996,7 +2152,10 @@ class PlexClient queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize}, ), (response) { - final allItems = _extractMetadataList(response); + final allItems = _extractMetadataListWithLibrary( + response, + librarySectionID: _librarySectionIdFromString(sectionId), + ); // Collections should have type="collection" return allItems.where((item) { return item.type?.toLowerCase() == ContentTypes.collection; @@ -2012,11 +2171,32 @@ class PlexClient int? start, int? size, AbortController? abort, - }) => _fetchPaginatedList('/library/collections/$collectionId/children', start: start, size: size, abort: abort); + String? librarySectionID, + String? librarySectionTitle, + }) => _fetchPaginatedList( + '/library/collections/$collectionId/children', + start: start, + size: size, + abort: abort, + librarySectionID: _librarySectionIdFromString(librarySectionID), + librarySectionTitle: librarySectionTitle, + ); /// Fetch every item in a collection (downloads, sync rules, context-menu shuffle). - Future> _fetchAllCollectionItemsDto(String collectionId) => - _fetchAllPages((start, size, abort) => _getCollectionItems(collectionId, start: start, size: size, abort: abort)); + Future> _fetchAllCollectionItemsDto( + String collectionId, { + String? librarySectionID, + String? librarySectionTitle, + }) => _fetchAllPages( + (start, size, abort) => _getCollectionItems( + collectionId, + start: start, + size: size, + abort: abort, + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, + ), + ); /// Get media featuring a specific person (actor/director), paginated. Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) => @@ -2141,16 +2321,25 @@ class PlexClient /// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with /// MediaItem-typed entries. - PlayQueueResponse _parsePlayQueueResponse(dynamic data) { + PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) { final container = data is Map && data['MediaContainer'] is Map ? data['MediaContainer'] as Map : data as Map; + final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID; + final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle; final metadata = container['Metadata']; List? items; if (metadata is List) { items = [ for (final e in metadata) - if (e is Map) PlexMappers.mediaItem(_createTaggedMetadata(e)), + if (e is Map) + PlexMappers.mediaItem( + _createTaggedMetadataWithLibrary( + e, + librarySectionID: containerSectionID, + librarySectionTitle: containerSectionTitle, + ), + ), ]; } return PlayQueueResponse( @@ -2177,6 +2366,8 @@ class PlexClient int shuffle = 0, int repeat = 0, int continuous = 0, + String? librarySectionID, + String? librarySectionTitle, }) async { try { final queryParams = { @@ -2198,7 +2389,11 @@ class PlexClient final response = await _http.post('/playQueues', queryParameters: queryParams); - return _parsePlayQueueResponse(response.data); + return _parsePlayQueueResponse( + response.data, + librarySectionID: _librarySectionIdFromString(librarySectionID), + librarySectionTitle: librarySectionTitle, + ); } catch (e) { appLogger.e('Failed to create play queue', error: e); return null; @@ -2213,6 +2408,8 @@ class PlexClient int window = 50, int includeBefore = 1, int includeAfter = 1, + String? librarySectionID, + String? librarySectionTitle, }) async { try { final queryParams = { @@ -2227,7 +2424,11 @@ class PlexClient final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams); - return _parsePlayQueueResponse(response.data); + return _parsePlayQueueResponse( + response.data, + librarySectionID: _librarySectionIdFromString(librarySectionID), + librarySectionTitle: librarySectionTitle, + ); } catch (e) { appLogger.e('Failed to get play queue: $e'); return null; @@ -2249,6 +2450,8 @@ class PlexClient required String showRatingKey, int shuffle = 0, String? startingEpisodeKey, + String? librarySectionID, + String? librarySectionTitle, }) async { try { final machineId = config.machineIdentifier ?? await getMachineIdentifier(); @@ -2263,6 +2466,8 @@ class PlexClient shuffle: shuffle, key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null, continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0, + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, ); } catch (e) { appLogger.e('Failed to create show play queue', error: e); @@ -2273,17 +2478,29 @@ class PlexClient /// Extract both Metadata and Directory entries from response /// Folders can come back as either type /// Automatically tags all items with this client's serverId and serverName - List _extractMetadataAndDirectories(MediaServerResponse response) { + List _extractMetadataAndDirectories( + MediaServerResponse response, { + int? librarySectionID, + String? librarySectionTitle, + }) { final List items = []; final container = _getMediaContainer(response); if (container != null) { + final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID; + final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle; // Extract Metadata entries - try full parsing first if (container['Metadata'] != null) { for (final json in container['Metadata'] as List) { try { // Try to parse with full PlexMetadataDto.fromJson first - items.add(_createTaggedMetadata(json)); + items.add( + _createTaggedMetadataWithLibrary( + json as Map, + librarySectionID: containerSectionID, + librarySectionTitle: containerSectionTitle, + ), + ); } catch (e) { // If full parsing fails, use minimal safe parsing appLogger.d('Using minimal parsing for metadata item: $e'); @@ -2297,6 +2514,8 @@ class PlexClient thumb: json['thumb'], art: json['art'], year: json['year'], + librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID, + librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle, serverId: serverId, serverName: serverName, ), @@ -2313,7 +2532,13 @@ class PlexClient for (final json in container['Directory'] as List) { try { // Try to parse as PlexMetadataDto first - items.add(_createTaggedMetadata(json)); + items.add( + _createTaggedMetadataWithLibrary( + json as Map, + librarySectionID: containerSectionID, + librarySectionTitle: containerSectionTitle, + ), + ); } catch (e) { // If that fails, use minimal folder representation try { @@ -2325,6 +2550,8 @@ class PlexClient title: json['title'] ?? 'Untitled', thumb: json['thumb'], art: json['art'], + librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID, + librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle, serverId: serverId, serverName: serverName, ), @@ -2348,7 +2575,7 @@ class PlexClient '/library/sections/$sectionId/folder', queryParameters: {'includeCollections': 0}, ); - return _extractMetadataAndDirectories(response); + return _extractMetadataAndDirectories(response, librarySectionID: _librarySectionIdFromString(sectionId)); } catch (e) { appLogger.e('Failed to get library folders: $e'); return []; @@ -2357,10 +2584,18 @@ class PlexClient /// Get children of a specific folder /// Returns files and subfolders within the given folder - Future> _getFolderChildren(String folderKey) async { + Future> _getFolderChildren( + String folderKey, { + String? librarySectionID, + String? librarySectionTitle, + }) async { try { final response = await _getWithFailover(folderKey); - return _extractMetadataAndDirectories(response); + return _extractMetadataAndDirectories( + response, + librarySectionID: _librarySectionIdFromString(folderKey) ?? _librarySectionIdFromString(librarySectionID), + librarySectionTitle: librarySectionTitle, + ); } catch (e) { appLogger.e('Failed to get folder children: $e'); return []; @@ -3091,7 +3326,7 @@ class PlexClient }) async { // libraryName is unused: Plex's /hubs/sections/{id} returns hubs already // titled per-library (e.g. "Recently Added in Movies"). - final hubs = await _getLibraryHubs(libraryId, limit: limit); + final hubs = await _getLibraryHubs(libraryId, limit: limit, libraryName: libraryName); return hubs.map((h) => PlexMappers.mediaHub(h)).toList(); } @@ -3168,8 +3403,17 @@ class PlexClient int? start, int? size, AbortController? abort, + String? libraryId, + String? libraryTitle, }) async { - final result = await _getCollectionItems(collectionId, start: start, size: size, abort: abort); + final result = await _getCollectionItems( + collectionId, + start: start, + size: size, + abort: abort, + librarySectionID: libraryId, + librarySectionTitle: libraryTitle, + ); return LibraryPage( items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalCount: result.totalSize, @@ -3178,8 +3422,16 @@ class PlexClient } /// Plex-specific: full collection contents across pages. - Future> fetchAllCollectionItemsAsMediaItems(String collectionId) async { - final raw = await _fetchAllCollectionItemsDto(collectionId); + Future> fetchAllCollectionItemsAsMediaItems( + String collectionId, { + String? libraryId, + String? libraryTitle, + }) async { + final raw = await _fetchAllCollectionItemsDto( + collectionId, + librarySectionID: libraryId, + librarySectionTitle: libraryTitle, + ); return raw.map((m) => PlexMappers.mediaItem(m)).toList(); } @@ -3225,8 +3477,8 @@ class PlexClient } /// Plex-specific: contents of a folder (files and subfolders). - Future> fetchFolderChildren(String folderKey) async { - final raw = await _getFolderChildren(folderKey); + Future> fetchFolderChildren(String folderKey, {String? libraryId, String? libraryTitle}) async { + final raw = await _getFolderChildren(folderKey, librarySectionID: libraryId, librarySectionTitle: libraryTitle); return raw.map((m) => PlexMappers.mediaItem(m)).toList(); } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index d428a5fe..0cbc8698 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -446,11 +446,14 @@ class SettingsService extends BaseSharedPreferencesService { static Map defaultKeyboardShortcuts() => _defaultKeyboardShortcuts(); static Map defaultKeyboardHotkeys() => _defaultKeyboardHotkeys(); - /// Null keys bypass the filter so we err on the side of syncing. + /// Unknown libraries are allowed only when no filter is configured. bool isLibraryAllowedForTracker(TrackerService service, String? libraryGlobalKey) { - if (libraryGlobalKey == null) return true; - final inList = read(trackerFilterIdsPref(service)).contains(libraryGlobalKey); + final filterIds = read(trackerFilterIdsPref(service)); final mode = read(trackerFilterModePref(service)); + if (libraryGlobalKey == null) { + return mode == TrackerLibraryFilterMode.blacklist && filterIds.isEmpty; + } + final inList = filterIds.contains(libraryGlobalKey); return mode == TrackerLibraryFilterMode.blacklist ? !inList : inList; } diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index e7fd61bb..6bfec3d2 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -259,10 +259,23 @@ class SyncRuleExecutor { required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, }) async { final fromServer = []; + final sourceMetadata = metadata[rule.globalKey]; if (rule.targetType == ContentTypes.show) { - await collectEpisodesForShow(client, rule.ratingKey, unwatchedOnly: true, out: fromServer); + await collectEpisodesForShow( + client, + rule.ratingKey, + unwatchedOnly: true, + out: fromServer, + fallback: sourceMetadata, + ); } else { - await collectEpisodesForSeason(client, rule.ratingKey, unwatchedOnly: true, out: fromServer); + await collectEpisodesForSeason( + client, + rule.ratingKey, + unwatchedOnly: true, + out: fromServer, + fallback: sourceMetadata, + ); } final unwatchedEpisodes = await _excludeLocallyWatched( @@ -334,7 +347,7 @@ class SyncRuleExecutor { // default limit. Plex collections use a distinct collections endpoint; // Jellyfin's collection page implementation maps to its children API. if (rule.targetType == ContentTypes.collection) { - rootItems = await _fetchAllCollectionItems(client, rule.ratingKey); + rootItems = await _fetchAllCollectionItems(client, rule.ratingKey, source: metadata[rule.globalKey]); } else { rootItems = await _fetchAllPlaylistItems(client, rule.ratingKey); } @@ -399,19 +412,16 @@ class SyncRuleExecutor { /// Page through every item in a collection. Plex requires /// [MediaServerClient.fetchCollectionPage] because collection children live /// under `/library/collections/{id}/children`, not metadata children. - Future> _fetchAllCollectionItems(MediaServerClient client, String collectionId) async { - final all = []; - const pageSize = 100; - var offset = 0; - while (true) { - final page = await client.fetchCollectionPage(collectionId, start: offset, size: pageSize); - if (page.items.isEmpty) break; - all.addAll(page.items); - if (all.length >= page.totalCount || page.items.length < pageSize) break; - offset += page.items.length; - } - return all; - } + Future> _fetchAllCollectionItems( + MediaServerClient client, + String collectionId, { + MediaItem? source, + }) => fetchAllCollectionItemsPaged( + client, + collectionId, + libraryId: source?.libraryId, + libraryTitle: source?.libraryTitle, + ); /// Walks [items] and collects playable movie/episode entries into [out]. /// Shows and seasons are expanded into their episodes; music and nested @@ -429,9 +439,9 @@ class SyncRuleExecutor { if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) break; out.add(item); case MediaKind.show: - await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out); + await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item); case MediaKind.season: - await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: out); + await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item); default: // Skip music, clips, nested collections/playlists, unknown types. break; diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart index ad544983..5e8cfc28 100644 --- a/lib/services/trackers/tracker.dart +++ b/lib/services/trackers/tracker.dart @@ -25,8 +25,8 @@ abstract class Tracker { /// Whether an item in the given library should be scrobbled. Applies the /// per-tracker whitelist/blacklist — callers pass the Plex library - /// `serverId:sectionId` globalKey (null when unknown, in which case the - /// filter is bypassed so we err on the side of syncing). + /// `serverId:sectionId` globalKey. Null is allowed only when no filter is + /// configured for this tracker. bool shouldScrobbleForLibrary(String? libraryGlobalKey); Future markWatched(TrackerContext ctx); diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index b3261c82..079e8522 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -27,6 +27,7 @@ class TrackerCoordinator { /// Resolver persists across episode swaps so back-to-back episodes of the /// same show reuse the cached IDs. Cleared only on profile switch. TrackerIdResolver? _resolver; + String? _activeLibraryGlobalKey; TrackerContext? _ctx; Duration _duration = Duration.zero; @@ -41,8 +42,13 @@ class TrackerCoordinator { if (isLive) return; final mediaType = metadata.kind; if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) return; - if (!_trackers.any((t) => t.canScrobble)) return; + final libraryGlobalKey = metadata.libraryGlobalKey; + if (!_trackers.any((t) => t.canScrobble && t.shouldScrobbleForLibrary(libraryGlobalKey))) { + _reset(); + return; + } + _activeLibraryGlobalKey = libraryGlobalKey; _resolver ??= TrackerIdResolver(client, needsFribb: _anyTrackerNeedsFribb); final ctx = await _buildContext(metadata); if (ctx == null) { @@ -54,7 +60,8 @@ class TrackerCoordinator { _ctx = ctx; } - bool _anyTrackerNeedsFribb() => _trackers.any((t) => t.canScrobble && t.needsFribb); + bool _anyTrackerNeedsFribb() => + _trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(_activeLibraryGlobalKey)); Future stopPlayback() async { final ctx = _ctx; @@ -98,6 +105,7 @@ class TrackerCoordinator { void _reset() { _ctx = null; + _activeLibraryGlobalKey = null; _duration = Duration.zero; _lastPosition = Duration.zero; _thresholdCrossed = false; diff --git a/lib/services/trakt/trakt_sync_queue.dart b/lib/services/trakt/trakt_sync_queue.dart index a3710f55..570f026c 100644 --- a/lib/services/trakt/trakt_sync_queue.dart +++ b/lib/services/trakt/trakt_sync_queue.dart @@ -11,6 +11,7 @@ class TraktSyncQueueItem { final TraktSyncOp op; final String ratingKey; final String serverId; + final String? libraryGlobalKey; final TraktMediaKind kind; final TraktIds ids; @@ -28,6 +29,7 @@ class TraktSyncQueueItem { required this.kind, required this.ids, required this.watchedAtIso, + this.libraryGlobalKey, this.season, this.number, this.attempts = 0, @@ -37,6 +39,7 @@ class TraktSyncQueueItem { op: op, ratingKey: ratingKey, serverId: serverId, + libraryGlobalKey: libraryGlobalKey, kind: kind, ids: ids, watchedAtIso: watchedAtIso, @@ -49,6 +52,7 @@ class TraktSyncQueueItem { 'op': op.name, 'ratingKey': ratingKey, 'serverId': serverId, + if (libraryGlobalKey != null) 'libraryGlobalKey': libraryGlobalKey, 'kind': kind.name, 'ids': ids.toJson(), if (season != null) 'season': season, @@ -61,6 +65,7 @@ class TraktSyncQueueItem { op: TraktSyncOp.fromName(json['op'] as String), ratingKey: json['ratingKey'] as String, serverId: json['serverId'] as String, + libraryGlobalKey: json['libraryGlobalKey'] as String?, kind: TraktMediaKind.fromName(json['kind'] as String), ids: TraktIds.fromJson(json['ids'] as Map), season: (json['season'] as num?)?.toInt(), diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index 4fa319ec..c49e4027 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -114,8 +114,7 @@ class TraktSyncService { final kind = TraktMediaKind.tryFromMediaKindId(event.mediaType); if (kind == null) return; - final settings = SettingsService.instanceOrNull; - if (settings != null && !settings.isLibraryAllowedForTracker(TrackerService.trakt, event.librarySectionGlobalKey)) { + if (!_isLibraryAllowed(event.librarySectionGlobalKey)) { appLogger.d('Trakt sync: library filtered out for ${event.itemId}'); return; } @@ -125,6 +124,7 @@ class TraktSyncService { op: op, ratingKey: event.itemId, serverId: event.serverId, + libraryGlobalKey: event.librarySectionGlobalKey, kind: kind, watchedAtIso: DateTime.now().toUtc().toIso8601String(), ); @@ -134,6 +134,7 @@ class TraktSyncService { required TraktSyncOp op, required String ratingKey, required String serverId, + required String? libraryGlobalKey, required TraktMediaKind kind, required String watchedAtIso, }) async { @@ -178,6 +179,7 @@ class TraktSyncService { op: op, ratingKey: ratingKey, serverId: serverId, + libraryGlobalKey: libraryGlobalKey, kind: kind, ids: ids, season: season, @@ -241,6 +243,10 @@ class TraktSyncService { await _recoverInMemoryFallback(); await _queue.drainWith(_activeUserUuid, (item) async { + if (!_isLibraryAllowed(item.libraryGlobalKey)) { + appLogger.d('Trakt sync: queued library filtered out for ${item.ratingKey}'); + return null; + } if (item.attempts >= TraktSyncQueue.maxAttempts) { appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts'); return null; @@ -273,6 +279,10 @@ class TraktSyncService { } } + bool _isLibraryAllowed(String? libraryGlobalKey) { + return SettingsService.instanceOrNull?.isLibraryAllowedForTracker(TrackerService.trakt, libraryGlobalKey) ?? true; + } + TraktScrobbleRequest _bodyFor(TraktSyncQueueItem item) { return switch (item.kind) { TraktMediaKind.movie => TraktScrobbleRequest.movie(ids: item.ids), diff --git a/lib/utils/episode_collection.dart b/lib/utils/episode_collection.dart index 0bfce6b2..4d440be8 100644 --- a/lib/utils/episode_collection.dart +++ b/lib/utils/episode_collection.dart @@ -18,8 +18,9 @@ Future collectEpisodesForShow( String showRatingKey, { required bool unwatchedOnly, required List out, + MediaItem? fallback, }) { - return _collectPlayable(client, showRatingKey, unwatchedOnly: unwatchedOnly, out: out); + return _collectPlayable(client, showRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback); } /// Collect every episode of a single season into [out] via the same @@ -30,8 +31,9 @@ Future collectEpisodesForSeason( String seasonRatingKey, { required bool unwatchedOnly, required List out, + MediaItem? fallback, }) { - return _collectPlayable(client, seasonRatingKey, unwatchedOnly: unwatchedOnly, out: out); + return _collectPlayable(client, seasonRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback); } Future _collectPlayable( @@ -39,11 +41,22 @@ Future _collectPlayable( String parentId, { required bool unwatchedOnly, required List out, + MediaItem? fallback, }) async { final leaves = await client.fetchPlayableDescendants(parentId); for (final ep in leaves) { if (ep.kind != MediaKind.episode) continue; if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue; - out.add(ep); + out.add(_withFallbackLibrary(ep, fallback)); } } + +MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) { + if (fallback == null) return item; + return item.copyWith( + serverId: item.serverId ?? fallback.serverId, + serverName: item.serverName ?? fallback.serverName, + libraryId: item.libraryId ?? fallback.libraryId, + libraryTitle: item.libraryTitle ?? fallback.libraryTitle, + ); +} diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index 6cea546b..1e4333f8 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -124,6 +124,8 @@ Future navigateToMediaItem( title: mi.grandparentTitle ?? mi.parentTitle ?? mi.displayTitle, thumbPath: mi.grandparentThumbPath ?? mi.parentThumbPath, artPath: mi.grandparentArtPath, + libraryId: mi.libraryId, + libraryTitle: mi.libraryTitle, serverId: mi.serverId, serverName: mi.serverName, ); diff --git a/lib/utils/plex_cache_parser.dart b/lib/utils/plex_cache_parser.dart index a36911d0..a88d1de5 100644 --- a/lib/utils/plex_cache_parser.dart +++ b/lib/utils/plex_cache_parser.dart @@ -4,9 +4,13 @@ class PlexCacheParser { PlexCacheParser._(); + static Map? extractMediaContainer(Map? cached) { + final container = cached?['MediaContainer']; + return container is Map ? container : null; + } + static List? extractMetadataList(Map? cached) { - if (cached == null) return null; - return cached['MediaContainer']?['Metadata'] as List?; + return extractMediaContainer(cached)?['Metadata'] as List?; } static Map? extractFirstMetadata(Map? cached) { diff --git a/lib/utils/plex_library_section_utils.dart b/lib/utils/plex_library_section_utils.dart new file mode 100644 index 00000000..448b9300 --- /dev/null +++ b/lib/utils/plex_library_section_utils.dart @@ -0,0 +1,27 @@ +import 'json_utils.dart'; + +final RegExp plexLibrarySectionPathPattern = RegExp(r'/(?:library|hubs)/sections/(\d+)'); + +int? plexLibrarySectionIdFromJson(Map? json) { + if (json == null) return null; + final direct = flexibleInt(json['librarySectionID']) ?? flexibleInt(json['targetLibrarySectionID']); + if (direct != null) return direct; + + for (final key in const ['librarySectionKey', 'key', 'hubKey']) { + final parsed = plexLibrarySectionIdFromString(json[key]?.toString()); + if (parsed != null) return parsed; + } + return null; +} + +int? plexLibrarySectionIdFromString(String? value) { + if (value == null || value == 'shared') return null; + final direct = int.tryParse(value); + if (direct != null) return direct; + final match = plexLibrarySectionPathPattern.firstMatch(value); + return match == null ? null : int.tryParse(match.group(1)!); +} + +String? plexLibrarySectionTitleFromJson(Map? json) { + return json?['librarySectionTitle']?.toString(); +} diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 9d59d77a..abfd9956 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -63,7 +63,8 @@ class WatchStateEvent with HierarchicalEventMixin { }) : globalKey = buildGlobalKey(serverId, itemId); /// `serverId:librarySectionID`, matching [MediaLibrary.globalKey]. Null when - /// the library section is unknown. + /// the library section is unknown; tracker filters treat unknown as allowed + /// only when no filter is configured. String? get librarySectionGlobalKey => librarySectionID != null ? buildGlobalKey(serverId, librarySectionID!) : null; @override diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 5ef360ae..15fb6741 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -909,6 +909,8 @@ void _navigateToSeason(BuildContext context, MediaItem episode, {bool isOffline title: episode.grandparentTitle ?? episode.displayTitle, thumbPath: episode.grandparentThumbPath, artPath: episode.grandparentArtPath, + libraryId: episode.libraryId, + libraryTitle: episode.libraryTitle, serverId: episode.serverId, serverName: episode.serverName, ); @@ -929,6 +931,8 @@ void _navigateToSeason(BuildContext context, MediaItem episode, {bool isOffline index: episode.parentIndex, parentId: episode.grandparentId, thumbPath: episode.parentThumbPath, + libraryId: episode.libraryId, + libraryTitle: episode.libraryTitle, serverId: episode.serverId, serverName: episode.serverName, ); @@ -956,6 +960,8 @@ void _navigateToDetail(BuildContext context, MediaItem mi, {bool isOffline = fal title: mi.grandparentTitle ?? mi.displayTitle, thumbPath: mi.grandparentThumbPath, artPath: mi.grandparentArtPath, + libraryId: mi.libraryId, + libraryTitle: mi.libraryTitle, serverId: mi.serverId, serverName: mi.serverName, ); @@ -968,6 +974,8 @@ void _navigateToDetail(BuildContext context, MediaItem mi, {bool isOffline = fal title: mi.grandparentTitle ?? mi.parentTitle ?? mi.displayTitle, thumbPath: mi.grandparentThumbPath ?? mi.parentThumbPath, artPath: mi.grandparentArtPath, + libraryId: mi.libraryId, + libraryTitle: mi.libraryTitle, serverId: mi.serverId, serverName: mi.serverName, ); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 6d4a27a5..188cf7f9 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1283,10 +1283,12 @@ class MediaContextMenuState extends State { final client = _getMediaClientForItem(); try { - // [fetchChildren] is the neutral equivalent of the previous Plex-only - // `fetchAllCollectionItemsAsMediaItems` — both backends return the - // collection's contents. - final items = await client.fetchChildren(collection.id); + final items = await fetchAllCollectionItemsPaged( + client, + collection.id, + libraryId: collection.libraryId, + libraryTitle: collection.libraryTitle, + ); if (!context.mounted) return; final result = await showCollectionDownloadOptionsAndQueue( diff --git a/test/services/plex_api_cache_test.dart b/test/services/plex_api_cache_test.dart index c580b703..56d8c3a3 100644 --- a/test/services/plex_api_cache_test.dart +++ b/test/services/plex_api_cache_test.dart @@ -23,8 +23,15 @@ void main() { }); // Helper: minimal Plex MediaContainer payload that PlexCacheParser can parse. - Map mediaContainer({String ratingKey = '42', String title = 'Item'}) => { + Map mediaContainer({ + String ratingKey = '42', + String title = 'Item', + Object? librarySectionID, + String? librarySectionTitle, + }) => { 'MediaContainer': { + 'librarySectionID': ?librarySectionID, + 'librarySectionTitle': ?librarySectionTitle, 'Metadata': [ {'ratingKey': ratingKey, 'title': title, 'type': 'movie'}, ], @@ -260,6 +267,20 @@ void main() { expect(meta.serverId, 'srv'); }); + test('getMetadata preserves hoisted MediaContainer library fields', () async { + await cache.put( + 'srv', + '/library/metadata/42', + mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: '7', librarySectionTitle: 'Movies'), + ); + + final meta = await cache.getMetadata('srv', '42'); + + expect(meta, isNotNull); + expect(meta!.libraryId, '7'); + expect(meta.libraryTitle, 'Movies'); + }); + test('getAllPinnedMetadata returns an empty map when nothing is pinned', () async { await cache.put('srv', '/library/metadata/1', mediaContainer(ratingKey: '1')); // No pin yet. @@ -285,6 +306,20 @@ void main() { expect(result['srv-b:9']!.serverId, 'srv-b'); }); + test('getAllPinnedMetadata preserves hoisted MediaContainer library fields', () async { + await cache.put( + 'srv', + '/library/metadata/42', + mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: 7, librarySectionTitle: 'Movies'), + ); + await cache.pinForOffline('srv', '42'); + + final result = await cache.getAllPinnedMetadata(); + + expect(result['srv:42']!.libraryId, '7'); + expect(result['srv:42']!.libraryTitle, 'Movies'); + }); + test('getAllPinnedMetadata skips rows whose key is not a metadata endpoint', () async { // Insert a pinned row at a non-metadata endpoint via raw insert. await db diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index 3179c155..f996123d 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -156,6 +156,8 @@ void main() { final hubs = await client.fetchLibraryHubs('4', libraryName: 'Movies', limit: 12); expect(hubs, hasLength(1)); + expect(hubs.single.items.single.libraryId, '4'); + expect(hubs.single.items.single.libraryTitle, 'Movies'); expect(client.config.baseUrl, primary); expect(httpClient.requests, hasLength(2)); expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary)); diff --git a/test/services/plex_library_details_test.dart b/test/services/plex_library_details_test.dart index 78222dbd..234c8331 100644 --- a/test/services/plex_library_details_test.dart +++ b/test/services/plex_library_details_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/library_query.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; @@ -71,6 +72,114 @@ void main() { 'random', ]); }); + + test('library content stamps known section when Plex omits librarySectionID on rows', () async { + final client = makeClient((request) async { + if (request.url.path == '/library/sections/7/all') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'size': 1, + 'totalSize': 1, + 'Metadata': [ + {'ratingKey': '42', 'type': 'movie', 'title': 'Library Movie'}, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + addTearDown(client.close); + + final page = await client.fetchLibraryContent('7', const LibraryQuery(limit: 1)); + + expect(page.items.single.id, '42'); + expect(page.items.single.libraryId, '7'); + }); + + test('child metadata inherits hoisted MediaContainer library section', () async { + final client = makeClient((request) async { + if (request.url.path == '/library/metadata/show-1/children') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'librarySectionID': '9', + 'librarySectionTitle': 'TV Shows', + 'Metadata': [ + {'ratingKey': 'season-1', 'type': 'season', 'title': 'Season 1'}, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + addTearDown(client.close); + + final children = await client.fetchChildren('show-1'); + + expect(children.single.libraryId, '9'); + expect(children.single.libraryTitle, 'TV Shows'); + }); + + test('hub content infers library section from /hubs/sections key', () async { + final client = makeClient((request) async { + if (request.url.path == '/hubs/sections/7/recentlyAdded/items') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'size': 1, + 'Metadata': [ + {'ratingKey': '42', 'type': 'movie', 'title': 'Hub Movie'}, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + addTearDown(client.close); + + final items = await client.fetchHubContent('/hubs/sections/7/recentlyAdded/items'); + + expect(items.single.id, '42'); + expect(items.single.libraryId, '7'); + }); + + test('collection page can inherit source collection library section', () async { + final client = makeClient((request) async { + if (request.url.path == '/library/collections/99/children') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'size': 1, + 'totalSize': 1, + 'Metadata': [ + {'ratingKey': '42', 'type': 'movie', 'title': 'Collection Movie'}, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + addTearDown(client.close); + + final page = await client.fetchCollectionPage('99', libraryId: '7', libraryTitle: 'Movies'); + + expect(page.items.single.id, '42'); + expect(page.items.single.libraryId, '7'); + expect(page.items.single.libraryTitle, 'Movies'); + }); } Map _filtersPayload() => { diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart index 3705c348..92eddfc3 100644 --- a/test/services/settings_service_test.dart +++ b/test/services/settings_service_test.dart @@ -94,5 +94,21 @@ void main() { expect(mode.value, TrackerLibraryFilterMode.blacklist); expect(ids.value, isEmpty); }); + + test('tracker library filter only allows unknown libraries when no filter is configured', () async { + final settings = await SettingsService.getInstance(); + final modePref = SettingsService.trackerFilterModePref(TrackerService.trakt); + final idsPref = SettingsService.trackerFilterIdsPref(TrackerService.trakt); + + expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isTrue); + + await settings.write(idsPref, ['server:blocked']); + expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isFalse); + + await settings.write(modePref, TrackerLibraryFilterMode.whitelist); + await settings.write(idsPref, ['server:allowed']); + expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isFalse); + expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, 'server:allowed'), isTrue); + }); }); } diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index 6ad7618b..279a2805 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -377,7 +377,14 @@ class _CollectionPagingClient implements MediaServerClient { } @override - Future> fetchCollectionPage(String collectionId, {int? start, int? size, abort}) async { + Future> fetchCollectionPage( + String collectionId, { + int? start, + int? size, + abort, + String? libraryId, + String? libraryTitle, + }) async { collectionPageCalls.add((start: start, size: size)); expect(collectionId, 'collection-1'); return LibraryPage( diff --git a/test/services/trakt_sync_queue_test.dart b/test/services/trakt_sync_queue_test.dart new file mode 100644 index 00000000..5a5a6280 --- /dev/null +++ b/test/services/trakt_sync_queue_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/trakt/trakt_ids.dart'; +import 'package:plezy/services/trakt/trakt_constants.dart'; +import 'package:plezy/services/trakt/trakt_sync_queue.dart'; + +void main() { + test('TraktSyncQueueItem preserves library context in JSON', () { + const item = TraktSyncQueueItem( + op: TraktSyncOp.add, + ratingKey: 'episode-1', + serverId: 'server-1', + libraryGlobalKey: 'server-1:7', + kind: TraktMediaKind.episode, + ids: TraktIds(tvdb: 123), + watchedAtIso: '2026-05-12T00:00:00.000Z', + season: 1, + number: 2, + ); + + final decoded = TraktSyncQueueItem.fromJson(item.toJson()); + + expect(decoded.libraryGlobalKey, 'server-1:7'); + expect(decoded.incrementAttempts().libraryGlobalKey, 'server-1:7'); + }); +}