Refactor fetchItems for pagination (#1143)

* Refactor base fetchItems for pagination

* Use base fetchItems for LibrarySection methods

* Use base fetchItems for MyPlexAccount watchlist

* Use base fetchItems for PlexServer history

* Fix imports
This commit is contained in:
JonnyWong16 2023-05-24 14:50:30 -07:00 committed by GitHub
parent 1082866e70
commit cde1e04495
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 106 additions and 177 deletions

View file

@ -4,7 +4,7 @@ import weakref
from urllib.parse import urlencode
from xml.etree import ElementTree
from plexapi import log, utils
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
from plexapi.utils import cached_property
@ -147,42 +147,7 @@ class PlexObject:
elem = ElementTree.fromstring(xml)
return self._buildItemOrNone(elem, cls)
def fetchItem(self, ekey, cls=None, **kwargs):
""" Load the specified key to find and build the first item with the
specified tag and attrs. If no tag or attrs are specified then
the first item in the result set is returned.
Parameters:
ekey (str or int): Path in Plex to fetch items from. If an int is passed
in, the key will be translated to /library/metadata/<key>. This allows
fetching an item only knowing its key-id.
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
with the best guess PlexObjects based on tag and type attrs.
etag (str): Only fetch items with the specified tag.
**kwargs (dict): Optionally add XML attribute to filter the items.
See :func:`~plexapi.base.PlexObject.fetchItems` for more details
on how this is used.
"""
if ekey is None:
raise BadRequest('ekey was not provided')
if isinstance(ekey, int):
ekey = f'/library/metadata/{ekey}'
data = self._server.query(ekey)
item = self.findItem(data, cls, ekey, **kwargs)
if item:
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
item.librarySectionID = librarySectionID
return item
clsname = cls.__name__ if cls else 'None'
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}')
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, maxresults=None, **kwargs):
""" Load the specified key to find and build all items with the specified tag
and attrs.
@ -195,6 +160,7 @@ class PlexObject:
etag (str): Only fetch items with the specified tag.
container_start (None, int): offset to get a subset of the data
container_size (None, int): How many items in data
maxresults (int, optional): Only return the specified number of results.
**kwargs (dict): Optionally add XML attribute to filter the items.
See the details below for more info.
@ -259,39 +225,77 @@ class PlexObject:
if ekey is None:
raise BadRequest('ekey was not provided')
params = {}
if container_start is not None:
params["X-Plex-Container-Start"] = container_start
if container_size is not None:
params["X-Plex-Container-Size"] = container_size
container_start = container_start or 0
container_size = container_size or X_PLEX_CONTAINER_SIZE
offset = container_start
data = self._server.query(ekey, params=params)
items = self.findItems(data, cls, ekey, **kwargs)
if maxresults is not None:
container_size = min(container_size, maxresults)
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
for item in items:
item.librarySectionID = librarySectionID
return items
results = []
subresults = []
headers = {}
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
""" Load the specified data to find and build the first items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
while True:
headers['X-Plex-Container-Start'] = str(container_start)
headers['X-Plex-Container-Size'] = str(container_size)
data = self._server.query(ekey, headers=headers)
subresults = self.findItems(data, cls, ekey, **kwargs)
total_size = utils.cast(int, data.attrib.get('totalSize') or data.attrib.get('size')) or len(subresults)
if not subresults:
if offset > total_size:
log.info('container_start is greater than the number of items')
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
for item in subresults:
item.librarySectionID = librarySectionID
results.extend(subresults)
wanted_number_of_items = total_size - offset
if maxresults is not None:
wanted_number_of_items = min(maxresults, wanted_number_of_items)
container_size = min(container_size, wanted_number_of_items - len(results))
if wanted_number_of_items <= len(results):
break
container_start += container_size
if container_start > total_size:
break
return results
def fetchItem(self, ekey, cls=None, **kwargs):
""" Load the specified key to find and build the first item with the
specified tag and attrs. If no tag or attrs are specified then
the first item in the result set is returned.
Parameters:
ekey (str or int): Path in Plex to fetch items from. If an int is passed
in, the key will be translated to /library/metadata/<key>. This allows
fetching an item only knowing its key-id.
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
with the best guess PlexObjects based on tag and type attrs.
etag (str): Only fetch items with the specified tag.
**kwargs (dict): Optionally add XML attribute to filter the items.
See :func:`~plexapi.base.PlexObject.fetchItems` for more details
on how this is used.
"""
# filter on cls attrs if specified
if cls and cls.TAG and 'tag' not in kwargs:
kwargs['etag'] = cls.TAG
if cls and cls.TYPE and 'type' not in kwargs:
kwargs['type'] = cls.TYPE
# rtag to iter on a specific root tag
if rtag:
data = next(data.iter(rtag), [])
# loop through all data elements to find matches
for elem in data:
if self._checkAttrs(elem, **kwargs):
item = self._buildItemOrNone(elem, cls, initpath)
return item
if isinstance(ekey, int):
ekey = f'/library/metadata/{ekey}'
try:
return self.fetchItems(ekey, cls, **kwargs)[0]
except IndexError:
clsname = cls.__name__ if cls else 'None'
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') from None
def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
""" Load the specified data to find and build all items with the specified tag
@ -315,6 +319,16 @@ class PlexObject:
items.append(item)
return items
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
""" Load the specified data to find and build the first items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
"""
try:
return self.findItems(data, cls, initpath, rtag, **kwargs)[0]
except IndexError:
return None
def firstAttr(self, *attrs):
""" Return the first attribute in attrs that is not None. """
for attr in attrs:
@ -643,7 +657,7 @@ class PlexPartialObject(PlexObject):
'have not allowed items to be deleted', self.key)
raise
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get Play History for a media item.
Parameters:

View file

@ -3,6 +3,7 @@ import time
from xml.etree import ElementTree
import requests
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported

View file

@ -3,7 +3,7 @@ import re
from datetime import datetime
from urllib.parse import quote_plus, urlencode
from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils
from plexapi import log, media, utils
from plexapi.base import OPERATORS, PlexObject
from plexapi.exceptions import BadRequest, NotFound
from plexapi.settings import Setting
@ -352,7 +352,7 @@ class Library(PlexObject):
part += urlencode(kwargs)
return self._server.query(part, method=self._server._session.post)
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get Play History for all library Sections for the owner.
Parameters:
maxresults (int): Only return the specified number of results (optional).
@ -421,40 +421,6 @@ class LibrarySection(PlexObject):
self._totalDuration = None
self._totalStorage = None
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
""" Load the specified key to find and build all items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
Parameters:
container_start (None, int): offset to get a subset of the data
container_size (None, int): How many items in data
"""
url_kw = {}
if container_start is not None:
url_kw["X-Plex-Container-Start"] = container_start
if container_size is not None:
url_kw["X-Plex-Container-Size"] = container_size
if ekey is None:
raise BadRequest('ekey was not provided')
data = self._server.query(ekey, params=url_kw)
if '/all' in ekey:
# totalSize is only included in the xml response
# if container size is used.
total_size = data.attrib.get("totalSize") or data.attrib.get("size")
self._totalViewSize = utils.cast(int, total_size)
items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
for item in items:
item.librarySectionID = librarySectionID
return items
@cached_property
def totalSize(self):
""" Returns the total number of items in the library for the default library type. """
@ -1268,7 +1234,7 @@ class LibrarySection(PlexObject):
return self._server.search(query, mediatype, limit, sectionId=self.key)
def search(self, title=None, sort=None, maxresults=None, libtype=None,
container_start=0, container_size=X_PLEX_CONTAINER_SIZE, limit=None, filters=None, **kwargs):
container_start=None, container_size=None, limit=None, filters=None, **kwargs):
""" Search the library. The http requests will be batched in container_size. If you are only looking for the
first <num> results, it would be wise to set the maxresults option to that amount so the search doesn't iterate
over all results on the server.
@ -1524,43 +1490,8 @@ class LibrarySection(PlexObject):
"""
key, kwargs = self._buildSearchKey(
title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs)
return self._search(key, maxresults, container_start, container_size, **kwargs)
def _search(self, key, maxresults, container_start, container_size, **kwargs):
""" Perform the actual library search and return the results. """
results = []
subresults = []
offset = container_start
if maxresults is not None:
container_size = min(container_size, maxresults)
while True:
subresults = self.fetchItems(key, container_start=container_start,
container_size=container_size, **kwargs)
if not len(subresults):
if offset > self._totalViewSize:
log.info("container_start is higher than the number of items in the library")
results.extend(subresults)
# self._totalViewSize is not used as a condition in the while loop as
# this require a additional http request.
# self._totalViewSize is updated from self.fetchItems
wanted_number_of_items = self._totalViewSize - offset
if maxresults is not None:
wanted_number_of_items = min(maxresults, wanted_number_of_items)
container_size = min(container_size, maxresults - len(results))
if wanted_number_of_items <= len(results):
break
container_start += container_size
if container_start > self._totalViewSize:
break
return results
return self.fetchItems(
key, container_start=container_start, container_size=container_size, maxresults=maxresults, **kwargs)
def _locations(self):
""" Returns a list of :class:`~plexapi.library.Location` objects
@ -1637,7 +1568,7 @@ class LibrarySection(PlexObject):
return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get Play History for this library Section for the owner.
Parameters:
maxresults (int): Only return the specified number of results (optional).

