From 997f9e93fb11c4b8104686e19fc4fcbf4441f4d1 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Wed, 11 Mar 2026 17:28:29 +0000 Subject: [PATCH 01/19] Ensure data parity for seasons and episodes retrieved by section searchSeasons() and searchEpisodes() methods and Show.seasons() and Season.episodes() methods --- plexapi/video.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index a418908fd..237f68545 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -710,7 +710,7 @@ def season(self, title=None, season=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. """ - key = f'{self.key}/children?excludeAllLeaves=1' + key = f'{self.key}/children?excludeAllLeaves=1&includeGuids=1' if title is not None and not isinstance(title, int): return self.fetchItem(key, Season, title__iexact=title) elif season is not None or isinstance(title, int): @@ -723,7 +723,7 @@ def season(self, title=None, season=None): def seasons(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Season` objects in the show. """ - key = f'{self.key}/children?excludeAllLeaves=1' + key = f'{self.key}/children?excludeAllLeaves=1&includeGuids=1' return self.fetchItems(key, Season, container_size=self.childCount, **kwargs) def episode(self, title=None, season=None, episode=None): @@ -737,7 +737,7 @@ def episode(self, title=None, season=None, episode=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. """ - key = f'{self.key}/allLeaves' + key = f'{self.key}/allLeaves?includeGuids=1' if title is not None: return self.fetchItem(key, Episode, title__iexact=title) elif season is not None and episode is not None: @@ -746,7 +746,7 @@ def episode(self, title=None, season=None, episode=None): def episodes(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ - key = f'{self.key}/allLeaves' + key = f'{self.key}/allLeaves?includeGuids=1' return self.fetchItems(key, Episode, **kwargs) def get(self, title=None, season=None, episode=None): @@ -906,7 +906,7 @@ def episode(self, title=None, episode=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. """ - key = f'{self.key}/children' + key = f'{self.key}/children?includeGuids=1' if title is not None and not isinstance(title, int): return self.fetchItem(key, Episode, title__iexact=title) elif episode is not None or isinstance(title, int): From ba913e5cdf7a7d82ec34ec9bf58285901b1785ec Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Fri, 13 Mar 2026 15:42:56 +0000 Subject: [PATCH 02/19] Extract includeGuids inclusion for parent/child searches into a mixin --- plexapi/mixins/__init__.py | 11 +++++++---- plexapi/mixins/tv_parent_child.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 plexapi/mixins/tv_parent_child.py diff --git a/plexapi/mixins/__init__.py b/plexapi/mixins/__init__.py index 740382eee..a929a4619 100644 --- a/plexapi/mixins/__init__.py +++ b/plexapi/mixins/__init__.py @@ -29,7 +29,7 @@ from .split_merge import SplitMergeMixin from .unmatch_match import UnmatchMatchMixin from .watchlist import WatchlistMixin - +from .tv_parent_child import TvParentChildMixin class MovieEditMixins( ArtLockMixin, PosterLockMixin, ThemeLockMixin, @@ -140,7 +140,8 @@ class ShowMixins( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, ArtMixin, LogoMixin, PosterMixin, SquareArtMixin, ThemeMixin, ShowEditMixins, - WatchlistMixin + WatchlistMixin, + TvParentChildMixin ): pass @@ -148,7 +149,8 @@ class ShowMixins( class SeasonMixins( AdvancedSettingsMixin, ExtrasMixin, RatingMixin, ArtMixin, LogoMixin, PosterMixin, SquareArtMixin, ThemeUrlMixin, - SeasonEditMixins + SeasonEditMixins, + TvParentChildMixin ): pass @@ -156,7 +158,8 @@ class SeasonMixins( class EpisodeMixins( ExtrasMixin, RatingMixin, ArtMixin, LogoMixin, PosterMixin, SquareArtMixin, ThemeUrlMixin, - EpisodeEditMixins + EpisodeEditMixins, + TvParentChildMixin ): pass diff --git a/plexapi/mixins/tv_parent_child.py b/plexapi/mixins/tv_parent_child.py new file mode 100644 index 000000000..33e151d66 --- /dev/null +++ b/plexapi/mixins/tv_parent_child.py @@ -0,0 +1,16 @@ +from plexapi import utils + +class TvParentChildMixin: + """ Mixin for Plex objects that have parent/child relationships (episode/season/show). """ + + def _buildRelationKey(self, key, **kwargs): + """ Returns a key suitable for fetching parent/child TV items """ + args = {} + + args['includeGuids'] = int(bool(kwargs.pop('includeGuids', True))) + for name, value in list(kwargs.items()): + args[name] = value + + params = utils.joinArgs(args).lstrip('?') + + return f"{key}?{params}" \ No newline at end of file From 6d600fe44b0cd5c63e6adee2f6229f61e1b4f953 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Fri, 13 Mar 2026 15:43:39 +0000 Subject: [PATCH 03/19] Implement the key-building mixin for all parent/child retrieval methods --- plexapi/video.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 237f68545..94ca6b3dc 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -710,7 +710,7 @@ def season(self, title=None, season=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. """ - key = f'{self.key}/children?excludeAllLeaves=1&includeGuids=1' + key = self._buildRelationKey(f'{self.key}/children)', excludeAllLeaves=1) if title is not None and not isinstance(title, int): return self.fetchItem(key, Season, title__iexact=title) elif season is not None or isinstance(title, int): @@ -723,7 +723,7 @@ def season(self, title=None, season=None): def seasons(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Season` objects in the show. """ - key = f'{self.key}/children?excludeAllLeaves=1&includeGuids=1' + key = self._buildRelationKey(f'{self.key}/children', excludeAllLeaves=1) return self.fetchItems(key, Season, container_size=self.childCount, **kwargs) def episode(self, title=None, season=None, episode=None): @@ -737,7 +737,7 @@ def episode(self, title=None, season=None, episode=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. """ - key = f'{self.key}/allLeaves?includeGuids=1' + key = self._buildRelationKey(f'{self.key}/allLeaves') if title is not None: return self.fetchItem(key, Episode, title__iexact=title) elif season is not None and episode is not None: @@ -746,7 +746,7 @@ def episode(self, title=None, season=None, episode=None): def episodes(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ - key = f'{self.key}/allLeaves?includeGuids=1' + key = self._buildRelationKey(f'{self.key}/allLeaves') return self.fetchItems(key, Episode, **kwargs) def get(self, title=None, season=None, episode=None): @@ -906,7 +906,7 @@ def episode(self, title=None, episode=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. """ - key = f'{self.key}/children?includeGuids=1' + key = self._buildRelationKey(f'{self.key}/children') if title is not None and not isinstance(title, int): return self.fetchItem(key, Episode, title__iexact=title) elif episode is not None or isinstance(title, int): @@ -919,7 +919,7 @@ def episode(self, title=None, episode=None): def episodes(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ - key = f'{self.key}/children' + key = self._buildRelationKey(f'{self.key}/children') return self.fetchItems(key, Episode, **kwargs) def get(self, title=None, episode=None): @@ -928,7 +928,7 @@ def get(self, title=None, episode=None): def show(self): """ Return the season's :class:`~plexapi.video.Show`. """ - return self.fetchItem(self.parentKey) + return self.fetchItem(self._buildRelationKey(self.parentKey)) def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ @@ -1136,7 +1136,12 @@ def parentThumb(self): def _season(self): """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ if self.grandparentKey and self.parentIndex is not None: - return self.fetchItem(f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}') + key = f'{self.grandparentKey}/children' + params = { + 'excludeAllLeaves': 1, + 'index': self.parentIndex + } + return self.fetchItem(self._buildRelationKey(key, params)) return None def __repr__(self): @@ -1213,11 +1218,11 @@ def hasPreviewThumbnails(self): def season(self): """" Return the episode's :class:`~plexapi.video.Season`. """ - return self.fetchItem(self.parentKey) + return self.fetchItem(self._buildRelationKey(self.parentKey)) def show(self): """" Return the episode's :class:`~plexapi.video.Show`. """ - return self.fetchItem(self.grandparentKey) + return self.fetchItem(self._buildRelationKey(self.grandparentKey)) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ From 31d261bacc8cfc4a934347a76c688612bf3df3fb Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Fri, 13 Mar 2026 20:09:44 +0000 Subject: [PATCH 04/19] Add unit tests for all permutations --- tests/test_video.py | 74 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_video.py b/tests/test_video.py index 24fdac8de..397c1c1ba 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -4,6 +4,7 @@ from urllib.parse import quote_plus import pytest +import plexapi.base from plexapi.exceptions import BadRequest, NotFound from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p @@ -957,6 +958,30 @@ def test_video_Show_isPlayed(show): assert not show.isPlayed +def test_video_Show_season_guids(show): + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.add('guids') + try: + season = show.season("Season 1") + assert season.guids + seasons = show.seasons() + assert len(seasons) > 0 + assert seasons[0].guids + finally: + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') + + +def test_video_Show_episode_guids(show): + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.add('guids') + try: + episode = show.episode("Winter Is Coming") + assert episode.guids + episodes = show.episodes() + assert len(episodes) > 0 + assert episodes[0].guids + finally: + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') + + def test_video_Show_section(show): section = show.section() assert section.title == "TV Shows" @@ -1142,6 +1167,7 @@ def test_video_Season_show(show): season_by_name = show.season("Season 1") assert show.ratingKey == season.parentRatingKey and season_by_name.parentRatingKey assert season.ratingKey == season_by_name.ratingKey + assert season.guids def test_video_Season_watched(show): @@ -1178,6 +1204,29 @@ def test_video_Season_episode(show): def test_video_Season_episodes(show): episodes = show.season("Season 2").episodes() assert len(episodes) >= 1 + + +def test_video_Season_episode_guids(show): + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.add('guids') + try: + season = show.season("Season 1") + episode = season.episode("Winter Is Coming") + assert episode.guids + episodes = season.episodes() + assert len(episodes) > 0 + assert episodes[0].guids + finally: + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') + + +def test_video_Season_show_guids(show): + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.add('guids') + try: + a_show = show.season("Season 1").show() + assert a_show + assert 'tmdb://1399' in [i.id for i in a_show.guids] + finally: + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') @pytest.mark.xfail(reason="Changing images fails randomly") @@ -1250,6 +1299,31 @@ def test_video_Episode(show): show.episode(season=1337, episode=1337) +def test_video_Episode_parent_guids(show): + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.add('guids') + try: + episodes = show.episodes() + assert episodes + episode = episodes[0] + assert episode + assert episode.isPartialObject() + season = episode._season + assert season + assert season.isPartialObject() + assert season.guids + season = episode.season() + assert season + assert season.isPartialObject() + assert season.guids + show = episode.show() + assert show + assert show.isPartialObject() + assert show.guids + finally: + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') + + + def test_video_Episode_hidden_season(episode): assert episode.skipParent is False assert episode.parentRatingKey From 1532d209f2f146bfe6d5f8a7b660e002878393e6 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Fri, 13 Mar 2026 20:10:15 +0000 Subject: [PATCH 05/19] Fix the mistakes the unit tests inevitably show up --- plexapi/video.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 94ca6b3dc..3c0dddf24 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -710,7 +710,7 @@ def season(self, title=None, season=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. """ - key = self._buildRelationKey(f'{self.key}/children)', excludeAllLeaves=1) + key = self._buildRelationKey(f'{self.key}/children', excludeAllLeaves=1) if title is not None and not isinstance(title, int): return self.fetchItem(key, Season, title__iexact=title) elif season is not None or isinstance(title, int): @@ -1136,12 +1136,12 @@ def parentThumb(self): def _season(self): """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ if self.grandparentKey and self.parentIndex is not None: - key = f'{self.grandparentKey}/children' - params = { - 'excludeAllLeaves': 1, - 'index': self.parentIndex - } - return self.fetchItem(self._buildRelationKey(key, params)) + key = self._buildRelationKey( + f'{self.grandparentKey}/children', + excludeAllLeaves=1, + index=self.parentIndex + ) + return self.fetchItem(key) return None def __repr__(self): From 47504f9e1219b85e0f00d257ce6b4408530c90ce Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Fri, 13 Mar 2026 20:33:02 +0000 Subject: [PATCH 06/19] Linting corrections (too many spaces) --- tests/test_video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_video.py b/tests/test_video.py index 397c1c1ba..b41a025f3 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1204,7 +1204,7 @@ def test_video_Season_episode(show): def test_video_Season_episodes(show): episodes = show.season("Season 2").episodes() assert len(episodes) >= 1 - + def test_video_Season_episode_guids(show): plexapi.base.USER_DONT_RELOAD_FOR_KEYS.add('guids') @@ -1224,7 +1224,7 @@ def test_video_Season_show_guids(show): try: a_show = show.season("Season 1").show() assert a_show - assert 'tmdb://1399' in [i.id for i in a_show.guids] + assert 'tmdb://1399' in [i.id for i in a_show.guids] finally: plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') From 67630af21fbc688ca167ca47fad7a3de90cbe622 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Fri, 13 Mar 2026 20:35:29 +0000 Subject: [PATCH 07/19] Linting corrections (trailing whitespace) --- plexapi/video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 3c0dddf24..1bf7be7e8 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -1137,8 +1137,8 @@ def _season(self): """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ if self.grandparentKey and self.parentIndex is not None: key = self._buildRelationKey( - f'{self.grandparentKey}/children', - excludeAllLeaves=1, + f'{self.grandparentKey}/children', + excludeAllLeaves=1, index=self.parentIndex ) return self.fetchItem(key) From 608190b29420958a3b8cc08b9da08438f0fe0d29 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Sat, 14 Mar 2026 08:19:13 +0000 Subject: [PATCH 08/19] More linting issues --- plexapi/mixins/__init__.py | 1 + plexapi/mixins/tv_parent_child.py | 3 ++- tests/test_video.py | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/plexapi/mixins/__init__.py b/plexapi/mixins/__init__.py index a929a4619..8be4e19ca 100644 --- a/plexapi/mixins/__init__.py +++ b/plexapi/mixins/__init__.py @@ -31,6 +31,7 @@ from .watchlist import WatchlistMixin from .tv_parent_child import TvParentChildMixin + class MovieEditMixins( ArtLockMixin, PosterLockMixin, ThemeLockMixin, AddedAtMixin, AudienceRatingMixin, ContentRatingMixin, CriticRatingMixin, EditionTitleMixin, diff --git a/plexapi/mixins/tv_parent_child.py b/plexapi/mixins/tv_parent_child.py index 33e151d66..98c52a66a 100644 --- a/plexapi/mixins/tv_parent_child.py +++ b/plexapi/mixins/tv_parent_child.py @@ -1,5 +1,6 @@ from plexapi import utils + class TvParentChildMixin: """ Mixin for Plex objects that have parent/child relationships (episode/season/show). """ @@ -13,4 +14,4 @@ def _buildRelationKey(self, key, **kwargs): params = utils.joinArgs(args).lstrip('?') - return f"{key}?{params}" \ No newline at end of file + return f"{key}?{params}" diff --git a/tests/test_video.py b/tests/test_video.py index b41a025f3..598011401 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1323,7 +1323,6 @@ def test_video_Episode_parent_guids(show): plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') - def test_video_Episode_hidden_season(episode): assert episode.skipParent is False assert episode.parentRatingKey From 32d7cac0c66dfd38f31d5fb2d06d245cbb0343e1 Mon Sep 17 00:00:00 2001 From: Touchstone64 <57038415+Touchstone64@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:32:48 +0000 Subject: [PATCH 09/19] Preserve fetchItem's failure mode when encountering invalid keys Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plexapi/mixins/tv_parent_child.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plexapi/mixins/tv_parent_child.py b/plexapi/mixins/tv_parent_child.py index 98c52a66a..7dc77a1b8 100644 --- a/plexapi/mixins/tv_parent_child.py +++ b/plexapi/mixins/tv_parent_child.py @@ -6,6 +6,9 @@ class TvParentChildMixin: def _buildRelationKey(self, key, **kwargs): """ Returns a key suitable for fetching parent/child TV items """ + if not key: + return None + args = {} args['includeGuids'] = int(bool(kwargs.pop('includeGuids', True))) From f46d49843614b18cf1c7485e53bf1ba682c79029 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Mon, 16 Mar 2026 10:41:36 +0000 Subject: [PATCH 10/19] Improve Season unit tests to provide partial objects have guids --- tests/test_video.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_video.py b/tests/test_video.py index 14db5cebf..32362b035 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1196,11 +1196,15 @@ def test_video_Season_attrs(show): def test_video_Season_show(show): - season = show.seasons()[0] - season_by_name = show.season("Season 1") - assert show.ratingKey == season.parentRatingKey and season_by_name.parentRatingKey - assert season.ratingKey == season_by_name.ratingKey - assert season.guids + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.add('guids') + try: + season = show.seasons()[0] + season_by_name = show.season("Season 1") + assert show.ratingKey == season.parentRatingKey and season_by_name.parentRatingKey + assert season.ratingKey == season_by_name.ratingKey + assert season.guids + finally: + plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') def test_video_Season_watched(show): From fabbe18892c987816292a6cb2878fc10bf0a1977 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Mon, 16 Mar 2026 11:25:05 +0000 Subject: [PATCH 11/19] Change the relational key builder to explicitly support filters in its parameters --- plexapi/mixins/tv_parent_child.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/plexapi/mixins/tv_parent_child.py b/plexapi/mixins/tv_parent_child.py index 7dc77a1b8..49a5a5488 100644 --- a/plexapi/mixins/tv_parent_child.py +++ b/plexapi/mixins/tv_parent_child.py @@ -5,16 +5,20 @@ class TvParentChildMixin: """ Mixin for Plex objects that have parent/child relationships (episode/season/show). """ def _buildRelationKey(self, key, **kwargs): - """ Returns a key suitable for fetching parent/child TV items """ - if not key: - return None + """ Returns a key suitable for fetching parent/child TV items - args = {} + Parameters: + key (str): The relational key being fetched, such as '/children' (may be + empty). + **kwargs (dict): Custom XML attribute filters to apply to add to the + query. See :func:`~plexapi.base.PlexObject.fetchItems` for more + details on how this is used. - args['includeGuids'] = int(bool(kwargs.pop('includeGuids', True))) - for name, value in list(kwargs.items()): - args[name] = value + """ + if not key: + return None + args = {'includeGuids': 1, **kwargs} params = utils.joinArgs(args).lstrip('?') return f"{key}?{params}" From b96f4211ef720c17b9aea48b2ae778518d4e3126 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Mon, 16 Mar 2026 11:55:01 +0000 Subject: [PATCH 12/19] Correct the implementation of relational key construction in Episode._season(), separating XML attributes from key construction. --- plexapi/mixins/tv_parent_child.py | 12 ++++++------ plexapi/video.py | 25 ++++++++++++------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/plexapi/mixins/tv_parent_child.py b/plexapi/mixins/tv_parent_child.py index 49a5a5488..5d93500dd 100644 --- a/plexapi/mixins/tv_parent_child.py +++ b/plexapi/mixins/tv_parent_child.py @@ -4,15 +4,15 @@ class TvParentChildMixin: """ Mixin for Plex objects that have parent/child relationships (episode/season/show). """ - def _buildRelationKey(self, key, **kwargs): + def _buildRelationalKey(self, key, **kwargs): """ Returns a key suitable for fetching parent/child TV items Parameters: - key (str): The relational key being fetched, such as '/children' (may be - empty). - **kwargs (dict): Custom XML attribute filters to apply to add to the - query. See :func:`~plexapi.base.PlexObject.fetchItems` for more - details on how this is used. + key (str): The relational key to be fetched. + **kwargs (dict): Optional relational selection parameters to apply to the + key, for example 'excludeAllLeaves=1'. Additional options (such as XML + filters) should be passed into search functions. See :func:`~plexapi.base.PlexObject.fetchItems` + for details. """ if not key: diff --git a/plexapi/video.py b/plexapi/video.py index 1bf7be7e8..5c78def3f 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -710,7 +710,7 @@ def season(self, title=None, season=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. """ - key = self._buildRelationKey(f'{self.key}/children', excludeAllLeaves=1) + key = self._buildRelationalKey(f'{self.key}/children', excludeAllLeaves=1) if title is not None and not isinstance(title, int): return self.fetchItem(key, Season, title__iexact=title) elif season is not None or isinstance(title, int): @@ -723,7 +723,7 @@ def season(self, title=None, season=None): def seasons(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Season` objects in the show. """ - key = self._buildRelationKey(f'{self.key}/children', excludeAllLeaves=1) + key = self._buildRelationalKey(f'{self.key}/children', excludeAllLeaves=1) return self.fetchItems(key, Season, container_size=self.childCount, **kwargs) def episode(self, title=None, season=None, episode=None): @@ -737,7 +737,7 @@ def episode(self, title=None, season=None, episode=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. """ - key = self._buildRelationKey(f'{self.key}/allLeaves') + key = self._buildRelationalKey(f'{self.key}/allLeaves') if title is not None: return self.fetchItem(key, Episode, title__iexact=title) elif season is not None and episode is not None: @@ -746,7 +746,7 @@ def episode(self, title=None, season=None, episode=None): def episodes(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ - key = self._buildRelationKey(f'{self.key}/allLeaves') + key = self._buildRelationalKey(f'{self.key}/allLeaves') return self.fetchItems(key, Episode, **kwargs) def get(self, title=None, season=None, episode=None): @@ -906,7 +906,7 @@ def episode(self, title=None, episode=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. """ - key = self._buildRelationKey(f'{self.key}/children') + key = self._buildRelationalKey(f'{self.key}/children') if title is not None and not isinstance(title, int): return self.fetchItem(key, Episode, title__iexact=title) elif episode is not None or isinstance(title, int): @@ -919,7 +919,7 @@ def episode(self, title=None, episode=None): def episodes(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ - key = self._buildRelationKey(f'{self.key}/children') + key = self._buildRelationalKey(f'{self.key}/children') return self.fetchItems(key, Episode, **kwargs) def get(self, title=None, episode=None): @@ -928,7 +928,7 @@ def get(self, title=None, episode=None): def show(self): """ Return the season's :class:`~plexapi.video.Show`. """ - return self.fetchItem(self._buildRelationKey(self.parentKey)) + return self.fetchItem(self._buildRelationalKey(self.parentKey)) def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ @@ -1136,12 +1136,11 @@ def parentThumb(self): def _season(self): """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ if self.grandparentKey and self.parentIndex is not None: - key = self._buildRelationKey( + key = self._buildRelationalKey( f'{self.grandparentKey}/children', - excludeAllLeaves=1, - index=self.parentIndex + excludeAllLeaves=1 ) - return self.fetchItem(key) + return self.fetchItem(key, index=self.parentIndex) return None def __repr__(self): @@ -1218,11 +1217,11 @@ def hasPreviewThumbnails(self): def season(self): """" Return the episode's :class:`~plexapi.video.Season`. """ - return self.fetchItem(self._buildRelationKey(self.parentKey)) + return self.fetchItem(self._buildRelationalKey(self.parentKey)) def show(self): """" Return the episode's :class:`~plexapi.video.Show`. """ - return self.fetchItem(self._buildRelationKey(self.grandparentKey)) + return self.fetchItem(self._buildRelationalKey(self.grandparentKey)) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ From cfebc5ee9051c9208541bfea0347e215d6e02e26 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Mon, 16 Mar 2026 17:11:22 +0000 Subject: [PATCH 13/19] Add the TV parent-child mixin to the collection of composite mixins. --- plexapi/mixins/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/mixins/__init__.py b/plexapi/mixins/__init__.py index 8be4e19ca..df170a2ba 100644 --- a/plexapi/mixins/__init__.py +++ b/plexapi/mixins/__init__.py @@ -266,5 +266,5 @@ class PlaylistMixins( # Composite Mixins 'AlbumMixins', 'ArtistMixins', 'ClipMixins', 'CollectionMixins', 'EpisodeMixins', 'MovieMixins', 'PhotoMixins', 'PhotoalbumMixins', 'PlaylistMixins', - 'SeasonMixins', 'ShowMixins', 'TrackMixins', + 'SeasonMixins', 'ShowMixins', 'TrackMixins', 'TvParentChildMixin' ] From 6563636c18adc3dd426452b24b3f8782c00ebc3c Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Mon, 16 Mar 2026 17:59:33 +0000 Subject: [PATCH 14/19] Improve the statement of intent when building relational keys --- plexapi/mixins/tv_parent_child.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plexapi/mixins/tv_parent_child.py b/plexapi/mixins/tv_parent_child.py index 5d93500dd..47b0b4202 100644 --- a/plexapi/mixins/tv_parent_child.py +++ b/plexapi/mixins/tv_parent_child.py @@ -4,21 +4,21 @@ class TvParentChildMixin: """ Mixin for Plex objects that have parent/child relationships (episode/season/show). """ - def _buildRelationalKey(self, key, **kwargs): + def _buildRelationalKey(self, key, **key_params): """ Returns a key suitable for fetching parent/child TV items Parameters: key (str): The relational key to be fetched. - **kwargs (dict): Optional relational selection parameters to apply to the - key, for example 'excludeAllLeaves=1'. Additional options (such as XML - filters) should be passed into search functions. See :func:`~plexapi.base.PlexObject.fetchItems` + **key_params (dict): Optional query parameters to add to the key, such as + 'excludeAllLeaves=1' or 'index=0'. Additional XML filters should instead + be passed into search functions. See :func:`~plexapi.base.PlexObject.fetchItems` for details. """ if not key: return None - args = {'includeGuids': 1, **kwargs} + args = {'includeGuids': 1, **key_params} params = utils.joinArgs(args).lstrip('?') return f"{key}?{params}" From db9668a9ab3f18e131ccdd7643bfea352ecfde03 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Mon, 16 Mar 2026 18:02:07 +0000 Subject: [PATCH 15/19] Prevent test local variables from shadowing fixtures --- tests/test_video.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_video.py b/tests/test_video.py index 32362b035..c3c778a2b 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1352,10 +1352,10 @@ def test_video_Episode_parent_guids(show): assert season assert season.isPartialObject() assert season.guids - show = episode.show() - assert show - assert show.isPartialObject() - assert show.guids + parent_show = episode.show() + assert parent_show + assert parent_show.isPartialObject() + assert parent_show.guids finally: plexapi.base.USER_DONT_RELOAD_FOR_KEYS.remove('guids') From 88da1e005d8ec5d4ae9c148a0b5a3cd42c5cdf79 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Mon, 16 Mar 2026 18:02:57 +0000 Subject: [PATCH 16/19] Move any parent-child query params into the key-building stage --- plexapi/video.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 5c78def3f..855ba12fd 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -1138,9 +1138,10 @@ def _season(self): if self.grandparentKey and self.parentIndex is not None: key = self._buildRelationalKey( f'{self.grandparentKey}/children', - excludeAllLeaves=1 + excludeAllLeaves=1, + index=self.parentIndex ) - return self.fetchItem(key, index=self.parentIndex) + return self.fetchItem(key) return None def __repr__(self): From 85aa15ba1dbe79ac7e50c7b7ca14748261d82ec6 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Tue, 17 Mar 2026 10:00:25 +0000 Subject: [PATCH 17/19] Move the relational key builder into PlexObject and remove the parent-child mixin. --- plexapi/base.py | 20 ++++++++++++++++++++ plexapi/mixins/__init__.py | 12 ++++-------- plexapi/mixins/tv_parent_child.py | 24 ------------------------ 3 files changed, 24 insertions(+), 32 deletions(-) delete mode 100644 plexapi/mixins/tv_parent_child.py diff --git a/plexapi/base.py b/plexapi/base.py index 65da20c4d..da6272f93 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -176,6 +176,26 @@ def _buildDetailsKey(self, **kwargs): details_key += '?' + urlencode(sorted(params.items())) return details_key + def _buildRelationalKey(self, key, **kwargs): + """ Returns a key suitable for fetching partial objects extended to + include relational information. + + Parameters: + key (str): The relational key to be fetched. + **kwargs (dict): Optional query parameters to add to the key, such as + 'excludeAllLeaves=1' or 'index=0'. Additional XML filters should instead + be passed into search functions. See :func:`~plexapi.base.PlexObject.fetchItems` + for details. + + """ + if not key: + return None + + args = {'includeGuids': 1, **kwargs} + params = utils.joinArgs(args) + + return f"{key}{params}" + def _isChildOf(self, **kwargs): """ Returns True if this object is a child of the given attributes. This will search the parent objects all the way to the top. diff --git a/plexapi/mixins/__init__.py b/plexapi/mixins/__init__.py index df170a2ba..3ad04ec42 100644 --- a/plexapi/mixins/__init__.py +++ b/plexapi/mixins/__init__.py @@ -29,7 +29,6 @@ from .split_merge import SplitMergeMixin from .unmatch_match import UnmatchMatchMixin from .watchlist import WatchlistMixin -from .tv_parent_child import TvParentChildMixin class MovieEditMixins( @@ -141,8 +140,7 @@ class ShowMixins( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, ArtMixin, LogoMixin, PosterMixin, SquareArtMixin, ThemeMixin, ShowEditMixins, - WatchlistMixin, - TvParentChildMixin + WatchlistMixin ): pass @@ -150,8 +148,7 @@ class ShowMixins( class SeasonMixins( AdvancedSettingsMixin, ExtrasMixin, RatingMixin, ArtMixin, LogoMixin, PosterMixin, SquareArtMixin, ThemeUrlMixin, - SeasonEditMixins, - TvParentChildMixin + SeasonEditMixins ): pass @@ -159,8 +156,7 @@ class SeasonMixins( class EpisodeMixins( ExtrasMixin, RatingMixin, ArtMixin, LogoMixin, PosterMixin, SquareArtMixin, ThemeUrlMixin, - EpisodeEditMixins, - TvParentChildMixin + EpisodeEditMixins ): pass @@ -266,5 +262,5 @@ class PlaylistMixins( # Composite Mixins 'AlbumMixins', 'ArtistMixins', 'ClipMixins', 'CollectionMixins', 'EpisodeMixins', 'MovieMixins', 'PhotoMixins', 'PhotoalbumMixins', 'PlaylistMixins', - 'SeasonMixins', 'ShowMixins', 'TrackMixins', 'TvParentChildMixin' + 'SeasonMixins', 'ShowMixins', 'TrackMixins' ] diff --git a/plexapi/mixins/tv_parent_child.py b/plexapi/mixins/tv_parent_child.py deleted file mode 100644 index 47b0b4202..000000000 --- a/plexapi/mixins/tv_parent_child.py +++ /dev/null @@ -1,24 +0,0 @@ -from plexapi import utils - - -class TvParentChildMixin: - """ Mixin for Plex objects that have parent/child relationships (episode/season/show). """ - - def _buildRelationalKey(self, key, **key_params): - """ Returns a key suitable for fetching parent/child TV items - - Parameters: - key (str): The relational key to be fetched. - **key_params (dict): Optional query parameters to add to the key, such as - 'excludeAllLeaves=1' or 'index=0'. Additional XML filters should instead - be passed into search functions. See :func:`~plexapi.base.PlexObject.fetchItems` - for details. - - """ - if not key: - return None - - args = {'includeGuids': 1, **key_params} - params = utils.joinArgs(args).lstrip('?') - - return f"{key}?{params}" From b3dc7a697cd96f835ec2c28677678e3de84fc27e Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Tue, 17 Mar 2026 17:13:07 +0000 Subject: [PATCH 18/19] Restore the trailing comma for Composite Mixins --- plexapi/mixins/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/mixins/__init__.py b/plexapi/mixins/__init__.py index 3ad04ec42..740382eee 100644 --- a/plexapi/mixins/__init__.py +++ b/plexapi/mixins/__init__.py @@ -262,5 +262,5 @@ class PlaylistMixins( # Composite Mixins 'AlbumMixins', 'ArtistMixins', 'ClipMixins', 'CollectionMixins', 'EpisodeMixins', 'MovieMixins', 'PhotoMixins', 'PhotoalbumMixins', 'PlaylistMixins', - 'SeasonMixins', 'ShowMixins', 'TrackMixins' + 'SeasonMixins', 'ShowMixins', 'TrackMixins', ] From 587618f54eab335163297ba7833ad2fc8cd1b2f8 Mon Sep 17 00:00:00 2001 From: Touchstone64 Date: Tue, 17 Mar 2026 17:58:38 +0000 Subject: [PATCH 19/19] Rename the relational key builder to be a query key builder --- plexapi/base.py | 7 +++---- plexapi/video.py | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index da6272f93..adf0c6b08 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -176,12 +176,11 @@ def _buildDetailsKey(self, **kwargs): details_key += '?' + urlencode(sorted(params.items())) return details_key - def _buildRelationalKey(self, key, **kwargs): - """ Returns a key suitable for fetching partial objects extended to - include relational information. + def _buildQueryKey(self, key, **kwargs): + """ Returns a query key suitable for fetching partial objects. Parameters: - key (str): The relational key to be fetched. + key (str): The key to which options should be added to form a query. **kwargs (dict): Optional query parameters to add to the key, such as 'excludeAllLeaves=1' or 'index=0'. Additional XML filters should instead be passed into search functions. See :func:`~plexapi.base.PlexObject.fetchItems` diff --git a/plexapi/video.py b/plexapi/video.py index 855ba12fd..a56b58e5a 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -710,7 +710,7 @@ def season(self, title=None, season=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. """ - key = self._buildRelationalKey(f'{self.key}/children', excludeAllLeaves=1) + key = self._buildQueryKey(f'{self.key}/children', excludeAllLeaves=1) if title is not None and not isinstance(title, int): return self.fetchItem(key, Season, title__iexact=title) elif season is not None or isinstance(title, int): @@ -723,7 +723,7 @@ def season(self, title=None, season=None): def seasons(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Season` objects in the show. """ - key = self._buildRelationalKey(f'{self.key}/children', excludeAllLeaves=1) + key = self._buildQueryKey(f'{self.key}/children', excludeAllLeaves=1) return self.fetchItems(key, Season, container_size=self.childCount, **kwargs) def episode(self, title=None, season=None, episode=None): @@ -737,7 +737,7 @@ def episode(self, title=None, season=None, episode=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. """ - key = self._buildRelationalKey(f'{self.key}/allLeaves') + key = self._buildQueryKey(f'{self.key}/allLeaves') if title is not None: return self.fetchItem(key, Episode, title__iexact=title) elif season is not None and episode is not None: @@ -746,7 +746,7 @@ def episode(self, title=None, season=None, episode=None): def episodes(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ - key = self._buildRelationalKey(f'{self.key}/allLeaves') + key = self._buildQueryKey(f'{self.key}/allLeaves') return self.fetchItems(key, Episode, **kwargs) def get(self, title=None, season=None, episode=None): @@ -906,7 +906,7 @@ def episode(self, title=None, episode=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. """ - key = self._buildRelationalKey(f'{self.key}/children') + key = self._buildQueryKey(f'{self.key}/children') if title is not None and not isinstance(title, int): return self.fetchItem(key, Episode, title__iexact=title) elif episode is not None or isinstance(title, int): @@ -919,7 +919,7 @@ def episode(self, title=None, episode=None): def episodes(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ - key = self._buildRelationalKey(f'{self.key}/children') + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItems(key, Episode, **kwargs) def get(self, title=None, episode=None): @@ -928,7 +928,7 @@ def get(self, title=None, episode=None): def show(self): """ Return the season's :class:`~plexapi.video.Show`. """ - return self.fetchItem(self._buildRelationalKey(self.parentKey)) + return self.fetchItem(self._buildQueryKey(self.parentKey)) def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ @@ -1136,7 +1136,7 @@ def parentThumb(self): def _season(self): """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ if self.grandparentKey and self.parentIndex is not None: - key = self._buildRelationalKey( + key = self._buildQueryKey( f'{self.grandparentKey}/children', excludeAllLeaves=1, index=self.parentIndex @@ -1218,11 +1218,11 @@ def hasPreviewThumbnails(self): def season(self): """" Return the episode's :class:`~plexapi.video.Season`. """ - return self.fetchItem(self._buildRelationalKey(self.parentKey)) + return self.fetchItem(self._buildQueryKey(self.parentKey)) def show(self): """" Return the episode's :class:`~plexapi.video.Show`. """ - return self.fetchItem(self._buildRelationalKey(self.grandparentKey)) + return self.fetchItem(self._buildQueryKey(self.grandparentKey)) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """