From e63986b3f9d27720be237c4a07fa5e1510022028 Mon Sep 17 00:00:00 2001 From: Art Jacobson Date: Wed, 1 Jul 2026 23:12:12 -0500 Subject: [PATCH 1/2] Allow query parameters in LibrarySection.hubs() and add Playlist.centroid * LibrarySection.hubs() now accepts optional query parameters (e.g. count=10, includeMyMixes=1), matching Library.hubs(); includeStations=1 remains the default and may be overridden. * Add Playlist.centroid returning the nested centroid Artist a personalized 'Mix For You' playlist is built around (its only artwork source), guarded against auto-reload on partial objects. --- plexapi/base.py | 2 +- plexapi/library.py | 11 +++++++++-- plexapi/playlist.py | 7 +++++++ tests/payloads.py | 12 ++++++++++++ tests/test_library.py | 7 +++++++ tests/test_playlist.py | 20 ++++++++++++++++++++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 6c6da0230..d6b69d73b 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -16,7 +16,7 @@ MediaContainerT = TypeVar("MediaContainerT", bound="MediaContainer") USER_DONT_RELOAD_FOR_KEYS = set() -_DONT_RELOAD_FOR_KEYS = {'key', 'sourceURI'} +_DONT_RELOAD_FOR_KEYS = {'centroid', 'key', 'sourceURI'} OPERATORS = { 'exact': lambda v, q: v == q, 'iexact': lambda v, q: v.lower() == q.lower(), diff --git a/plexapi/library.py b/plexapi/library.py index da4e80491..b902305e0 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -710,10 +710,17 @@ def resetManagedHubs(self): key = f'/hubs/sections/{self.key}/manage' self._server.query(key, method=self._server._session.delete) - def hubs(self): + def hubs(self, **kwargs): """ Returns a list of available :class:`~plexapi.library.Hub` for this library section. + + Parameters: + **kwargs (dict): Optional query parameters to add to the request + (e.g. ``count=10`` to limit the number of items per hub or + ``includeMyMixes=1`` to include the personalized "Mixes For You" hub). + ``includeStations=1`` is included by default and may be overridden. """ - key = self._buildQueryKey(f'/hubs/sections/{self.key}', includeStations=1) + kwargs.setdefault('includeStations', 1) + key = self._buildQueryKey(f'/hubs/sections/{self.key}', **kwargs) return self.fetchItems(key) def agents(self): diff --git a/plexapi/playlist.py b/plexapi/playlist.py index c42517b16..9fb98d6c3 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -21,6 +21,8 @@ class Playlist( TYPE (str): 'playlist' addedAt (datetime): Datetime the playlist was added to the server. allowSync (bool): True if you allow syncing playlists. + centroid (:class:`~plexapi.audio.Artist`): The centroid artist a personalized + 'Mix For You' playlist is built around, or None for regular playlists. composite (str): URL to composite image (/playlist//composite/) content (str): The filter URI string for smart playlists. duration (int): Duration of the playlist in milliseconds. @@ -76,6 +78,11 @@ def _loadData(self, data): def fields(self): return self.findItems(self._data, media.Field) + @cached_data_property + def centroid(self): + from plexapi.audio import Artist + return self.findItem(self._data, cls=Artist, centroid='1') + def __len__(self): # pragma: no cover return len(self.items()) diff --git a/tests/payloads.py b/tests/payloads.py index 8c9252ffc..9b20cd289 100644 --- a/tests/payloads.py +++ b/tests/payloads.py @@ -44,3 +44,15 @@ """ + +MUSIC_MIXES_HUB = """ + + + + + + + + + +""" diff --git a/tests/test_library.py b/tests/test_library.py index 2cae6b3cb..dae9614d2 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -391,6 +391,13 @@ def test_library_MovieSection_PlexWebURL_hub(plex, movies): assert quote_plus(hub.key) in url +def test_library_MusicSection_hubs_kwargs(music): + hubs = music.hubs(count=5) + assert hubs + for hub in hubs: + assert len(hub._partialItems) <= 5 + + def test_library_ShowSection_all(tvshows): assert len(tvshows.all(title__iexact="The 100")) diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 7c2b1be5a..db1797cd6 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -6,6 +6,7 @@ from . import conftest as utils from . import test_mixins +from .payloads import MUSIC_MIXES_HUB def test_Playlist_attrs(playlist): @@ -31,6 +32,25 @@ def test_Playlist_attrs(playlist): assert playlist.isVideo is True assert playlist.isAudio is False assert playlist.isPhoto is False + assert playlist.centroid is None + + +def test_Playlist_centroid(plex, music, requests_mock): + # 'Mix For You' playlists cannot be generated on the bootstrap server, + # so serve a representative hubs response and parse it through the normal path. + requests_mock.get(plex.url(f"/hubs/sections/{music.key}"), text=MUSIC_MIXES_HUB) + hubs = music.hubs(includeMyMixes=1) + mix = next(h for h in hubs if h.context == "hub.music.mixes").items()[0] + assert mix.smart is True + centroid = mix.centroid + assert centroid.type == "artist" + assert centroid.title == "Centroid Artist" + assert centroid.thumb == "/library/metadata/100/thumb/1" + # accessing centroid on a partial playlist without one must not trigger a reload + other = next(h for h in hubs if h.context == "hub.music.playlists").items()[0] + assert other.isPartialObject() + assert other.centroid is None + assert len(requests_mock.request_history) == 1 def test_Playlist_create(plex, show): From 6cfa7624a12b61d5b4f2c8577e66769673d10ba3 Mon Sep 17 00:00:00 2001 From: Art Jacobson Date: Thu, 2 Jul 2026 11:52:58 -0500 Subject: [PATCH 2/2] Accept booleans for include parameters in LibrarySection.hubs() --- plexapi/library.py | 6 ++++-- tests/test_library.py | 2 ++ tests/test_playlist.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index b902305e0..99f74b34f 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -716,10 +716,12 @@ def hubs(self, **kwargs): Parameters: **kwargs (dict): Optional query parameters to add to the request (e.g. ``count=10`` to limit the number of items per hub or - ``includeMyMixes=1`` to include the personalized "Mixes For You" hub). - ``includeStations=1`` is included by default and may be overridden. + ``includeMyMixes=True`` to include the personalized "Mixes For You" hub). + ``includeStations`` is included by default and may be overridden + with ``includeStations=False``. """ kwargs.setdefault('includeStations', 1) + kwargs = {k: 1 if v is True else 0 if v is False else v for k, v in kwargs.items()} key = self._buildQueryKey(f'/hubs/sections/{self.key}', **kwargs) return self.fetchItems(key) diff --git a/tests/test_library.py b/tests/test_library.py index dae9614d2..a95008ab1 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -396,6 +396,8 @@ def test_library_MusicSection_hubs_kwargs(music): assert hubs for hub in hubs: assert len(hub._partialItems) <= 5 + hubs = music.hubs(includeStations=False) + assert not any(h.context == "hub.music.stations" for h in hubs) def test_library_ShowSection_all(tvshows): diff --git a/tests/test_playlist.py b/tests/test_playlist.py index db1797cd6..41914fb37 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -38,8 +38,8 @@ def test_Playlist_attrs(playlist): def test_Playlist_centroid(plex, music, requests_mock): # 'Mix For You' playlists cannot be generated on the bootstrap server, # so serve a representative hubs response and parse it through the normal path. - requests_mock.get(plex.url(f"/hubs/sections/{music.key}"), text=MUSIC_MIXES_HUB) - hubs = music.hubs(includeMyMixes=1) + requests_mock.get(plex.url(f"/hubs/sections/{music.key}?includeMyMixes=1"), text=MUSIC_MIXES_HUB) + hubs = music.hubs(includeMyMixes=True) mix = next(h for h in hubs if h.context == "hub.music.mixes").items()[0] assert mix.smart is True centroid = mix.centroid