View file

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit
from plexapi import media, settings, utils

View file

@ -7,8 +7,9 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from xml.etree import ElementTree
import requests
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE,
X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils)
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER,
log, logfilter, utils)
from plexapi.base import PlexObject
from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
@ -786,7 +787,7 @@ class MyPlexAccount(PlexObject):
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
return response.json()['token']
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get Play History for all library sections on all servers for the owner.
Parameters:
@ -819,7 +820,7 @@ class MyPlexAccount(PlexObject):
data = self.query(f'{self.MUSIC}/hubs')
return self.findItems(data)
def watchlist(self, filter=None, sort=None, libtype=None, maxresults=9999999, **kwargs):
def watchlist(self, filter=None, sort=None, libtype=None, maxresults=None, **kwargs):
""" Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist.
Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server,
search for the media using the guid.
@ -859,23 +860,10 @@ class MyPlexAccount(PlexObject):
if libtype:
params['type'] = utils.searchType(libtype)
params['X-Plex-Container-Start'] = 0
params['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
params.update(kwargs)
results, subresults = [], '_init'
while subresults and maxresults > len(results):
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
subresults = self.findItems(data)
results += subresults[:maxresults - len(results)]
params['X-Plex-Container-Start'] += params['X-Plex-Container-Size']
# totalSize is available in first response, update maxresults from it
totalSize = utils.cast(int, data.attrib.get('totalSize'))
if maxresults > totalSize:
maxresults = totalSize
return self._toOnlineMetadata(results, **kwargs)
key = f'{self.METADATA}/library/sections/watchlist/{filter}{utils.joinArgs(params)}'
return self._toOnlineMetadata(self.fetchItems(key, maxresults=maxresults), **kwargs)
def onWatchlist(self, item):
""" Returns True if the item is on the user's watchlist.
@ -1161,7 +1149,7 @@ class MyPlexUser(PlexObject):
raise NotFound(f'Unable to find server {name}')
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get all Play History for a user in all shared servers.
Parameters:
maxresults (int): Only return the specified number of results (optional).
@ -1235,7 +1223,7 @@ class Section(PlexObject):
self.sectionId = self.id # For backwards compatibility
self.sectionKey = self.key # For backwards compatibility
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get all Play History for a user for this section in this shared server.
Parameters:
maxresults (int): Only return the specified number of results (optional).

