Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
997f9e9
Ensure data parity for seasons and episodes retrieved by section sear…
Mar 11, 2026
ba913e5
Extract includeGuids inclusion for parent/child searches into a mixin
Mar 13, 2026
6d600fe
Implement the key-building mixin for all parent/child retrieval methods
Mar 13, 2026
31d261b
Add unit tests for all permutations
Mar 13, 2026
1532d20
Fix the mistakes the unit tests inevitably show up
Mar 13, 2026
47504f9
Linting corrections (too many spaces)
Mar 13, 2026
67630af
Linting corrections (trailing whitespace)
Mar 13, 2026
608190b
More linting issues
Mar 14, 2026
f794705
Merge branch 'master' into data-parity-for-seasons-and-episodes
Touchstone64 Mar 14, 2026
32d7cac
Preserve fetchItem's failure mode when encountering invalid keys
Touchstone64 Mar 16, 2026
f46d498
Improve Season unit tests to provide partial objects have guids
Mar 16, 2026
fabbe18
Change the relational key builder to explicitly support filters in it…
Mar 16, 2026
b96f421
Correct the implementation of relational key construction in Episode.…
Mar 16, 2026
cfebc5e
Add the TV parent-child mixin to the collection of composite mixins.
Mar 16, 2026
6563636
Improve the statement of intent when building relational keys
Mar 16, 2026
db9668a
Prevent test local variables from shadowing fixtures
Mar 16, 2026
88da1e0
Move any parent-child query params into the key-building stage
Mar 16, 2026
85aa15b
Move the relational key builder into PlexObject and remove the parent…
Mar 17, 2026
b3dc7a6
Restore the trailing comma for Composite Mixins
Mar 17, 2026
587618f
Rename the relational key builder to be a query key builder
Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions plexapi/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,25 @@ def _buildDetailsKey(self, **kwargs):
details_key += '?' + urlencode(sorted(params.items()))
return details_key

def _buildQueryKey(self, key, **kwargs):
""" Returns a query key suitable for fetching partial objects.

Parameters:
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`
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.
Expand Down
25 changes: 15 additions & 10 deletions plexapi/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 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):
Expand All @@ -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 = 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):
Expand All @@ -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 = 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:
Expand All @@ -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 = self._buildQueryKey(f'{self.key}/allLeaves')
return self.fetchItems(key, Episode, **kwargs)

def get(self, title=None, season=None, episode=None):
Expand Down Expand Up @@ -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 = 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):
Expand All @@ -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._buildQueryKey(f'{self.key}/children')
return self.fetchItems(key, Episode, **kwargs)

def get(self, title=None, episode=None):
Expand All @@ -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._buildQueryKey(self.parentKey))

def watched(self):
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
Expand Down Expand Up @@ -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 = self._buildQueryKey(
f'{self.grandparentKey}/children',
excludeAllLeaves=1,
index=self.parentIndex
)
return self.fetchItem(key)
return None

def __repr__(self):
Expand Down Expand Up @@ -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._buildQueryKey(self.parentKey))

def show(self):
"""" Return the episode's :class:`~plexapi.video.Show`. """
return self.fetchItem(self.grandparentKey)
return self.fetchItem(self._buildQueryKey(self.grandparentKey))

def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
Expand Down
85 changes: 81 additions & 4 deletions tests/test_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from urllib.parse import quote_plus

import pytest
import plexapi.base
import plexapi.utils as plexutils
from plexapi.exceptions import BadRequest, NotFound
from plexapi.utils import setDatetimeTimezone
Expand Down Expand Up @@ -990,6 +991,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"
Expand Down Expand Up @@ -1171,10 +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
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):
Expand Down Expand Up @@ -1213,6 +1243,29 @@ def test_video_Season_episodes(show):
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")
def test_video_Season_mixins_images(show):
season = show.season(season=1)
Expand Down Expand Up @@ -1283,6 +1336,30 @@ 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
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')


def test_video_Episode_hidden_season(episode):
assert episode.skipParent is False
assert episode.parentRatingKey
Expand Down
Loading