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