View file

@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
import os
from urllib.parse import urlencode
from xml.etree import ElementTree
import requests
import os
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log,
logfilter)
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter
from plexapi import utils
from plexapi.alert import AlertListener
from plexapi.base import PlexObject
@ -637,7 +637,7 @@ class PlexServer(PlexObject):
# figure out what method this is..
return self.query(part, method=self._session.put)
def history(self, maxresults=9999999, mindate=None, ratingKey=None, accountID=None, librarySectionID=None):
def history(self, maxresults=None, mindate=None, ratingKey=None, accountID=None, librarySectionID=None):
""" Returns a list of media items from watched history. If there are many results, they will
be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only
looking for the first <num> results, it would be wise to set the maxresults option to that
@ -651,7 +651,6 @@ class PlexServer(PlexObject):
accountID (int/str) Request history for a specific account ID.
librarySectionID (int/str) Request history for a specific library section ID.
"""
results, subresults = [], '_init'
args = {'sort': 'viewedAt:desc'}
if ratingKey:
args['metadataItemID'] = ratingKey
@ -661,14 +660,9 @@ class PlexServer(PlexObject):
args['librarySectionID'] = librarySectionID
if mindate:
args['viewedAt>'] = int(mindate.timestamp())
args['X-Plex-Container-Start'] = 0
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
while subresults and maxresults > len(results):
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
subresults = self.fetchItems(key)
results += subresults[:maxresults - len(results)]
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
return results
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
return self.fetchItems(key, maxresults=maxresults)
def playlists(self, playlistType=None, sectionId=None, title=None, sort=None, **kwargs):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server.

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import requests
from plexapi import CONFIG, X_PLEX_IDENTIFIER
from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest

View file

@ -18,6 +18,7 @@ from urllib.parse import quote
from requests.status_codes import _codes as codes
import requests
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
try: