diff --git a/plexapi/base.py b/plexapi/base.py index 96dbad604..b8a52e8aa 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -346,6 +346,16 @@ def _castAttrValue(self, op, query, value): def _loadData(self, data): raise NotImplementedError('Abstract method not implemented.') + def fetchXML(self, ekey): + """ Fetch raw XML for manual parsing. This method helps + by encoding the response to utf-8 and parsing the returned XML into and + ElementTree object. + """ + if ekey is None: + raise BadRequest('ekey was not provided') + return self._server.query(ekey) + + class PlexPartialObject(PlexObject): """ Not all objects in the Plex listings return the complete list of elements @@ -502,7 +512,7 @@ def delete(self): except BadRequest: # pragma: no cover log.error('Failed to delete %s. This could be because you ' 'have not allowed items to be deleted', self.key) - raise + raise BadRequest("Failed to delete %s", self.key) def history(self, maxresults=9999999, mindate=None): """ Get Play History for a media item. diff --git a/plexapi/library.py b/plexapi/library.py index 8d3749a80..e5b166899 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -1230,10 +1230,9 @@ def _loadData(self, data): self.context = data.attrib.get('context') self.hubKey = data.attrib.get('hubKey') self.hubIdentifier = data.attrib.get('hubIdentifier') - self.items = self.findItems(data) self.key = data.attrib.get('key') + self._items = [] self.more = utils.cast(bool, data.attrib.get('more')) - self.size = utils.cast(int, data.attrib.get('size')) self.style = data.attrib.get('style') self.title = data.attrib.get('title') self.type = data.attrib.get('type') @@ -1241,12 +1240,20 @@ def _loadData(self, data): def __len__(self): return self.size + @property + def items(self): + if not self._items: + self._items = self.fetchItems(self.key) + return self._item + + @property + def size(self): + return len(self._items) + def reload(self): - """ Reloads the hub to fetch all items in the hub. """ - if self.more and self.key: - self.items = self.fetchItems(self.key) - self.more = False - self.size = len(self.items) + """ Reloads the hub to fetch new items in the hub. """ + if self.key: + self._items = self.fetchItems(self.key) class HubMediaTag(PlexObject): @@ -1518,6 +1525,7 @@ class FirstCharacter(PlexObject): size (str): Total amount of library items starting with this character. title (str): Character (#, !, A, B, C, ...). """ + def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data diff --git a/plexapi/livetv.py b/plexapi/livetv.py new file mode 100644 index 000000000..86ca5a737 --- /dev/null +++ b/plexapi/livetv.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +import os +from typing import List +from urllib.parse import quote_plus, urlencode +from datetime import datetime +import requests + +from plexapi import media, utils, settings, library +from plexapi.base import PlexObject, Playable, PlexPartialObject +from plexapi.exceptions import BadRequest, NotFound +from plexapi.media import Session +from plexapi.video import Video +from requests.status_codes import _codes as codes + + +@utils.registerPlexObject +class IPTVChannel(Video): + """ Represents a single IPTVChannel.""" + + TAG = 'Directory' + TYPE = 'channel' + METADATA_TYPE = 'channel' + + def _loadData(self, data): + self._data = data + self.art = data.attrib.get('art') + self.guid = data.attrib.get('id') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.items = self.findItems(data) + + +@utils.registerPlexObject +class Recording(Video): + """ Represents a single Recording.""" + + TAG = 'MediaSubscription' + + def _loadData(self, data): + self._data = data + self.key = data.attrib.key('key') + self.type = data.attrib.key('type') + self.targetLibrarySectionId = data.attrib.get('targetLibrarySectionId') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.title = data.attrib.get('title') + self.items = self.findItems(data) + + def delete(self): + self._server.query(key='/media/subscription/' + self.key, method=self._server._session.delete) + + +@utils.registerPlexObject +class ScheduledRecording(Video): + """ Represents a single ScheduledRecording.""" + + TAG = 'MediaGrabOperation' + + def _loadData(self, data): + self._data = data + self.mediaSubscriptionID = data.attrib.get('mediaSubscriptionID') + self.mediaIndex = data.attrib.get('mediaIndex') + self.key = data.attrib.key('key') + self.grabberIdentifier = data.attrib.get('grabberIdentifier') + self.grabberProtocol = data.attrib.get('grabberProtocol') + self.deviceID = data.attrib.get('deviceID') + self.status = data.attrib.get('status') + self.provider = data.attrib.get('provider') + self.items = self.findItems(data) + + +@utils.registerPlexObject +class Setting(PlexObject): + """ Represents a single DVRDevice Setting.""" + + TAG = 'Setting' + + def _loadData(self, data): + self._data = data + self.id = data.attrib.get('id') + self.label = data.attrib.get('label') + self.summary = data.attrib.get('summary') + self.type = data.attrib.get('type') + self.default = data.attrib.get('default') + self.value = data.attrib.get('value') + self.hidden = data.attrib.get('hidden') + self.advanced = data.attrib.get('advanced') + self.group = data.attrib.get('group') + self.enumValues = data.attrib.get('enumValues') + self.items = self.findItems(data) + + +@utils.registerPlexObject +class DVRChannel(PlexObject): + """ Represents a single DVRDevice DVRChannel.""" + + TAG = 'ChannelMapping' + + def _loadData(self, data): + self._data = data + self.channelKey = data.attrib.get('channelKey') + self.deviceIdentifier = data.attrib.get('deviceIdentifier') + self.enabled = utils.cast(int, data.attrib.get('enabled')) + self.lineupIdentifier = data.attrib.get('lineupIdentifier') + self.items = self.findItems(data) + + +@utils.registerPlexObject +class DVRDevice(PlexObject): + """ Represents a single DVRDevice.""" + + TAG = 'Device' + + def _loadData(self, data): + self._data = data + self.parentID = data.attrib.get('parentID') + self.key = data.attrib.get('key', '') + self.uuid = data.attrib.get('uuid') + self.uri = data.attrib.get('uri') + self.protocol = data.attrib.get('protocol') + self.status = data.attrib.get('status') + self.state = data.attrib.get('state') + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.make = data.attrib.get('make') + self.model = data.attrib.get('model') + self.modelNumber = data.attrib.get('modelNumber') + self.source = data.attrib.get('source') + self.sources = data.attrib.get('sources') + self.thumb = data.attrib.get('thumb') + self.tuners = utils.cast(int, data.attrib.get('tuners')) + self.items = self.findItems(data) + + +@utils.registerPlexObject +class DVR(DVRDevice): + """ Represents a single DVR.""" + + TAG = 'Dvr' + + def _loadData(self, data): + self._data = data + self.key = utils.cast(int, data.attrib.get('key')) + self.uuid = data.attrib.get('uuid') + self.language = data.attrib.get('language') + self.lineupURL = data.attrib.get('lineup') + self.title = data.attrib.get('lineupTitle') + self.country = data.attrib.get('country') + self.refreshTime = utils.toDatetime(data.attrib.get('refreshedAt')) + self.epgIdentifier = data.attrib.get('epgIdentifier') + self.items = self.findItems(data) + + +class LiveTV(PlexObject): + def __init__(self, server, data, session=None, token=None): + self._token = token + self._session = session or requests.Session() + self._server = server + self._dvrs = [] # cached DVR objects + self._cloud_key = None # used if cloud XML (zip code) + self._xmltv_key = None # used if local XML (XML path) + super().__init__(server, data) + + def _loadData(self, data): + self._data = data + + def _parseXmlToDict(self, key: str): + response = self._server._queryReturnResponse(key=key) + if not response: + return None + return utils.parseXmlToDict(xml_data_string=response.text) + + @property + def cloud_key(self): + if not self._cloud_key: + data = self._parseXmlToDict(key='/tv.plex.providers.epg.cloud') + if not data: + return None + try: + self._cloud_key = data['MediaContainer']['Directory'][1]['@title'] + except: + pass + return self._cloud_key + + @property + def xmltv_key(self): + if not self._xmltv_key: + data = self._parseXmlToDict(key='/tv.plex.providers.epg.xmltv') + if not data: + return None + try: + self._xmltv_key = data['MediaContainer']['Directory'][1]['@title'] + except: + pass + return self._xmltv_key + + @property + def dvrs(self) -> List[DVR]: + """ Returns a list of :class:`~plexapi.livetv.DVR` objects available to your server. + """ + if not self._dvrs: + self._dvrs = self.fetchItems('/livetv/dvrs') + return self._dvrs + + @property + def sessions(self) -> List[Session]: + """ Returns a list of all active live tv session (currently playing) media objects. + """ + return self.fetchItems('/livetv/sessions') + + @property + def hubs(self): + """ Returns a list of all :class:`~plexapi.livetv.Hub` objects available to your server. + """ + hubs = [] + if self.cloud_key: + hubs.extend(self._server.fetchItems("/" + self.cloud_key + '/hubs/discover')) + if self.xmltv_key: + hubs.extend(self._server.fetchItems("/" + self.xmltv_key + '/hubs/discover')) + return hubs + + @property + def recordings(self): + return self.fetchItems('/media/subscriptions/scheduled') + + @property + def scheduled(self): + return self.fetchItems('/media/subscriptions') + + def _guide_items(self, key, grid_type: int, beginsAt: datetime = None, endsAt: datetime = None): + """ Returns a list of all guide items + + Parameters: + key (str): cloud_key or xmltv_key + grid_type (int): 1 for movies, 4 for shows + beginsAt (datetime): Limit results to beginning after UNIX timestamp (epoch). + endsAt (datetime): Limit results to ending before UNIX timestamp (epoch). + """ + key = '/%s/grid?type=%s' % (key, grid_type) + if beginsAt: + key += '&beginsAt%3C=%s' % utils.datetimeToEpoch(beginsAt) # %3C is <, so <= + if endsAt: + key += '&endsAt%3E=%s' % utils.datetimeToEpoch(endsAt) # %3E is >, so >= + return self._server.fetchItems(key) + + def movies(self, beginsAt: datetime = None, endsAt: datetime = None): + """ Returns a list of all :class:`~plexapi.video.Movie` items on the guide. + + Parameters: + beginsAt (datetime): Limit results to beginning after UNIX timestamp (epoch). + endsAt (datetime): Limit results to ending before UNIX timestamp (epoch). + """ + movies = [] + if self.cloud_key: + movies.extend(self._guide_items(key=self.cloud_key, grid_type=1, beginsAt=beginsAt, endsAt=endsAt)) + if self.xmltv_key: + movies.extend(self._guide_items(key=self.xmltv_key, grid_type=1, beginsAt=beginsAt, endsAt=endsAt)) + return movies + + def shows(self, beginsAt: datetime = None, endsAt: datetime = None): + """ Returns a list of all :class:`~plexapi.video.Show` items on the guide. + + Parameters: + beginsAt (datetime): Limit results to beginning after UNIX timestamp (epoch). + endsAt (datetime): Limit results to ending before UNIX timestamp (epoch). + """ + shows = [] + if self.cloud_key: + shows.extend(self._guide_items(key=self.cloud_key, grid_type=4, beginsAt=beginsAt, endsAt=endsAt)) + if self.xmltv_key: + shows.extend(self._guide_items(key=self.xmltv_key, grid_type=4, beginsAt=beginsAt, endsAt=endsAt)) + return shows + + def guide(self, beginsAt: datetime = None, endsAt: datetime = None): + """ Returns a list of all media items on the guide. Items may be any of + :class:`~plexapi.video.Movie`, :class:`~plexapi.video.Show`. + + Parameters: + beginsAt (datetime): Limit results to beginning after UNIX timestamp (epoch). + endsAt (datetime): Limit results to ending before UNIX timestamp (epoch). + """ + all_movies = self.movies(beginsAt, endsAt) + return all_movies + # Potential show endpoint currently hanging, do not use + # all_shows = self.shows(beginsAt, endsAt) + # return all_movies + all_shows diff --git a/plexapi/media.py b/plexapi/media.py index 735bbe1bf..81e56d6b5 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -66,6 +66,15 @@ def _loadData(self, data): self.proxyType = cast(int, data.attrib.get('proxyType')) self.target = data.attrib.get('target') self.title = data.attrib.get('title') + self.protocol = data.attrib.get('protocol') + self.channelCallSign = data.attrib.get('channelCallSign') + self.channelIdentifier = data.attrib.get('channelIdentifier') + self.channelThumb = data.attrib.get('channelThumb') + self.channelTitle = data.attrib.get('channelTitle') + self.beginsAt = utils.toDatetime(data.attrib.get('beginsAt')) + self.endsAt = utils.toDatetime(data.attrib.get('endsAt')) + self.onAir = cast(int, data.attrib.get('onAir')) + self.channelID = data.attrib.get('channelID') self.videoCodec = data.attrib.get('videoCodec') self.videoFrameRate = data.attrib.get('videoFrameRate') self.videoProfile = data.attrib.get('videoProfile') diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 84970dafd..9b45a5077 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -2,6 +2,7 @@ import copy import threading import time +from typing import List from xml.etree import ElementTree import requests @@ -10,7 +11,7 @@ from plexapi.base import PlexObject from plexapi.client import PlexClient from plexapi.exceptions import BadRequest, NotFound, Unauthorized -from plexapi.library import LibrarySection +from plexapi.library import LibrarySection, Hub from plexapi.server import PlexServer from plexapi.sonos import PlexSonosClient from plexapi.sync import SyncItem, SyncList @@ -83,6 +84,7 @@ class MyPlexAccount(PlexObject): NEWS = 'https://news.provider.plex.tv/' # get PODCASTS = 'https://podcasts.provider.plex.tv/' # get MUSIC = 'https://music.provider.plex.tv/' # get + IPTV = 'https://epg.provider.plex.tv/' # get # Key may someday switch to the following url. For now the current value works. # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} key = 'https://plex.tv/users/account' @@ -660,6 +662,7 @@ def history(self, maxresults=9999999, mindate=None): hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1)) return hist + @property def videoOnDemand(self): """ Returns a list of VOD Hub items :class:`~plexapi.library.Hub` """ @@ -667,6 +670,7 @@ def videoOnDemand(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + @property def webShows(self): """ Returns a list of Webshow Hub items :class:`~plexapi.library.Hub` """ @@ -674,6 +678,7 @@ def webShows(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + @property def news(self): """ Returns a list of News Hub items :class:`~plexapi.library.Hub` """ @@ -681,6 +686,7 @@ def news(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + @property def podcasts(self): """ Returns a list of Podcasts Hub items :class:`~plexapi.library.Hub` """ @@ -688,13 +694,22 @@ def podcasts(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + @property def tidal(self): - """ Returns a list of tidal Hub items :class:`~plexapi.library.Hub` + """ Returns a list of Tidal Hub items :class:`~plexapi.library.Hub` """ req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token}) elem = ElementTree.fromstring(req.text) return self.findItems(elem) + @property + def iptv(self) -> List[Hub]: + """ Returns a list of IPTV Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.IPTV + 'hubs/sections/all', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + def link(self, pin): """ Link a device to the account using a pin code. @@ -707,7 +722,7 @@ def link(self, pin): } data = {'code': pin} self.query(self.LINK, self._session.put, headers=headers, data=data) - + class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked diff --git a/plexapi/server.py b/plexapi/server.py index d90c76dac..a6ad2bc74 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -4,7 +4,7 @@ import requests from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log, - logfilter) + logfilter, livetv) from plexapi import utils from plexapi.alert import AlertListener from plexapi.base import PlexObject @@ -15,6 +15,7 @@ from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue from plexapi.settings import Settings +from plexapi.livetv import LiveTV from plexapi.utils import cast, deprecated from requests.status_codes import _codes as codes @@ -108,6 +109,7 @@ def __init__(self, baseurl=None, token=None, session=None, timeout=None): self._library = None # cached library self._settings = None # cached settings self._myPlexAccount = None # cached myPlexAccount + self._liveTV = None # cached liveTV self._systemAccounts = None # cached list of SystemAccount self._systemDevices = None # cached list of SystemDevice data = self.query(self.key, timeout=timeout) @@ -257,6 +259,15 @@ def _myPlexClientPorts(self): log.warning('Unable to fetch client ports from myPlex: %s', err) return ports + @property + def livetv(self) -> LiveTV: + """ Returns a :class:`~plexapi.livetv.LiveTV` object using the same + token to access this server. + """ + if self._liveTV is None: + self._liveTV = LiveTV(token=self._token, data=None, server=self) + return self._liveTV + def browse(self, path=None, includeFiles=True): """ Browse the system file path using the Plex API. Returns list of :class:`~plexapi.library.Path` and :class:`~plexapi.library.File` objects. @@ -493,12 +504,7 @@ def query(self, key, method=None, headers=None, timeout=None, **kwargs): by encoding the response to utf-8 and parsing the returned XML into and ElementTree object. Returns None if no data exists in the response. """ - url = self.url(key) - method = method or self._session.get - timeout = timeout or TIMEOUT - log.debug('%s %s', method.__name__.upper(), url) - headers = self._headers(**headers or {}) - response = method(url, headers=headers, timeout=timeout, **kwargs) + response = self._queryReturnResponse(key=key, method=method, headers=headers, timeout=timeout, **kwargs) if response.status_code not in (200, 201, 204): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') @@ -512,6 +518,18 @@ def query(self, key, method=None, headers=None, timeout=None, **kwargs): data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None + def _queryReturnResponse(self, key, method=None, headers=None, timeout=None, **kwargs): + """ Fork of query() function to return the entire requests.Response object for parsing + elsewhere. + """ + url = self.url(key) + method = method or self._session.get + timeout = timeout or TIMEOUT + log.debug('%s %s', method.__name__.upper(), url) + headers = self._headers(**headers or {}) + response = method(url, headers=headers, timeout=timeout, **kwargs) + return response + def search(self, query, mediatype=None, limit=None): """ Returns a list of media items or filter categories from the resulting `Hub Search `_ diff --git a/plexapi/utils.py b/plexapi/utils.py index be07672ee..bb55b5c7b 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -11,8 +11,11 @@ from getpass import getpass from threading import Event, Thread from urllib.parse import quote +from xml.etree import ElementTree import requests +import xmltodict + from plexapi.exceptions import BadRequest, NotFound try: @@ -60,7 +63,7 @@ def registerPlexObject(cls): ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG if ehash in PLEXOBJECTS: raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' % - (cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__)) + (cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__)) PLEXOBJECTS[ehash] = cls return cls @@ -207,6 +210,15 @@ def toDatetime(value, format=None): return value +def datetimeToEpoch(value: datetime): + """ Returns the epoch timestamp for the specified timestamp. + + Parameters: + value (datetime): datetime to return as a timestamp + """ + return int(value.timestamp()) + + def millisecondToHumanstr(milliseconds): """ Returns human readable time duration from milliseconds. HH:MM:SS:MMMM @@ -471,11 +483,24 @@ def decorator(func): """This is a decorator which can be used to mark functions as deprecated. It will result in a warning being emitted when the function is used.""" + @functools.wraps(func) def wrapper(*args, **kwargs): msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message) warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel) log.warning(msg) return func(*args, **kwargs) + return wrapper + return decorator + + +def parseXml(xml_data_string): + xml_data_string = xml_data_string.replace('\n', '').encode('utf8') + return ElementTree.XML(xml_data_string) + + +def parseXmlToDict(xml_data_string): + xml_data_string = xml_data_string.encode('utf8') + return xmltodict.parse(xml_data_string) diff --git a/plexapi/video.py b/plexapi/video.py index ed4fbf1dc..198e92c5f 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,6 +5,7 @@ from plexapi import library, media, settings, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound +from plexapi.media import Media from plexapi.mixins import ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin @@ -52,6 +53,7 @@ def _loadData(self, data): self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.listType = 'video' + self.year = data.attrib.get('year') self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') @@ -67,6 +69,19 @@ def isWatched(self): """ Returns True if this video is watched. """ return bool(self.viewCount > 0) if self.viewCount else False + def thumbUrl(self): + """ Return the first first thumbnail url starting on + the most specific thumbnail for that item. + """ + thumb = self.firstAttr('thumb', 'parentThumb', 'grandparentThumb') + return self._server.url(thumb, includeToken=True) if thumb else None + + @property + def artUrl(self): + """ Return the first first art url starting on the most specific for that item.""" + art = self.firstAttr('art', 'grandparentArt') + return self._server.url(art, includeToken=True) if art else None + def url(self, part): """ Returns the full url for something. Typically used for getting a specific image. """ return self._server.url(part, includeToken=True) if part else None @@ -311,6 +326,8 @@ def _loadData(self, data): self.media = self.findItems(data, media.Media) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originalTitle = data.attrib.get('originalTitle') + self.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), format='%Y-%m-%d %H:%M%S') self.primaryExtraKey = data.attrib.get('primaryExtraKey') self.producers = self.findItems(data, media.Producer) self.rating = utils.cast(float, data.attrib.get('rating')) @@ -825,6 +842,7 @@ def _loadData(self, data): self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) self.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.live = utils.cast(int, data.attrib.get('live', '0')) self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) @@ -892,6 +910,12 @@ def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title) + def record(self): + # TODO + if self.live: + return False + return False + @utils.registerPlexObject class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): diff --git a/requirements.txt b/requirements.txt index ac8922b4d..eaca4c18d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ # pip install -r requirements.txt #--------------------------------------------------------- requests +xmltodict \ No newline at end of file