1# -*- coding: utf-8 -*- 2import re 3import weakref 4from urllib.parse import quote_plus, urlencode 5from xml.etree import ElementTree 6 7from plexapi import log, utils 8from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported 9 10USER_DONT_RELOAD_FOR_KEYS = set() 11_DONT_RELOAD_FOR_KEYS = {'key', 'session'} 12_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'} 13OPERATORS = { 14 'exact': lambda v, q: v == q, 15 'iexact': lambda v, q: v.lower() == q.lower(), 16 'contains': lambda v, q: q in v, 17 'icontains': lambda v, q: q.lower() in v.lower(), 18 'ne': lambda v, q: v != q, 19 'in': lambda v, q: v in q, 20 'gt': lambda v, q: v > q, 21 'gte': lambda v, q: v >= q, 22 'lt': lambda v, q: v < q, 23 'lte': lambda v, q: v <= q, 24 'startswith': lambda v, q: v.startswith(q), 25 'istartswith': lambda v, q: v.lower().startswith(q), 26 'endswith': lambda v, q: v.endswith(q), 27 'iendswith': lambda v, q: v.lower().endswith(q), 28 'exists': lambda v, q: v is not None if q else v is None, 29 'regex': lambda v, q: re.match(q, v), 30 'iregex': lambda v, q: re.match(q, v, flags=re.IGNORECASE), 31} 32 33 34class PlexObject(object): 35 """ Base class for all Plex objects. 36 37 Parameters: 38 server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional) 39 data (ElementTree): Response from PlexServer used to build this object (optional). 40 initpath (str): Relative path requested when retrieving specified `data` (optional). 41 parent (:class:`~plexapi.base.PlexObject`): The parent object that this object is built from (optional). 42 """ 43 TAG = None # xml element tag 44 TYPE = None # xml element type 45 key = None # plex relative url 46 47 def __init__(self, server, data, initpath=None, parent=None): 48 self._server = server 49 self._data = data 50 self._initpath = initpath or self.key 51 self._parent = weakref.ref(parent) if parent is not None else None 52 self._details_key = None 53 if data is not None: 54 self._loadData(data) 55 self._details_key = self._buildDetailsKey() 56 self._autoReload = False 57 58 def __repr__(self): 59 uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri')) 60 name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value')) 61 return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p]) 62 63 def __setattr__(self, attr, value): 64 # Don't overwrite session specific attr with [] 65 if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []: 66 value = getattr(self, attr, []) 67 68 autoReload = self.__dict__.get('_autoReload') 69 # Don't overwrite an attr with None unless it's a private variable or not auto reload 70 if value is not None or attr.startswith('_') or attr not in self.__dict__ or not autoReload: 71 self.__dict__[attr] = value 72 73 def _clean(self, value): 74 """ Clean attr value for display in __repr__. """ 75 if value: 76 value = str(value).replace('/library/metadata/', '') 77 value = value.replace('/children', '') 78 value = value.replace('/accounts/', '') 79 value = value.replace('/devices/', '') 80 return value.replace(' ', '-')[:20] 81 82 def _buildItem(self, elem, cls=None, initpath=None): 83 """ Factory function to build objects based on registered PLEXOBJECTS. """ 84 # cls is specified, build the object and return 85 initpath = initpath or self._initpath 86 if cls is not None: 87 return cls(self._server, elem, initpath, parent=self) 88 # cls is not specified, try looking it up in PLEXOBJECTS 89 etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type'))) 90 ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag 91 ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag)) 92 # log.debug('Building %s as %s', elem.tag, ecls.__name__) 93 if ecls is not None: 94 return ecls(self._server, elem, initpath) 95 raise UnknownType("Unknown library type <%s type='%s'../>" % (elem.tag, etype)) 96 97 def _buildItemOrNone(self, elem, cls=None, initpath=None): 98 """ Calls :func:`~plexapi.base.PlexObject._buildItem` but returns 99 None if elem is an unknown type. 100 """ 101 try: 102 return self._buildItem(elem, cls, initpath) 103 except UnknownType: 104 return None 105 106 def _buildDetailsKey(self, **kwargs): 107 """ Builds the details key with the XML include parameters. 108 All parameters are included by default with the option to override each parameter 109 or disable each parameter individually by setting it to False or 0. 110 """ 111 details_key = self.key 112 if details_key and hasattr(self, '_INCLUDES'): 113 includes = {} 114 for k, v in self._INCLUDES.items(): 115 value = kwargs.get(k, v) 116 if value not in [False, 0, '0']: 117 includes[k] = 1 if value is True else value 118 if includes: 119 details_key += '?' + urlencode(sorted(includes.items())) 120 return details_key 121 122 def _isChildOf(self, **kwargs): 123 """ Returns True if this object is a child of the given attributes. 124 This will search the parent objects all the way to the top. 125 126 Parameters: 127 **kwargs (dict): The attributes and values to search for in the parent objects. 128 See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`. 129 """ 130 obj = self 131 while obj and obj._parent is not None: 132 obj = obj._parent() 133 if obj and obj._checkAttrs(obj._data, **kwargs): 134 return True 135 return False 136 137 def _manuallyLoadXML(self, xml, cls=None): 138 """ Manually load an XML string as a :class:`~plexapi.base.PlexObject`. 139 140 Parameters: 141 xml (str): The XML string to load. 142 cls (:class:`~plexapi.base.PlexObject`): If you know the class of the 143 items to be fetched, passing this in will help the parser ensure 144 it only returns those items. By default we convert the xml elements 145 with the best guess PlexObjects based on tag and type attrs. 146 """ 147 elem = ElementTree.fromstring(xml) 148 return self._buildItemOrNone(elem, cls) 149 150 def fetchItem(self, ekey, cls=None, **kwargs): 151 """ Load the specified key to find and build the first item with the 152 specified tag and attrs. If no tag or attrs are specified then 153 the first item in the result set is returned. 154 155 Parameters: 156 ekey (str or int): Path in Plex to fetch items from. If an int is passed 157 in, the key will be translated to /library/metadata/<key>. This allows 158 fetching an item only knowing its key-id. 159 cls (:class:`~plexapi.base.PlexObject`): If you know the class of the 160 items to be fetched, passing this in will help the parser ensure 161 it only returns those items. By default we convert the xml elements 162 with the best guess PlexObjects based on tag and type attrs. 163 etag (str): Only fetch items with the specified tag. 164 **kwargs (dict): Optionally add XML attribute to filter the items. 165 See :func:`~plexapi.base.PlexObject.fetchItems` for more details 166 on how this is used. 167 """ 168 if ekey is None: 169 raise BadRequest('ekey was not provided') 170 if isinstance(ekey, int): 171 ekey = '/library/metadata/%s' % ekey 172 for elem in self._server.query(ekey): 173 if self._checkAttrs(elem, **kwargs): 174 return self._buildItem(elem, cls, ekey) 175 clsname = cls.__name__ if cls else 'None' 176 raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) 177 178 def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs): 179 """ Load the specified key to find and build all items with the specified tag 180 and attrs. 181 182 Parameters: 183 ekey (str): API URL path in Plex to fetch items from. 184 cls (:class:`~plexapi.base.PlexObject`): If you know the class of the 185 items to be fetched, passing this in will help the parser ensure 186 it only returns those items. By default we convert the xml elements 187 with the best guess PlexObjects based on tag and type attrs. 188 etag (str): Only fetch items with the specified tag. 189 container_start (None, int): offset to get a subset of the data 190 container_size (None, int): How many items in data 191 **kwargs (dict): Optionally add XML attribute to filter the items. 192 See the details below for more info. 193 194 **Filtering XML Attributes** 195 196 Any XML attribute can be filtered when fetching results. Filtering is done before 197 the Python objects are built to help keep things speedy. For example, passing in 198 ``viewCount=0`` will only return matching items where the view count is ``0``. 199 Note that case matters when specifying attributes. Attributes futher down in the XML 200 tree can be filtered by *prepending* the attribute with each element tag ``Tag__``. 201 202 Examples: 203 204 .. code-block:: python 205 206 fetchItem(ekey, viewCount=0) 207 fetchItem(ekey, contentRating="PG") 208 fetchItem(ekey, Genre__tag="Animation") 209 fetchItem(ekey, Media__videoCodec="h265") 210 fetchItem(ekey, Media__Part__container="mp4) 211 212 Note that because some attribute names are already used as arguments to this 213 function, such as ``tag``, you may still reference the attr tag by prepending an 214 underscore. For example, passing in ``_tag='foobar'`` will return all items where 215 ``tag='foobar'``. 216 217 **Using PlexAPI Operators** 218 219 Optionally, PlexAPI operators can be specified by *appending* it to the end of the 220 attribute for more complex lookups. For example, passing in ``viewCount__gte=0`` 221 will return all items where ``viewCount >= 0``. 222 223 List of Available Operators: 224 225 * ``__contains``: Value contains specified arg. 226 * ``__endswith``: Value ends with specified arg. 227 * ``__exact``: Value matches specified arg. 228 * ``__exists`` (*bool*): Value is or is not present in the attrs. 229 * ``__gt``: Value is greater than specified arg. 230 * ``__gte``: Value is greater than or equal to specified arg. 231 * ``__icontains``: Case insensative value contains specified arg. 232 * ``__iendswith``: Case insensative value ends with specified arg. 233 * ``__iexact``: Case insensative value matches specified arg. 234 * ``__in``: Value is in a specified list or tuple. 235 * ``__iregex``: Case insensative value matches the specified regular expression. 236 * ``__istartswith``: Case insensative value starts with specified arg. 237 * ``__lt``: Value is less than specified arg. 238 * ``__lte``: Value is less than or equal to specified arg. 239 * ``__regex``: Value matches the specified regular expression. 240 * ``__startswith``: Value starts with specified arg. 241 242 Examples: 243 244 .. code-block:: python 245 246 fetchItem(ekey, viewCount__gte=0) 247 fetchItem(ekey, Media__container__in=["mp4", "mkv"]) 248 fetchItem(ekey, guid__iregex=r"(imdb:\/\/|themoviedb:\/\/)") 249 fetchItem(ekey, Media__Part__file__startswith="D:\\Movies") 250 251 """ 252 url_kw = {} 253 if container_start is not None: 254 url_kw["X-Plex-Container-Start"] = container_start 255 if container_size is not None: 256 url_kw["X-Plex-Container-Size"] = container_size 257 258 if ekey is None: 259 raise BadRequest('ekey was not provided') 260 data = self._server.query(ekey, params=url_kw) 261 items = self.findItems(data, cls, ekey, **kwargs) 262 263 librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) 264 if librarySectionID: 265 for item in items: 266 item.librarySectionID = librarySectionID 267 return items 268 269 def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs): 270 """ Load the specified data to find and build all items with the specified tag 271 and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details 272 on how this is used. 273 """ 274 # filter on cls attrs if specified 275 if cls and cls.TAG and 'tag' not in kwargs: 276 kwargs['etag'] = cls.TAG 277 if cls and cls.TYPE and 'type' not in kwargs: 278 kwargs['type'] = cls.TYPE 279 # rtag to iter on a specific root tag 280 if rtag: 281 data = next(data.iter(rtag), []) 282 # loop through all data elements to find matches 283 items = [] 284 for elem in data: 285 if self._checkAttrs(elem, **kwargs): 286 item = self._buildItemOrNone(elem, cls, initpath) 287 if item is not None: 288 items.append(item) 289 return items 290 291 def firstAttr(self, *attrs): 292 """ Return the first attribute in attrs that is not None. """ 293 for attr in attrs: 294 value = getattr(self, attr, None) 295 if value is not None: 296 return value 297 298 def listAttrs(self, data, attr, rtag=None, **kwargs): 299 """ Return a list of values from matching attribute. """ 300 results = [] 301 # rtag to iter on a specific root tag 302 if rtag: 303 data = next(data.iter(rtag), []) 304 for elem in data: 305 kwargs['%s__exists' % attr] = True 306 if self._checkAttrs(elem, **kwargs): 307 results.append(elem.attrib.get(attr)) 308 return results 309 310 def reload(self, key=None, **kwargs): 311 """ Reload the data for this object from self.key. 312 313 Parameters: 314 key (string, optional): Override the key to reload. 315 **kwargs (dict): A dictionary of XML include parameters to exclude or override. 316 All parameters are included by default with the option to override each parameter 317 or disable each parameter individually by setting it to False or 0. 318 See :class:`~plexapi.base.PlexPartialObject` for all the available include parameters. 319 320 Example: 321 322 .. code-block:: python 323 324 from plexapi.server import PlexServer 325 plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx') 326 movie = plex.library.section('Movies').get('Cars') 327 328 # Partial reload of the movie without the `checkFiles` parameter. 329 # Excluding `checkFiles` will prevent the Plex server from reading the 330 # file to check if the file still exists and is accessible. 331 # The movie object will remain as a partial object. 332 movie.reload(checkFiles=False) 333 movie.isPartialObject() # Returns True 334 335 # Full reload of the movie with all include parameters. 336 # The movie object will be a full object. 337 movie.reload() 338 movie.isFullObject() # Returns True 339 340 """ 341 return self._reload(key=key, **kwargs) 342 343 def _reload(self, key=None, _autoReload=False, **kwargs): 344 """ Perform the actual reload. """ 345 details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key 346 key = key or details_key or self.key 347 if not key: 348 raise Unsupported('Cannot reload an object not built from a URL.') 349 self._initpath = key 350 data = self._server.query(key) 351 self._autoReload = _autoReload 352 self._loadData(data[0]) 353 self._autoReload = False 354 return self 355 356 def _checkAttrs(self, elem, **kwargs): 357 attrsFound = {} 358 for attr, query in kwargs.items(): 359 attr, op, operator = self._getAttrOperator(attr) 360 values = self._getAttrValue(elem, attr) 361 # special case query in (None, 0, '') to include missing attr 362 if op == 'exact' and not values and query in (None, 0, ''): 363 return True 364 # return if attr were looking for is missing 365 attrsFound[attr] = False 366 for value in values: 367 value = self._castAttrValue(op, query, value) 368 if operator(value, query): 369 attrsFound[attr] = True 370 break 371 # log.debug('Checking %s for %s found: %s', elem.tag, kwargs, attrsFound) 372 return all(attrsFound.values()) 373 374 def _getAttrOperator(self, attr): 375 for op, operator in OPERATORS.items(): 376 if attr.endswith('__%s' % op): 377 attr = attr.rsplit('__', 1)[0] 378 return attr, op, operator 379 # default to exact match 380 return attr, 'exact', OPERATORS['exact'] 381 382 def _getAttrValue(self, elem, attrstr, results=None): 383 # log.debug('Fetching %s in %s', attrstr, elem.tag) 384 parts = attrstr.split('__', 1) 385 attr = parts[0] 386 attrstr = parts[1] if len(parts) == 2 else None 387 if attrstr: 388 results = [] if results is None else results 389 for child in [c for c in elem if c.tag.lower() == attr.lower()]: 390 results += self._getAttrValue(child, attrstr, results) 391 return [r for r in results if r is not None] 392 # check were looking for the tag 393 if attr.lower() == 'etag': 394 return [elem.tag] 395 # loop through attrs so we can perform case-insensative match 396 for _attr, value in elem.attrib.items(): 397 if attr.lower() == _attr.lower(): 398 return [value] 399 return [] 400 401 def _castAttrValue(self, op, query, value): 402 if op == 'exists': 403 return value 404 if isinstance(query, bool): 405 return bool(int(value)) 406 if isinstance(query, int) and '.' in value: 407 return float(value) 408 if isinstance(query, int): 409 return int(value) 410 if isinstance(query, float): 411 return float(value) 412 return value 413 414 def _loadData(self, data): 415 raise NotImplementedError('Abstract method not implemented.') 416 417 418class PlexPartialObject(PlexObject): 419 """ Not all objects in the Plex listings return the complete list of elements 420 for the object. This object will allow you to assume each object is complete, 421 and if the specified value you request is None it will fetch the full object 422 automatically and update itself. 423 """ 424 _INCLUDES = { 425 'checkFiles': 1, 426 'includeAllConcerts': 1, 427 'includeBandwidths': 1, 428 'includeChapters': 1, 429 'includeChildren': 1, 430 'includeConcerts': 1, 431 'includeExternalMedia': 1, 432 'includeExtras': 1, 433 'includeFields': 'thumbBlurHash,artBlurHash', 434 'includeGeolocation': 1, 435 'includeLoudnessRamps': 1, 436 'includeMarkers': 1, 437 'includeOnDeck': 1, 438 'includePopularLeaves': 1, 439 'includePreferences': 1, 440 'includeRelated': 1, 441 'includeRelatedCount': 1, 442 'includeReviews': 1, 443 'includeStations': 1 444 } 445 446 def __eq__(self, other): 447 return other not in [None, []] and self.key == other.key 448 449 def __hash__(self): 450 return hash(repr(self)) 451 452 def __iter__(self): 453 yield self 454 455 def __getattribute__(self, attr): 456 # Dragons inside.. :-/ 457 value = super(PlexPartialObject, self).__getattribute__(attr) 458 # Check a few cases where we dont want to reload 459 if attr in _DONT_RELOAD_FOR_KEYS: return value 460 if attr in _DONT_OVERWRITE_SESSION_KEYS: return value 461 if attr in USER_DONT_RELOAD_FOR_KEYS: return value 462 if attr.startswith('_'): return value 463 if value not in (None, []): return value 464 if self.isFullObject(): return value 465 # Log the reload. 466 clsname = self.__class__.__name__ 467 title = self.__dict__.get('title', self.__dict__.get('name')) 468 objname = "%s '%s'" % (clsname, title) if title else clsname 469 log.debug("Reloading %s for attr '%s'", objname, attr) 470 # Reload and return the value 471 self._reload(_autoReload=True) 472 return super(PlexPartialObject, self).__getattribute__(attr) 473 474 def analyze(self): 475 """ Tell Plex Media Server to performs analysis on it this item to gather 476 information. Analysis includes: 477 478 * Gather Media Properties: All of the media you add to a Library has 479 properties that are useful to know–whether it's a video file, a 480 music track, or one of your photos (container, codec, resolution, etc). 481 * Generate Default Artwork: Artwork will automatically be grabbed from a 482 video file. A background image will be pulled out as well as a 483 smaller image to be used for poster/thumbnail type purposes. 484 * Generate Video Preview Thumbnails: Video preview thumbnails are created, 485 if you have that feature enabled. Video preview thumbnails allow 486 graphical seeking in some Apps. It's also used in the Plex Web App Now 487 Playing screen to show a graphical representation of where playback 488 is. Video preview thumbnails creation is a CPU-intensive process akin 489 to transcoding the file. 490 * Generate intro video markers: Detects show intros, exposing the 491 'Skip Intro' button in clients. 492 """ 493 key = '/%s/analyze' % self.key.lstrip('/') 494 self._server.query(key, method=self._server._session.put) 495 496 def isFullObject(self): 497 """ Returns True if this is already a full object. A full object means all attributes 498 were populated from the api path representing only this item. For example, the 499 search result for a movie often only contain a portion of the attributes a full 500 object (main url) for that movie would contain. 501 """ 502 return not self.key or (self._details_key or self.key) == self._initpath 503 504 def isPartialObject(self): 505 """ Returns True if this is not a full object. """ 506 return not self.isFullObject() 507 508 def _edit(self, **kwargs): 509 """ Actually edit an object. """ 510 if 'id' not in kwargs: 511 kwargs['id'] = self.ratingKey 512 if 'type' not in kwargs: 513 kwargs['type'] = utils.searchType(self.type) 514 515 part = '/library/sections/%s/all?%s' % (self.librarySectionID, 516 urlencode(kwargs)) 517 self._server.query(part, method=self._server._session.put) 518 519 def edit(self, **kwargs): 520 """ Edit an object. 521 522 Parameters: 523 kwargs (dict): Dict of settings to edit. 524 525 Example: 526 {'type': 1, 527 'id': movie.ratingKey, 528 'collection[0].tag.tag': 'Super', 529 'collection.locked': 0} 530 """ 531 self._edit(**kwargs) 532 533 def _edit_tags(self, tag, items, locked=True, remove=False): 534 """ Helper to edit tags. 535 536 Parameters: 537 tag (str): Tag name. 538 items (list): List of tags to add. 539 locked (bool): True to lock the field. 540 remove (bool): True to remove the tags in items. 541 """ 542 if not isinstance(items, list): 543 items = [items] 544 value = getattr(self, utils.tag_plural(tag)) 545 existing_tags = [t.tag for t in value if t and remove is False] 546 tag_edits = utils.tag_helper(tag, existing_tags + items, locked, remove) 547 self.edit(**tag_edits) 548 549 def refresh(self): 550 """ Refreshing a Library or individual item causes the metadata for the item to be 551 refreshed, even if it already has metadata. You can think of refreshing as 552 "update metadata for the requested item even if it already has some". You should 553 refresh a Library or individual item if: 554 555 * You've changed the Library Metadata Agent. 556 * You've added "Local Media Assets" (such as artwork, theme music, external 557 subtitle files, etc.) 558 * You want to freshen the item posters, summary, etc. 559 * There's a problem with the poster image that's been downloaded. 560 * Items are missing posters or other downloaded information. This is possible if 561 the refresh process is interrupted (the Server is turned off, internet 562 connection dies, etc). 563 """ 564 key = '%s/refresh' % self.key 565 self._server.query(key, method=self._server._session.put) 566 567 def section(self): 568 """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ 569 return self._server.library.sectionByID(self.librarySectionID) 570 571 def delete(self): 572 """ Delete a media element. This has to be enabled under settings > server > library in plex webui. """ 573 try: 574 return self._server.query(self.key, method=self._server._session.delete) 575 except BadRequest: # pragma: no cover 576 log.error('Failed to delete %s. This could be because you ' 577 'have not allowed items to be deleted', self.key) 578 raise 579 580 def history(self, maxresults=9999999, mindate=None): 581 """ Get Play History for a media item. 582 583 Parameters: 584 maxresults (int): Only return the specified number of results (optional). 585 mindate (datetime): Min datetime to return results from. 586 """ 587 return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey) 588 589 def _getWebURL(self, base=None): 590 """ Get the Plex Web URL with the correct parameters. 591 Private method to allow overriding parameters from subclasses. 592 """ 593 return self._server._buildWebURL(base=base, endpoint='details', key=self.key) 594 595 def getWebURL(self, base=None): 596 """ Returns the Plex Web URL for a media item. 597 598 Parameters: 599 base (str): The base URL before the fragment (``#!``). 600 Default is https://app.plex.tv/desktop. 601 """ 602 return self._getWebURL(base=base) 603 604 605class Playable(object): 606 """ This is a general place to store functions specific to media that is Playable. 607 Things were getting mixed up a bit when dealing with Shows, Season, Artists, 608 Albums which are all not playable. 609 610 Attributes: 611 sessionKey (int): Active session key. 612 usernames (str): Username of the person playing this item (for active sessions). 613 players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions). 614 session (:class:`~plexapi.media.Session`): Session object, for a playing media file. 615 transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object 616 if item is being transcoded (None otherwise). 617 viewedAt (datetime): Datetime item was last viewed (history). 618 accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. 619 deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. 620 playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items). 621 playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). 622 """ 623 624 def _loadData(self, data): 625 self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session 626 self.usernames = self.listAttrs(data, 'title', etag='User') # session 627 self.players = self.findItems(data, etag='Player') # session 628 self.transcodeSessions = self.findItems(data, etag='TranscodeSession') # session 629 self.session = self.findItems(data, etag='Session') # session 630 self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history 631 self.accountID = utils.cast(int, data.attrib.get('accountID')) # history 632 self.deviceID = utils.cast(int, data.attrib.get('deviceID')) # history 633 self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist 634 self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue 635 636 def getStreamURL(self, **params): 637 """ Returns a stream url that may be used by external applications such as VLC. 638 639 Parameters: 640 **params (dict): optional parameters to manipulate the playback when accessing 641 the stream. A few known parameters include: maxVideoBitrate, videoResolution 642 offset, copyts, protocol, mediaIndex, platform. 643 644 Raises: 645 :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. 646 """ 647 if self.TYPE not in ('movie', 'episode', 'track', 'clip'): 648 raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) 649 mvb = params.get('maxVideoBitrate') 650 vr = params.get('videoResolution', '') 651 params = { 652 'path': self.key, 653 'offset': params.get('offset', 0), 654 'copyts': params.get('copyts', 1), 655 'protocol': params.get('protocol'), 656 'mediaIndex': params.get('mediaIndex', 0), 657 'X-Plex-Platform': params.get('platform', 'Chrome'), 658 'maxVideoBitrate': max(mvb, 64) if mvb else None, 659 'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None 660 } 661 # remove None values 662 params = {k: v for k, v in params.items() if v is not None} 663 streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video' 664 # sort the keys since the randomness fucks with my tests.. 665 sorted_params = sorted(params.items(), key=lambda val: val[0]) 666 return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' % 667 (streamtype, urlencode(sorted_params)), includeToken=True) 668 669 def iterParts(self): 670 """ Iterates over the parts of this media item. """ 671 for item in self.media: 672 for part in item.parts: 673 yield part 674 675 def play(self, client): 676 """ Start playback on the specified client. 677 678 Parameters: 679 client (:class:`~plexapi.client.PlexClient`): Client to start playing on. 680 """ 681 client.playMedia(self) 682 683 def download(self, savepath=None, keep_original_name=False, **kwargs): 684 """ Downloads the media item to the specified location. Returns a list of 685 filepaths that have been saved to disk. 686 687 Parameters: 688 savepath (str): Defaults to current working dir. 689 keep_original_name (bool): True to keep the original filename otherwise 690 a friendlier filename is generated. See filenames below. 691 **kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL` 692 to download a transcoded stream, otherwise the media item will be downloaded 693 as-is and saved to disk. 694 695 **Filenames** 696 697 * Movie: ``<title> (<year>)`` 698 * Episode: ``<show title> - s00e00 - <episode title>`` 699 * Track: ``<artist title> - <album title> - 00 - <track title>`` 700 * Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>`` 701 """ 702 filepaths = [] 703 parts = [i for i in self.iterParts() if i] 704 705 for part in parts: 706 if not keep_original_name: 707 filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container)) 708 else: 709 filename = part.file 710 711 if kwargs: 712 # So this seems to be a alot slower but allows transcode. 713 download_url = self.getStreamURL(**kwargs) 714 else: 715 download_url = self._server.url('%s?download=1' % part.key) 716 717 filepath = utils.download( 718 download_url, 719 self._server._token, 720 filename=filename, 721 savepath=savepath, 722 session=self._server._session 723 ) 724 725 if filepath: 726 filepaths.append(filepath) 727 728 return filepaths 729 730 def stop(self, reason=''): 731 """ Stop playback for a media item. """ 732 key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason)) 733 return self._server.query(key) 734 735 def updateProgress(self, time, state='stopped'): 736 """ Set the watched progress for this video. 737 738 Note that setting the time to 0 will not work. 739 Use `markWatched` or `markUnwatched` to achieve 740 that goal. 741 742 Parameters: 743 time (int): milliseconds watched 744 state (string): state of the video, default 'stopped' 745 """ 746 key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey, 747 time, state) 748 self._server.query(key) 749 self._reload(_autoReload=True) 750 751 def updateTimeline(self, time, state='stopped', duration=None): 752 """ Set the timeline progress for this video. 753 754 Parameters: 755 time (int): milliseconds watched 756 state (string): state of the video, default 'stopped' 757 duration (int): duration of the item 758 """ 759 durationStr = '&duration=' 760 if duration is not None: 761 durationStr = durationStr + str(duration) 762 else: 763 durationStr = durationStr + str(self.duration) 764 key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s' 765 key %= (self.ratingKey, self.key, time, state, durationStr) 766 self._server.query(key) 767 self._reload(_autoReload=True) 768 769 770class MediaContainer(PlexObject): 771 """ Represents a single MediaContainer. 772 773 Attributes: 774 TAG (str): 'MediaContainer' 775 allowSync (int): Sync/Download is allowed/disallowed for feature. 776 augmentationKey (str): API URL (/library/metadata/augmentations/<augmentationKey>). 777 identifier (str): "com.plexapp.plugins.library" 778 librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. 779 librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. 780 librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID. 781 mediaTagPrefix (str): "/system/bundle/media/flags/" 782 mediaTagVersion (int): Unknown 783 size (int): The number of items in the hub. 784 785 """ 786 TAG = 'MediaContainer' 787 788 def _loadData(self, data): 789 self._data = data 790 self.allowSync = utils.cast(int, data.attrib.get('allowSync')) 791 self.augmentationKey = data.attrib.get('augmentationKey') 792 self.identifier = data.attrib.get('identifier') 793 self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) 794 self.librarySectionTitle = data.attrib.get('librarySectionTitle') 795 self.librarySectionUUID = data.attrib.get('librarySectionUUID') 796 self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') 797 self.mediaTagVersion = data.attrib.get('mediaTagVersion') 798 self.size = utils.cast(int, data.attrib.get('size')) 799