From 360a781043edcb04b3f855c9ef4a331a0ba8d73c Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Mon, 16 Mar 2020 02:09:21 -0400 Subject: [PATCH 01/20] Initial commit for Live TV support --- plexapi/livetv.py | 278 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 plexapi/livetv.py diff --git a/plexapi/livetv.py b/plexapi/livetv.py new file mode 100644 index 000000000..06cfaa1e4 --- /dev/null +++ b/plexapi/livetv.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- +from plexapi import X_PLEX_CONTAINER_SIZE, log, utils +from plexapi.base import PlexObject +from plexapi.compat import unquote, urlencode, quote_plus +from plexapi.media import MediaTag +from plexapi.exceptions import BadRequest, NotFound + + +class LiveTV(PlexObject): + def _loadData(self, data): + self._data = data + self.cloudKey = None + self.identifier = data.attrib.get('identifier') + self.size = data.attrib.get('size') + + def cloudKey(self): + if not self.cloudKey: + res = self._server.query(key='/tv.plex.providers.epg.cloud') + if res: + self.cloudKey = res.attrib.get('title') + return self.cloudKey + + def hubs(self): + res = self._server.query(key='/{}/hubs/discover'.format(self.cloudKey())) + if res: + return [Hub(item) for item in res.attrib.get('Hub')] + return [] + + def hub(self, identifier=None): + hubs = self.hubs() + for hub in hubs: + if hub.identifier == identifier: + return hub + return None + + def dvrSchedule(self): + res = self._server.query(key='/media/subscriptions/scheduled') + if res: + return DVRSchedule(res.attrib.get('MediaContainer')) + return None + + def dvrItems(self): + res = self._server.query(key='/media/subscriptions') + if res: + return [DVRItem(item) for item in res.attrib.get('MediaSubscription')] + return [] + + def dvrItem(self, title=None): + items = self.dvrItems() + for item in items: + if item.title == title: + return item + return None + + def homepageItems(self): + res = self._server.query(key='/hubs') + if res: + return [Hub(item) for item in res.attrib.get('Hub')] + return [] + + def homepageItem(self, title=None): + items = self.homepageItems() + for item in items: + if item.title == title: + return item + return None + + def liveTVSessions(self): + res = self._server.query(key='/livetv/sessions') + if res: + return [TVSession(item) for item in res.attrib.get('Metadata')] + return [] + + def liveTVSession(self, key=None): + items = self.liveTVSessions() + for item in items: + if item.key == key: + return item + return None + + def dvrs(self): + res = self._server.query(key='/livetv/dvrs') + if res: + return [DVR(item) for item in res.attrib.get('Dvr')] + return [] + + def dvr(self, title=None): + items = self.dvrs() + for item in items: + if item.title == title: + return item + return None + + +class Hub: + def __init__(self, data): + self.data = data + self.key = data.get('hubKey') + self.title = data.get('title') + self.type = data.get('type') + self.identifier = data.get('hubIdentifier') + self.context = data.get('context') + self.size = data.get('size') + self.more = data.get('more') + self.promoted = data.get('promoted') + if data.get('Metadata'): + self.items = [MediaItem(item) for item in self.data.get('Metadata')] + + +class DVR: + def __init__(self, data): + self.data = data + self.key = data.get('key') + self.uuid = data.get('uuid') + self.language = data.get('language') + self.lineupURL = data.get('lineup') + self.title = data.get('lineupTitle') + self.country = data.get('country') + self.refreshTime = data.get('refreshedAt') + self.epgIdentifier = data.get('epgIdentifier') + self.device = [Device(device) for device in data.get('Device')] + + +class DVRSchedule: + def __init__(self, data): + self.data = data + self.count = data.get('size') + if data.get('MediaGrabOperation'): + self.items = [DVRItem(item) for item in data.get('MediaGrabOperation')] + + +class DVRItem: + def __init__(self, data): + self.data = data + self.type = data.get('type') + self.targetLibrarySectionID = data.get('targetLibrarySectionID') + self.created = data.get('createdAt') + self.title = data.get('title') + self.mediaSubscriptionID = data.get('mediaSubscriptionID') + self.mediaIndex = data.get('mediaIndex') + self.key = data.get('key') + self.grabberIdentifier = data.get('grabberIdentifier') + self.grabberProtocol = data.get('grabberProtocol') + self.deviceID = data.get('deviceID') + self.status = data.get('status') + self.provider = data.get('provider') + if data.get('Video'): + self.video = Video(data.get('Video')) + + def delete(self): + self._server.query(key='/media/subscription/{}'.format(self.mediaSubscriptionID), method=self._server._session.delete) + + +class TVSession: + def __init__(self, data): + self.data = data + self.ratingKey = data.get('ratingKey') + self.guid = data.get('guid') + self.type = data.get('type') + self.title = data.get('title') + self.summary = data.get('title') + self.ratingCount = data.get('ratingCount') + self.year = data.get('year') + self.added = data.get('addedAt') + self.genuineMediaAnalysis = data.get('genuineMediaAnalysis') + self.grandparentThumb = data.get('grandparentThumb') + self.grandparentTitle = data.get('grandparentTitle') + self.key = data.get('key') + self.live = data.get('live') + self.parentIndex = data.get('parentIndex') + self.media = [MediaItem(item) for item in data.get('Media')] + + +class Device: + def __init__(self, data): + self.data = data + self.parentID = data.get('parentID') + self.key = data.get('key') + self.uuid = data.get('uuid') + self.uri = data.get('uri') + self.protocol = data.get('protocol') + self.status = data.get('status') + self.state = data.get('state') + self.lastSeen = data.get('lastSeenAt') + self.make = data.get('make') + self.model = data.get('model') + self.modelNumber = data.get('modelNumber') + self.source = data.get('source') + self.sources = data.get('sources') + self.thumb = data.get('thumb') + self.tuners = data.get('tuners') + if data.get('Channels'): + self.channels = [Channel(channel) for channel in data.get('Channels')] + if data.get('Setting'): + self.settings = [Setting(setting) for setting in data.get('Setting')] + + +class Channel: + def __init__(self, data): + self.data = data + self.deviceId = data.get('deviceIdentifier') + self.enabled = data.get('enabled') + self.lineupId = data.get('lineupIdentifier') + + +class Setting: + def __init__(self, data): + self.data = data + self.id = data.get('id') + self.label = data.get('label') + self.summary = data.get('summary') + self.type = data.get('type') + self.default = data.get('default') + self.value = data.get('value') + self.hidden = data.get('hidden') + self.advanced = data.get('advanced') + self.group = data.get('group') + self.enumValues = data.get('enumValues') + + +class MediaFile: + def __init__(self, data): + self.data = data + self.id = data.get('id') + self.duration = data.get('duration') + self.audioChannels = data.get('audioChannels') + self.videoResolution = data.get('videoResolution') + self.channelCallSign = data.get('channelCallSign') + self.channelIdentifier = data.get('channelIdentifier') + self.channelThumb = data.get('channelThumb') + self.channelTitle = data.get('channelTitle') + self.protocol = data.get('protocol') + self.begins = data.get('beginsAt') + self.ends = data.get('endsAt') + self.onAir = data.get('onAir') + self.channelID = data.get('channelID') + self.origin = data.get('origin') + self.uuid = data.get('uuid') + self.container = data.get('container') + self.startOffsetSeconds = data.get('startOffsetSeconds') + self.endOffsetSeconds = data.get('endOffsetSeconds') + self.premiere = data.get('premiere') + + +class MediaItem: + def __init__(self, data): + self.data = data + self.ratingKey = data.get('ratingKey') + self.key = data.get('key') + self.skipParent = data.get('skipParent') + self.guid = data.get('guid') + self.parentGuid = data.get('parentGuid') + self.grandparentGuid = data.get('grandparentGuid') + self.type = data.get('type') + self.title = data.get('title') + self.grandparentKey = data.get('grandparentKey') + self.grandparentTitle = data.get('grandparentTitle') + self.parentTitle = data.get('parentTitle') + self.summary = data.get('summary') + self.parentIndex = data.get('parentIndex') + self.year = data.get('year') + self.grandparentThumb = data.get('grandparentThumb') + self.duration = data.get('duration') + self.originallyAvailable = data.get('originallyAvailableAt') + self.added = data.get('addedAt') + self.onAir = data.get('onAir') + if data.get('Media'): + self.media = [MediaFile(item) for item in data.get('Media')] + if data.get('Genre'): + self.genres = [Genre(item) for item in data.get('Genre')] + + +class Genre: + def __init__(self, data): + self.data = data + self.filter = data.get('filter') + self.id = data.get('id') + self.tag = data.get('tag') From 1e8b3efb88841115b82516aa9128a58238d6f8ba Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Mon, 16 Mar 2020 02:17:47 -0400 Subject: [PATCH 02/20] Initial commit for Live TV support --- plexapi/livetv.py | 256 +++++++++++++++++++++++----------------------- 1 file changed, 128 insertions(+), 128 deletions(-) diff --git a/plexapi/livetv.py b/plexapi/livetv.py index 06cfaa1e4..182ec0b45 100644 --- a/plexapi/livetv.py +++ b/plexapi/livetv.py @@ -95,57 +95,57 @@ def dvr(self, title=None): class Hub: def __init__(self, data): self.data = data - self.key = data.get('hubKey') - self.title = data.get('title') - self.type = data.get('type') - self.identifier = data.get('hubIdentifier') - self.context = data.get('context') - self.size = data.get('size') - self.more = data.get('more') - self.promoted = data.get('promoted') - if data.get('Metadata'): - self.items = [MediaItem(item) for item in self.data.get('Metadata')] + self.key = data.attrib.get('hubKey') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.identifier = data.attrib.get('hubIdentifier') + self.context = data.attrib.get('context') + self.size = data.attrib.get('size') + self.more = data.attrib.get('more') + self.promoted = data.attrib.get('promoted') + if data.attrib.get('Metadata'): + self.items = [MediaItem(item) for item in self.data.attrib.get('Metadata')] class DVR: def __init__(self, data): self.data = data - self.key = data.get('key') - self.uuid = data.get('uuid') - self.language = data.get('language') - self.lineupURL = data.get('lineup') - self.title = data.get('lineupTitle') - self.country = data.get('country') - self.refreshTime = data.get('refreshedAt') - self.epgIdentifier = data.get('epgIdentifier') - self.device = [Device(device) for device in data.get('Device')] + self.key = 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 = data.attrib.get('refreshedAt') + self.epgIdentifier = data.attrib.get('epgIdentifier') + self.device = [Device(device) for device in data.attrib.get('Device')] class DVRSchedule: def __init__(self, data): self.data = data - self.count = data.get('size') - if data.get('MediaGrabOperation'): - self.items = [DVRItem(item) for item in data.get('MediaGrabOperation')] + self.count = data.attrib.get('size') + if data.attrib.get('MediaGrabOperation'): + self.items = [DVRItem(item) for item in data.attrib.get('MediaGrabOperation')] class DVRItem: def __init__(self, data): self.data = data - self.type = data.get('type') - self.targetLibrarySectionID = data.get('targetLibrarySectionID') - self.created = data.get('createdAt') - self.title = data.get('title') - self.mediaSubscriptionID = data.get('mediaSubscriptionID') - self.mediaIndex = data.get('mediaIndex') - self.key = data.get('key') - self.grabberIdentifier = data.get('grabberIdentifier') - self.grabberProtocol = data.get('grabberProtocol') - self.deviceID = data.get('deviceID') - self.status = data.get('status') - self.provider = data.get('provider') - if data.get('Video'): - self.video = Video(data.get('Video')) + self.type = data.attrib.get('type') + self.targetLibrarySectionID = data.attrib.get('targetLibrarySectionID') + self.created = data.attrib.get('createdAt') + self.title = data.attrib.get('title') + self.mediaSubscriptionID = data.attrib.get('mediaSubscriptionID') + self.mediaIndex = data.attrib.get('mediaIndex') + self.key = data.attrib.get('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') + if data.attrib.get('Video'): + self.video = Video(data.attrib.get('Video')) def delete(self): self._server.query(key='/media/subscription/{}'.format(self.mediaSubscriptionID), method=self._server._session.delete) @@ -154,125 +154,125 @@ def delete(self): class TVSession: def __init__(self, data): self.data = data - self.ratingKey = data.get('ratingKey') - self.guid = data.get('guid') - self.type = data.get('type') - self.title = data.get('title') - self.summary = data.get('title') - self.ratingCount = data.get('ratingCount') - self.year = data.get('year') - self.added = data.get('addedAt') - self.genuineMediaAnalysis = data.get('genuineMediaAnalysis') - self.grandparentThumb = data.get('grandparentThumb') - self.grandparentTitle = data.get('grandparentTitle') - self.key = data.get('key') - self.live = data.get('live') - self.parentIndex = data.get('parentIndex') - self.media = [MediaItem(item) for item in data.get('Media')] + self.ratingKey = data.attrib.get('ratingKey') + self.guid = data.attrib.get('guid') + self.type = data.attrib.get('type') + self.title = data.attrib.get('title') + self.summary = data.attrib.get('title') + self.ratingCount = data.attrib.get('ratingCount') + self.year = data.attrib.get('year') + self.added = data.attrib.get('addedAt') + self.genuineMediaAnalysis = data.attrib.get('genuineMediaAnalysis') + self.grandparentThumb = data.attrib.get('grandparentThumb') + self.grandparentTitle = data.attrib.get('grandparentTitle') + self.key = data.attrib.get('key') + self.live = data.attrib.get('live') + self.parentIndex = data.attrib.get('parentIndex') + self.media = [MediaItem(item) for item in data.attrib.get('Media')] class Device: def __init__(self, data): self.data = data - self.parentID = data.get('parentID') - self.key = data.get('key') - self.uuid = data.get('uuid') - self.uri = data.get('uri') - self.protocol = data.get('protocol') - self.status = data.get('status') - self.state = data.get('state') - self.lastSeen = data.get('lastSeenAt') - self.make = data.get('make') - self.model = data.get('model') - self.modelNumber = data.get('modelNumber') - self.source = data.get('source') - self.sources = data.get('sources') - self.thumb = data.get('thumb') - self.tuners = data.get('tuners') - if data.get('Channels'): - self.channels = [Channel(channel) for channel in data.get('Channels')] - if data.get('Setting'): - self.settings = [Setting(setting) for setting in data.get('Setting')] + 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.lastSeen = 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 = data.attrib.get('tuners') + if data.attrib.get('Channels'): + self.channels = [Channel(channel) for channel in data.attrib.get('Channels')] + if data.attrib.get('Setting'): + self.settings = [Setting(setting) for setting in data.attrib.get('Setting')] class Channel: def __init__(self, data): self.data = data - self.deviceId = data.get('deviceIdentifier') - self.enabled = data.get('enabled') - self.lineupId = data.get('lineupIdentifier') + self.deviceId = data.attrib.get('deviceIdentifier') + self.enabled = data.attrib.get('enabled') + self.lineupId = data.attrib.get('lineupIdentifier') class Setting: def __init__(self, data): self.data = data - self.id = data.get('id') - self.label = data.get('label') - self.summary = data.get('summary') - self.type = data.get('type') - self.default = data.get('default') - self.value = data.get('value') - self.hidden = data.get('hidden') - self.advanced = data.get('advanced') - self.group = data.get('group') - self.enumValues = data.get('enumValues') + 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') class MediaFile: def __init__(self, data): self.data = data - self.id = data.get('id') - self.duration = data.get('duration') - self.audioChannels = data.get('audioChannels') - self.videoResolution = data.get('videoResolution') - self.channelCallSign = data.get('channelCallSign') - self.channelIdentifier = data.get('channelIdentifier') - self.channelThumb = data.get('channelThumb') - self.channelTitle = data.get('channelTitle') - self.protocol = data.get('protocol') - self.begins = data.get('beginsAt') - self.ends = data.get('endsAt') - self.onAir = data.get('onAir') - self.channelID = data.get('channelID') - self.origin = data.get('origin') - self.uuid = data.get('uuid') - self.container = data.get('container') - self.startOffsetSeconds = data.get('startOffsetSeconds') - self.endOffsetSeconds = data.get('endOffsetSeconds') - self.premiere = data.get('premiere') + self.id = data.attrib.get('id') + self.duration = data.attrib.get('duration') + self.audioChannels = data.attrib.get('audioChannels') + self.videoResolution = data.attrib.get('videoResolution') + 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.protocol = data.attrib.get('protocol') + self.begins = data.attrib.get('beginsAt') + self.ends = data.attrib.get('endsAt') + self.onAir = data.attrib.get('onAir') + self.channelID = data.attrib.get('channelID') + self.origin = data.attrib.get('origin') + self.uuid = data.attrib.get('uuid') + self.container = data.attrib.get('container') + self.startOffsetSeconds = data.attrib.get('startOffsetSeconds') + self.endOffsetSeconds = data.attrib.get('endOffsetSeconds') + self.premiere = data.attrib.get('premiere') class MediaItem: def __init__(self, data): self.data = data - self.ratingKey = data.get('ratingKey') - self.key = data.get('key') - self.skipParent = data.get('skipParent') - self.guid = data.get('guid') - self.parentGuid = data.get('parentGuid') - self.grandparentGuid = data.get('grandparentGuid') - self.type = data.get('type') - self.title = data.get('title') - self.grandparentKey = data.get('grandparentKey') - self.grandparentTitle = data.get('grandparentTitle') - self.parentTitle = data.get('parentTitle') - self.summary = data.get('summary') - self.parentIndex = data.get('parentIndex') - self.year = data.get('year') - self.grandparentThumb = data.get('grandparentThumb') - self.duration = data.get('duration') - self.originallyAvailable = data.get('originallyAvailableAt') - self.added = data.get('addedAt') - self.onAir = data.get('onAir') - if data.get('Media'): - self.media = [MediaFile(item) for item in data.get('Media')] - if data.get('Genre'): - self.genres = [Genre(item) for item in data.get('Genre')] + self.ratingKey = data.attrib.get('ratingKey') + self.key = data.attrib.get('key') + self.skipParent = data.attrib.get('skipParent') + self.guid = data.attrib.get('guid') + self.parentGuid = data.attrib.get('parentGuid') + self.grandparentGuid = data.attrib.get('grandparentGuid') + self.type = data.attrib.get('type') + self.title = data.attrib.get('title') + self.grandparentKey = data.attrib.get('grandparentKey') + self.grandparentTitle = data.attrib.get('grandparentTitle') + self.parentTitle = data.attrib.get('parentTitle') + self.summary = data.attrib.get('summary') + self.parentIndex = data.attrib.get('parentIndex') + self.year = data.attrib.get('year') + self.grandparentThumb = data.attrib.get('grandparentThumb') + self.duration = data.attrib.get('duration') + self.originallyAvailable = data.attrib.get('originallyAvailableAt') + self.added = data.attrib.get('addedAt') + self.onAir = data.attrib.get('onAir') + if data.attrib.get('Media'): + self.media = [MediaFile(item) for item in data.attrib.get('Media')] + if data.attrib.get('Genre'): + self.genres = [Genre(item) for item in data.attrib.get('Genre')] class Genre: def __init__(self, data): self.data = data - self.filter = data.get('filter') - self.id = data.get('id') - self.tag = data.get('tag') + self.filter = data.attrib.get('filter') + self.id = data.attrib.get('id') + self.tag = data.attrib.get('tag') From adea3d3d57fdb95341681a9bded78455bf6499f4 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 29 Jul 2020 03:09:27 -0400 Subject: [PATCH 03/20] New Directory class for IPTV channels --- plexapi/video.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index 5396d87fa..1a8825957 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -784,3 +784,23 @@ def _loadData(self, data): self.title = data.attrib.get('title') self.type = data.attrib.get('type') self.year = data.attrib.get('year') + + +@utils.registerPlexObject +class Directory(Video): + """ Represents a single Directory.""" + + TAG = 'Directory' + TYPE = 'channel' + METADATA_TYPE = 'channel' + + def _loadData(self, data): + self._data = data + 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) + + def __len__(self): + return self.size From 11fea1ca84baa3e537a380905dbbe92e782b155a Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 29 Jul 2020 03:11:19 -0400 Subject: [PATCH 04/20] New iptv() method to get Plex Live TV channel hubs --- plexapi/myplex.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 8806fdb55..2b02e3eba 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -82,6 +82,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' @@ -682,6 +683,14 @@ def tidal(self): req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token}) elem = ElementTree.fromstring(req.text) return self.findItems(elem) + + + def iptv(self): + """ 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) class MyPlexUser(PlexObject): From d86ba0ac9e6c3ea06a9a4efc155ff8becd3aee08 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Fri, 7 Aug 2020 00:51:15 -0400 Subject: [PATCH 05/20] Initial commit for limited Live TV (DVR) and IPTV (Free Plex streams) support --- plexapi/base.py | 3 +- plexapi/livetv.py | 422 +++++++++++++++++++++------------------------- plexapi/media.py | 9 + plexapi/myplex.py | 3 +- plexapi/server.py | 12 +- plexapi/video.py | 34 ++-- 6 files changed, 222 insertions(+), 261 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 101a0b435..8c81d5ee6 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -435,8 +435,7 @@ def delete(self): try: return self._server.query(self.key, method=self._server._session.delete) except BadRequest: # pragma: no cover - log.error('Failed to delete %s. This could be because you ' - 'havnt allowed items to be deleted' % self.key) + log.error("Failed to delete %s. This could be because you haven't allowed items to be deleted" % self.key) raise def history(self, maxresults=9999999, mindate=None): diff --git a/plexapi/livetv.py b/plexapi/livetv.py index 182ec0b45..a00a276c8 100644 --- a/plexapi/livetv.py +++ b/plexapi/livetv.py @@ -1,278 +1,232 @@ # -*- coding: utf-8 -*- -from plexapi import X_PLEX_CONTAINER_SIZE, log, utils -from plexapi.base import PlexObject -from plexapi.compat import unquote, urlencode, quote_plus -from plexapi.media import MediaTag -from plexapi.exceptions import BadRequest, NotFound +import os +from urllib.parse import quote_plus, urlencode +import requests +from plexapi import media, utils, settings, library +from plexapi.base import PlexObject, Playable, PlexPartialObject +from plexapi.exceptions import BadRequest, NotFound +from plexapi.video import Video +from requests.status_codes import _codes as codes -class LiveTV(PlexObject): - def _loadData(self, data): - self._data = data - self.cloudKey = None - self.identifier = data.attrib.get('identifier') - self.size = data.attrib.get('size') - - def cloudKey(self): - if not self.cloudKey: - res = self._server.query(key='/tv.plex.providers.epg.cloud') - if res: - self.cloudKey = res.attrib.get('title') - return self.cloudKey - - def hubs(self): - res = self._server.query(key='/{}/hubs/discover'.format(self.cloudKey())) - if res: - return [Hub(item) for item in res.attrib.get('Hub')] - return [] - - def hub(self, identifier=None): - hubs = self.hubs() - for hub in hubs: - if hub.identifier == identifier: - return hub - return None - def dvrSchedule(self): - res = self._server.query(key='/media/subscriptions/scheduled') - if res: - return DVRSchedule(res.attrib.get('MediaContainer')) - return None +@utils.registerPlexObject +class IPTVChannel(Video): + """ Represents a single IPTVChannel.""" - def dvrItems(self): - res = self._server.query(key='/media/subscriptions') - if res: - return [DVRItem(item) for item in res.attrib.get('MediaSubscription')] - return [] - - def dvrItem(self, title=None): - items = self.dvrItems() - for item in items: - if item.title == title: - return item - return None + TAG = 'Directory' + TYPE = 'channel' + METADATA_TYPE = 'channel' - def homepageItems(self): - res = self._server.query(key='/hubs') - if res: - return [Hub(item) for item in res.attrib.get('Hub')] - return [] - - def homepageItem(self, title=None): - items = self.homepageItems() - for item in items: - if item.title == title: - return item - return None + def _loadData(self, data): + self._data = data + 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) - def liveTVSessions(self): - res = self._server.query(key='/livetv/sessions') - if res: - return [TVSession(item) for item in res.attrib.get('Metadata')] - return [] - - def liveTVSession(self, key=None): - items = self.liveTVSessions() - for item in items: - if item.key == key: - return item - return None - def dvrs(self): - res = self._server.query(key='/livetv/dvrs') - if res: - return [DVR(item) for item in res.attrib.get('Dvr')] - return [] - - def dvr(self, title=None): - items = self.dvrs() - for item in items: - if item.title == title: - return item - return None +@utils.registerPlexObject +class Recording(Video): + """ Represents a single Recording.""" + TAG = 'MediaSubscription' -class Hub: - def __init__(self, data): - self.data = data - self.key = data.attrib.get('hubKey') + 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.type = data.attrib.get('type') - self.identifier = data.attrib.get('hubIdentifier') - self.context = data.attrib.get('context') - self.size = data.attrib.get('size') - self.more = data.attrib.get('more') - self.promoted = data.attrib.get('promoted') - if data.attrib.get('Metadata'): - self.items = [MediaItem(item) for item in self.data.attrib.get('Metadata')] - - -class DVR: - def __init__(self, data): - self.data = data - self.key = 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 = data.attrib.get('refreshedAt') - self.epgIdentifier = data.attrib.get('epgIdentifier') - self.device = [Device(device) for device in data.attrib.get('Device')] + self.items = self.findItems(data) + def delete(self): + self._server.query(key='/media/subscription/' + self.key, method=self._server._session.delete) -class DVRSchedule: - def __init__(self, data): - self.data = data - self.count = data.attrib.get('size') - if data.attrib.get('MediaGrabOperation'): - self.items = [DVRItem(item) for item in data.attrib.get('MediaGrabOperation')] +@utils.registerPlexObject +class ScheduledRecording(Video): + """ Represents a single ScheduledRecording.""" -class DVRItem: - def __init__(self, data): - self.data = data - self.type = data.attrib.get('type') - self.targetLibrarySectionID = data.attrib.get('targetLibrarySectionID') - self.created = data.attrib.get('createdAt') - self.title = data.attrib.get('title') + 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.get('key') + 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') - if data.attrib.get('Video'): - self.video = Video(data.attrib.get('Video')) + self.items = self.findItems(data) - def delete(self): - self._server.query(key='/media/subscription/{}'.format(self.mediaSubscriptionID), method=self._server._session.delete) +@utils.registerPlexObject +class Setting(PlexObject): + """ Represents a single DVRDevice Setting.""" + + TAG = 'Setting' -class TVSession: - def __init__(self, data): - self.data = data - self.ratingKey = data.attrib.get('ratingKey') - self.guid = data.attrib.get('guid') + 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.title = data.attrib.get('title') - self.summary = data.attrib.get('title') - self.ratingCount = data.attrib.get('ratingCount') - self.year = data.attrib.get('year') - self.added = data.attrib.get('addedAt') - self.genuineMediaAnalysis = data.attrib.get('genuineMediaAnalysis') - self.grandparentThumb = data.attrib.get('grandparentThumb') - self.grandparentTitle = data.attrib.get('grandparentTitle') - self.key = data.attrib.get('key') - self.live = data.attrib.get('live') - self.parentIndex = data.attrib.get('parentIndex') - self.media = [MediaItem(item) for item in data.attrib.get('Media')] - - -class Device: - def __init__(self, data): - self.data = data + 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.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.lastSeen = data.attrib.get('lastSeenAt') + 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 = data.attrib.get('tuners') - if data.attrib.get('Channels'): - self.channels = [Channel(channel) for channel in data.attrib.get('Channels')] - if data.attrib.get('Setting'): - self.settings = [Setting(setting) for setting in data.attrib.get('Setting')] + self.tuners = utils.cast(int, data.attrib.get('tuners')) + self.items = self.findItems(data) -class Channel: - def __init__(self, data): - self.data = data - self.deviceId = data.attrib.get('deviceIdentifier') - self.enabled = data.attrib.get('enabled') - self.lineupId = data.attrib.get('lineupIdentifier') +@utils.registerPlexObject +class DVR(DVRDevice): + """ Represents a single DVR.""" + TAG = 'Dvr' -class Setting: - def __init__(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') + 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 MediaFile: - def __init__(self, data): - self.data = data - self.id = data.attrib.get('id') - self.duration = data.attrib.get('duration') - self.audioChannels = data.attrib.get('audioChannels') - self.videoResolution = data.attrib.get('videoResolution') - 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.protocol = data.attrib.get('protocol') - self.begins = data.attrib.get('beginsAt') - self.ends = data.attrib.get('endsAt') - self.onAir = data.attrib.get('onAir') - self.channelID = data.attrib.get('channelID') - self.origin = data.attrib.get('origin') - self.uuid = data.attrib.get('uuid') - self.container = data.attrib.get('container') - self.startOffsetSeconds = data.attrib.get('startOffsetSeconds') - self.endOffsetSeconds = data.attrib.get('endOffsetSeconds') - self.premiere = data.attrib.get('premiere') - - -class MediaItem: - def __init__(self, data): - self.data = data - self.ratingKey = data.attrib.get('ratingKey') - self.key = data.attrib.get('key') - self.skipParent = data.attrib.get('skipParent') - self.guid = data.attrib.get('guid') - self.parentGuid = data.attrib.get('parentGuid') - self.grandparentGuid = data.attrib.get('grandparentGuid') - self.type = data.attrib.get('type') - self.title = data.attrib.get('title') - self.grandparentKey = data.attrib.get('grandparentKey') - self.grandparentTitle = data.attrib.get('grandparentTitle') - self.parentTitle = data.attrib.get('parentTitle') - self.summary = data.attrib.get('summary') - self.parentIndex = data.attrib.get('parentIndex') - self.year = data.attrib.get('year') - self.grandparentThumb = data.attrib.get('grandparentThumb') - self.duration = data.attrib.get('duration') - self.originallyAvailable = data.attrib.get('originallyAvailableAt') - self.added = data.attrib.get('addedAt') - self.onAir = data.attrib.get('onAir') - if data.attrib.get('Media'): - self.media = [MediaFile(item) for item in data.attrib.get('Media')] - if data.attrib.get('Genre'): - self.genres = [Genre(item) for item in data.attrib.get('Genre')] - - -class Genre: - def __init__(self, data): - self.data = data - self.filter = data.attrib.get('filter') - self.id = data.attrib.get('id') - self.tag = data.attrib.get('tag') +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 + super().__init__(server, data) + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.cloud_key = data.attrib.get('machineIdentifier') + + def _get_cloud_key(self): + url = self._server.url(key='/tv.plex.providers.epg.cloud', includeToken=True) + data = requests.get(url=url).json() + if data: + self.cloud_key = data.get('MediaContainer').get('Directory')[1].get('title') + return self.cloud_key + return None + + def dvrs(self): + """ 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 + + def sessions(self): + """ Returns a list of all active live tv session (currently playing) media objects. + """ + return self.fetchItems('/livetv/sessions') + + def directories(self): + """ Returns a list of all :class:`~plexapi.livetv.Directory` objects available to your server. + """ + return self._server.fetchItems(self.cloud_key + '/hubs/discover') + + def _guide_items(self, grid_type: int, beginsAt: int = None, endsAt: int = None): + key = self.cloud_key + '/grid?type=' + str(grid_type) + if beginsAt: + key += '&beginsAt%3C=' + str(beginsAt) # %3C is <, so <= + if endsAt: + key += '&endsAt%3E=' + str(endsAt) # %3E is >, so >= + return self._server.fetchItems(key) + + def movies(self, beginsAt: int = None, endsAt: int = None): + """ Returns a list of all :class:`~plexapi.video.Movie` items on the guide. + + Parameters: + grid_type (int): 1 for movies, 4 for shows + beginsAt (int): Limit results to beginning after UNIX timestamp (epoch). + endsAt (int): Limit results to ending before UNIX timestamp (epoch). + """ + return self._guide_items(grid_type=1, beginsAt=beginsAt, endsAt=endsAt) + + def shows(self, beginsAt: int = None, endsAt: int = None): + """ Returns a list of all :class:`~plexapi.video.Show` items on the guide. + + Parameters: + beginsAt (int): Limit results to beginning after UNIX timestamp (epoch). + endsAt (int): Limit results to ending before UNIX timestamp (epoch). + """ + return self._guide_items(grid_type=4, beginsAt=beginsAt, endsAt=endsAt) + + def guide(self, beginsAt: int = None, endsAt: int = 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 (int): Limit results to beginning after UNIX timestamp (epoch). + endsAt (int): 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 + + def recordings(self): + return self.fetchItems('/media/subscriptions/scheduled') + + def scheduled(self): + return self.fetchItems('/media/subscriptions') diff --git a/plexapi/media.py b/plexapi/media.py index 7a106232e..24a9e848d 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -55,6 +55,15 @@ def _loadData(self, data): self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) 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 2b02e3eba..dd9bad2f1 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -684,11 +684,10 @@ def tidal(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) - def iptv(self): """ 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}) + req = requests.get(self.IPTV + 'hubs/sections/all/', headers={'X-Plex-Token': self._token}) elem = ElementTree.fromstring(req.text) return self.findItems(elem) diff --git a/plexapi/server.py b/plexapi/server.py index e61eb5cbf..82165ba6b 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -107,6 +107,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 data = self.query(self.key, timeout=timeout) super(PlexServer, self).__init__(self, data, self.key) @@ -239,6 +240,15 @@ def _myPlexClientPorts(self): log.warning('Unable to fetch client ports from myPlex: %s', err) return ports + def livetv(self): + """ Returns a :class:`~plexapi.livetv.LiveTV` object using the same + token to access this server. + """ + if self._liveTV is None: + from plexapi.livetv import LiveTV + self._liveTV = LiveTV(token=self._token) + return self._liveTV + def clients(self): """ Returns list of all :class:`~plexapi.client.PlexClient` objects connected to server. """ items = [] @@ -609,4 +619,4 @@ def _loadData(self, data): self._data = data self.accountID = cast(int, data.attrib.get('id')) self.accountKey = data.attrib.get('key') - self.name = data.attrib.get('name') + self.name = data.attrib.get('name') \ No newline at end of file diff --git a/plexapi/video.py b/plexapi/video.py index 1a8825957..d4d478271 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,6 +5,7 @@ from plexapi import media, utils, settings, library from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound +from plexapi.media import Media class Video(PlexPartialObject): @@ -32,6 +33,8 @@ def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data self.listType = 'video' + self.guid = data.attrib.get('guid') + self.year = data.attrib.get('year') self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.key = data.attrib.get('key', '') self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) @@ -55,7 +58,7 @@ def thumbUrl(self): """ Return the first first thumbnail url starting on the most specific thumbnail for that item. """ - thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') + thumb = self.firstAttr('thumb', 'parentThumb', 'grandparentThumb') return self._server.url(thumb, includeToken=True) if thumb else None @property @@ -294,7 +297,7 @@ def _loadData(self, data): self.guid = data.attrib.get('guid') self.originalTitle = data.attrib.get('originalTitle') self.originallyAvailableAt = utils.toDatetime( - data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + data.attrib.get('originallyAvailableAt'), format='%Y-%m-%d %H:%M%S') self.primaryExtraKey = data.attrib.get('primaryExtraKey') self.rating = utils.cast(float, data.attrib.get('rating')) self.ratingImage = data.attrib.get('ratingImage') @@ -703,6 +706,7 @@ def _loadData(self, data): self.rating = utils.cast(float, data.attrib.get('rating')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.year = utils.cast(int, data.attrib.get('year')) + self.live = utils.cast(int, data.attrib.get('live', '0')) self.directors = self.findItems(data, media.Director) self.media = self.findItems(data, media.Media) self.writers = self.findItems(data, media.Writer) @@ -760,6 +764,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(Playable, Video): @@ -784,23 +794,3 @@ def _loadData(self, data): self.title = data.attrib.get('title') self.type = data.attrib.get('type') self.year = data.attrib.get('year') - - -@utils.registerPlexObject -class Directory(Video): - """ Represents a single Directory.""" - - TAG = 'Directory' - TYPE = 'channel' - METADATA_TYPE = 'channel' - - def _loadData(self, data): - self._data = data - 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) - - def __len__(self): - return self.size From 2d88f346ba730dda99da0945e3956d99fc41d362 Mon Sep 17 00:00:00 2001 From: Nate Date: Wed, 2 Sep 2020 21:51:14 -0400 Subject: [PATCH 06/20] new datetimeToTimestamp method, bug fix --- plexapi/utils.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plexapi/utils.py b/plexapi/utils.py index 58e9be0be..f2cd4efa1 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -57,7 +57,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 @@ -204,6 +204,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 @@ -212,7 +221,7 @@ def millisecondToHumanstr(milliseconds): milliseconds (str,int): time duration in milliseconds. """ milliseconds = int(milliseconds) - r = datetime.datetime.utcfromtimestamp(milliseconds / 1000) + r = datetime.utcfromtimestamp(milliseconds / 1000) f = r.strftime("%H:%M:%S.%f") return f[:-2] From c1b77a36d37f7ab3d6a9d8279fa4e447bfc0404f Mon Sep 17 00:00:00 2001 From: Nate Date: Wed, 2 Sep 2020 21:51:51 -0400 Subject: [PATCH 07/20] Line length fix for linter --- plexapi/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plexapi/base.py b/plexapi/base.py index 8c81d5ee6..9bb56e503 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -435,7 +435,8 @@ def delete(self): try: return self._server.query(self.key, method=self._server._session.delete) except BadRequest: # pragma: no cover - log.error("Failed to delete %s. This could be because you haven't allowed items to be deleted" % self.key) + log.error("Failed to delete %s. This could be because you haven't " + "allowed items to be deleted" % self.key) raise def history(self, maxresults=9999999, mindate=None): From c7453d19cf87a416339516aa234331993a7770b3 Mon Sep 17 00:00:00 2001 From: Nate Date: Wed, 2 Sep 2020 21:53:01 -0400 Subject: [PATCH 08/20] Reused sessions, datetime rather than int in guide item methods, proper %s formatting --- plexapi/livetv.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/plexapi/livetv.py b/plexapi/livetv.py index a00a276c8..fceb05ecc 100644 --- a/plexapi/livetv.py +++ b/plexapi/livetv.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import os from urllib.parse import quote_plus, urlencode +from datetime import datetime import requests from plexapi import media, utils, settings, library @@ -161,8 +162,9 @@ def _loadData(self, data): def _get_cloud_key(self): url = self._server.url(key='/tv.plex.providers.epg.cloud', includeToken=True) - data = requests.get(url=url).json() - if data: + data = self._session.get(url=url).json() + if data and data.get('MediaContainer') and data['MediaContainer'].get('Directory')\ + and len(data['MediaContainer']['Directory']) > 1: self.cloud_key = data.get('MediaContainer').get('Directory')[1].get('title') return self.cloud_key return None @@ -184,40 +186,40 @@ def directories(self): """ return self._server.fetchItems(self.cloud_key + '/hubs/discover') - def _guide_items(self, grid_type: int, beginsAt: int = None, endsAt: int = None): - key = self.cloud_key + '/grid?type=' + str(grid_type) + def _guide_items(self, grid_type: int, beginsAt: datetime = None, endsAt: datetime = None): + key = '%s/grid?type=%s' % (self.cloud_key, grid_type) if beginsAt: - key += '&beginsAt%3C=' + str(beginsAt) # %3C is <, so <= + key += '&beginsAt%3C=%s' % utils.datetimeToEpoch(beginsAt) # %3C is <, so <= if endsAt: - key += '&endsAt%3E=' + str(endsAt) # %3E is >, so >= + key += '&endsAt%3E=%s' % utils.datetimeToEpoch(endsAt) # %3E is >, so >= return self._server.fetchItems(key) - def movies(self, beginsAt: int = None, endsAt: int = None): + def movies(self, beginsAt: datetime = None, endsAt: datetime = None): """ Returns a list of all :class:`~plexapi.video.Movie` items on the guide. Parameters: grid_type (int): 1 for movies, 4 for shows - beginsAt (int): Limit results to beginning after UNIX timestamp (epoch). - endsAt (int): Limit results to ending before UNIX timestamp (epoch). + beginsAt (datetime): Limit results to beginning after UNIX timestamp (epoch). + endsAt (datetime): Limit results to ending before UNIX timestamp (epoch). """ return self._guide_items(grid_type=1, beginsAt=beginsAt, endsAt=endsAt) - def shows(self, beginsAt: int = None, endsAt: int = None): + def shows(self, beginsAt: datetime = None, endsAt: datetime = None): """ Returns a list of all :class:`~plexapi.video.Show` items on the guide. Parameters: - beginsAt (int): Limit results to beginning after UNIX timestamp (epoch). - endsAt (int): Limit results to ending before UNIX timestamp (epoch). + beginsAt (datetime): Limit results to beginning after UNIX timestamp (epoch). + endsAt (datetime): Limit results to ending before UNIX timestamp (epoch). """ return self._guide_items(grid_type=4, beginsAt=beginsAt, endsAt=endsAt) - def guide(self, beginsAt: int = None, endsAt: int = None): + 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 (int): Limit results to beginning after UNIX timestamp (epoch). - endsAt (int): Limit results to ending before UNIX timestamp (epoch). + 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 From 4aecf289d5ac25cc5502ac8094edffc43442ec73 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 29 Dec 2020 15:40:35 -0700 Subject: [PATCH 09/20] Grab 'art' attribute for IPTVChannel, bug fix for iptv() --- plexapi/livetv.py | 9 +++++---- plexapi/myplex.py | 8 ++++---- plexapi/server.py | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/plexapi/livetv.py b/plexapi/livetv.py index fceb05ecc..5644485d7 100644 --- a/plexapi/livetv.py +++ b/plexapi/livetv.py @@ -21,6 +21,7 @@ class IPTVChannel(Video): 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') @@ -152,7 +153,7 @@ 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._dvrs = [] # cached DVR objects super().__init__(server, data) def _loadData(self, data): @@ -172,9 +173,9 @@ def _get_cloud_key(self): def dvrs(self): """ 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 + if not self._dvrs: + self._dvrs = self.fetchItems('/livetv/dvrs') + return self._dvrs def sessions(self): """ Returns a list of all active live tv session (currently playing) media objects. diff --git a/plexapi/myplex.py b/plexapi/myplex.py index dd9bad2f1..8bab02314 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -678,16 +678,16 @@ def podcasts(self): return self.findItems(elem) 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) - - def iptv(self): + + def iptv(self): """ 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}) + req = requests.get(self.IPTV + 'hubs/sections/all', headers={'X-Plex-Token': self._token}) elem = ElementTree.fromstring(req.text) return self.findItems(elem) diff --git a/plexapi/server.py b/plexapi/server.py index 82165ba6b..a060d8247 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -246,7 +246,7 @@ def livetv(self): """ if self._liveTV is None: from plexapi.livetv import LiveTV - self._liveTV = LiveTV(token=self._token) + self._liveTV = LiveTV(token=self._token, data=None, server=self) return self._liveTV def clients(self): From 7a7d6f2200b805c40a567e2fb12d910dd03f9abd Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 29 Dec 2020 15:45:03 -0700 Subject: [PATCH 10/20] Fix conflicts --- plexapi/myplex.py | 13 +++++++++++++ plexapi/server.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 8bab02314..38dd7b27e 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -76,6 +76,7 @@ class MyPlexAccount(PlexObject): REQUESTS = 'https://plex.tv/api/invites/requests' # get SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data + LINK = 'https://plex.tv/api/v2/pins/link' # put # Hub sections VOD = 'https://vod.provider.plex.tv/' # get WEBSHOWS = 'https://webshows.provider.plex.tv/' # get @@ -691,6 +692,18 @@ def iptv(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + def link(self, pin): + """ Link a device to the account using a pin code. + + Parameters: + pin (str): The 4 digit link pin code. + """ + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Plex-Product': 'Plex SSO' + } + 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 a060d8247..a480b3927 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -15,7 +15,7 @@ from plexapi.base import PlexObject from plexapi.client import PlexClient from plexapi.exceptions import BadRequest, NotFound, Unauthorized -from plexapi.library import Hub, Library +from plexapi.library import Hub, Library, Path, File from plexapi.media import Conversion, Optimized from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue @@ -249,6 +249,53 @@ def livetv(self): 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. + + Parameters: + path (:class:`~plexapi.library.Path` or str, optional): Full path to browse. + includeFiles (bool): True to include files when browsing (Default). + False to only return folders. + """ + if isinstance(path, Path): + key = path.key + elif path is not None: + base64path = utils.base64str(path) + key = '/services/browse/%s' % base64path + else: + key = '/services/browse' + if includeFiles: + key += '?includeFiles=1' + return self.fetchItems(key) + + def walk(self, path=None): + """ Walk the system file tree using the Plex API similar to `os.walk`. + Yields a 3-tuple `(path, paths, files)` where + `path` is a string of the directory path, + `paths` is a list of :class:`~plexapi.library.Path` objects, and + `files` is a list of :class:`~plexapi.library.File` objects. + + Parameters: + path (:class:`~plexapi.library.Path` or str, optional): Full path to walk. + """ + paths = [] + files = [] + for item in self.browse(path): + if isinstance(item, Path): + paths.append(item) + elif isinstance(item, File): + files.append(item) + + if isinstance(path, Path): + path = path.path + + yield path or '', paths, files + + for _path in paths: + for path, paths, files in self.walk(_path): + yield path, paths, files + def clients(self): """ Returns list of all :class:`~plexapi.client.PlexClient` objects connected to server. """ items = [] From ead72e950c1bd33da30d7cc654c8fe456cabd445 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 15 Mar 2021 23:00:27 -0600 Subject: [PATCH 11/20] Added return type documentation --- plexapi/livetv.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plexapi/livetv.py b/plexapi/livetv.py index 5644485d7..ca758ac36 100644 --- a/plexapi/livetv.py +++ b/plexapi/livetv.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import os +from typing import List from urllib.parse import quote_plus, urlencode from datetime import datetime import requests @@ -7,6 +8,7 @@ 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 @@ -170,18 +172,21 @@ def _get_cloud_key(self): return self.cloud_key return None - def dvrs(self): + @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 - def sessions(self): + @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 directories(self): """ Returns a list of all :class:`~plexapi.livetv.Directory` objects available to your server. """ @@ -228,8 +233,10 @@ def guide(self, beginsAt: datetime = None, endsAt: datetime = None): # all_shows = self.shows(beginsAt, endsAt) # return all_movies + all_shows + @property def recordings(self): return self.fetchItems('/media/subscriptions/scheduled') + @property def scheduled(self): return self.fetchItems('/media/subscriptions') From eb97cb1e4bf598e46c6884d50380ebfe0f07e1d5 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 15 Mar 2021 23:01:06 -0600 Subject: [PATCH 12/20] Made some attributes (news, tidal, iptv, etc) as properties rather than functions --- plexapi/myplex.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 90d664ec1..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 @@ -661,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` """ @@ -668,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` """ @@ -675,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` """ @@ -682,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` """ @@ -689,6 +694,7 @@ 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` """ @@ -696,7 +702,8 @@ def tidal(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) - def iptv(self): + @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}) From faa59da81b06d7b7ecae18a84a4a76168e09e05a Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 15 Mar 2021 23:01:34 -0600 Subject: [PATCH 13/20] Fixed LiveTV import --- plexapi/server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plexapi/server.py b/plexapi/server.py index 8bc6a1ae5..de0d70373 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 @@ -258,12 +259,12 @@ def _myPlexClientPorts(self): log.warning('Unable to fetch client ports from myPlex: %s', err) return ports - def livetv(self): + @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: - from plexapi.livetv import LiveTV self._liveTV = LiveTV(token=self._token, data=None, server=self) return self._liveTV From a1f88b4fbfe6bae6378a6b67d4e197a751412524 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 17 Mar 2021 19:03:05 -0600 Subject: [PATCH 14/20] Added helper methods for XML parsing, xmltodict --- plexapi/base.py | 10 ++++++++++ plexapi/utils.py | 16 ++++++++++++++++ requirements.txt | 1 + 3 files changed, 27 insertions(+) diff --git a/plexapi/base.py b/plexapi/base.py index fbeed391f..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 diff --git a/plexapi/utils.py b/plexapi/utils.py index 6c7ee674a..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: @@ -480,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/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 From a8b1b03755a46984699f7178c65878361b661cf9 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 17 Mar 2021 19:04:18 -0600 Subject: [PATCH 15/20] Abstracted server.query() with private function to get the raw requests.Response object if needed --- plexapi/server.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plexapi/server.py b/plexapi/server.py index de0d70373..d526104f4 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -504,6 +504,14 @@ 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. """ + response = self._queryReturnResponse(key=key, method=method, headers=headers, timeout=timeout, **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 @@ -520,8 +528,7 @@ def query(self, key, method=None, headers=None, timeout=None, **kwargs): raise NotFound(message) else: raise BadRequest(message) - data = response.text.encode('utf8') - return ElementTree.fromstring(data) if data.strip() else None + return response def search(self, query, mediatype=None, limit=None): """ Returns a list of media items or filter categories from the resulting From 963df3aebf52e7303c650bc25a55cf7402584dbb Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 17 Mar 2021 19:04:54 -0600 Subject: [PATCH 16/20] Fixed getting cloud key (now livetv_key) --- plexapi/livetv.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/plexapi/livetv.py b/plexapi/livetv.py index ca758ac36..0af4e9e74 100644 --- a/plexapi/livetv.py +++ b/plexapi/livetv.py @@ -156,21 +156,24 @@ def __init__(self, server, data, session=None, token=None): self._session = session or requests.Session() self._server = server self._dvrs = [] # cached DVR objects + self._livetv_key = None super().__init__(server, data) def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data - self.cloud_key = data.attrib.get('machineIdentifier') + # self.cloud_key = data.attrib.get('machineIdentifier') - def _get_cloud_key(self): - url = self._server.url(key='/tv.plex.providers.epg.cloud', includeToken=True) - data = self._session.get(url=url).json() - if data and data.get('MediaContainer') and data['MediaContainer'].get('Directory')\ - and len(data['MediaContainer']['Directory']) > 1: - self.cloud_key = data.get('MediaContainer').get('Directory')[1].get('title') - return self.cloud_key - return None + @property + def livetv_key(self): + if not self._livetv_key: + response = self._server._queryReturnResponse(key='/tv.plex.providers.epg.xmltv') + data = utils.parseXmlToDict(xml_data_string=response.text) + try: + self._livetv_key = data['MediaContainer']['Directory'][1]['@title'] + except: + pass + return self._livetv_key @property def dvrs(self) -> List[DVR]: @@ -190,10 +193,10 @@ def sessions(self) -> List[Session]: def directories(self): """ Returns a list of all :class:`~plexapi.livetv.Directory` objects available to your server. """ - return self._server.fetchItems(self.cloud_key + '/hubs/discover') + return self._server.fetchItems("/" + self.livetv_key + '/hubs/discover') def _guide_items(self, grid_type: int, beginsAt: datetime = None, endsAt: datetime = None): - key = '%s/grid?type=%s' % (self.cloud_key, grid_type) + key = '%s/grid?type=%s' % (self.livetv_key, grid_type) if beginsAt: key += '&beginsAt%3C=%s' % utils.datetimeToEpoch(beginsAt) # %3C is <, so <= if endsAt: From 605319cce1b7d08b82987de7eea5a428b7ceca98 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 17 Mar 2021 20:15:44 -0600 Subject: [PATCH 17/20] Items and size cached, can be reloaded manually --- plexapi/library.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index 8d3749a80..ce36ab474 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,21 @@ 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): From b660c3b220e2a69e007449570a15da369e205fb8 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 17 Mar 2021 20:16:55 -0600 Subject: [PATCH 18/20] Moved response code check out of queryReturnResponse --- plexapi/server.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plexapi/server.py b/plexapi/server.py index d526104f4..a6ad2bc74 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -505,6 +505,16 @@ def query(self, key, method=None, headers=None, timeout=None, **kwargs): ElementTree object. Returns None if no data exists in the response. """ 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', ' ') + message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + if response.status_code == 401: + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + else: + raise BadRequest(message) data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -518,16 +528,6 @@ def _queryReturnResponse(self, key, method=None, headers=None, timeout=None, **k log.debug('%s %s', method.__name__.upper(), url) headers = self._headers(**headers or {}) response = method(url, 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', ' ') - message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) - if response.status_code == 401: - raise Unauthorized(message) - elif response.status_code == 404: - raise NotFound(message) - else: - raise BadRequest(message) return response def search(self, query, mediatype=None, limit=None): From 6cbf9dfb3b4228f6b9fd45b1c2823ed9c6e836e6 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 17 Mar 2021 20:17:39 -0600 Subject: [PATCH 19/20] Handle both kinds of livetv keys (cloud (ZIP code guide) and xmltv (local XMLTV guide)) --- plexapi/library.py | 2 +- plexapi/livetv.py | 90 +++++++++++++++++++++++++++++++++------------- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index ce36ab474..e5b166899 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -1256,7 +1256,6 @@ def reload(self): self._items = self.fetchItems(self.key) - class HubMediaTag(PlexObject): """ Base class of hub media tag search results. @@ -1526,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 index 0af4e9e74..cafee6144 100644 --- a/plexapi/livetv.py +++ b/plexapi/livetv.py @@ -156,24 +156,42 @@ def __init__(self, server, data, session=None, token=None): self._session = session or requests.Session() self._server = server self._dvrs = [] # cached DVR objects - self._livetv_key = None + 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): - """ Load attribute values from Plex XML response. """ self._data = data - # self.cloud_key = data.attrib.get('machineIdentifier') + + 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 livetv_key(self): - if not self._livetv_key: - response = self._server._queryReturnResponse(key='/tv.plex.providers.epg.xmltv') - data = utils.parseXmlToDict(xml_data_string=response.text) + 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._livetv_key = data['MediaContainer']['Directory'][1]['@title'] + self._xmltv_key = data['MediaContainer']['Directory'][1]['@title'] except: pass - return self._livetv_key + return self._xmltv_key @property def dvrs(self) -> List[DVR]: @@ -190,13 +208,34 @@ def sessions(self) -> List[Session]: return self.fetchItems('/livetv/sessions') @property - def directories(self): - """ Returns a list of all :class:`~plexapi.livetv.Directory` objects available to your server. + def hubs(self): + """ Returns a list of all :class:`~plexapi.livetv.Hub` objects available to your server. """ - return self._server.fetchItems("/" + self.livetv_key + '/hubs/discover') + 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 - def _guide_items(self, grid_type: int, beginsAt: datetime = None, endsAt: datetime = None): - key = '%s/grid?type=%s' % (self.livetv_key, grid_type) + 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: @@ -207,11 +246,15 @@ def movies(self, beginsAt: datetime = None, endsAt: datetime = None): """ Returns a list of all :class:`~plexapi.video.Movie` items on the guide. Parameters: - 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). """ - return self._guide_items(grid_type=1, beginsAt=beginsAt, endsAt=endsAt) + 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. @@ -220,7 +263,12 @@ def shows(self, beginsAt: datetime = None, endsAt: datetime = None): beginsAt (datetime): Limit results to beginning after UNIX timestamp (epoch). endsAt (datetime): Limit results to ending before UNIX timestamp (epoch). """ - return self._guide_items(grid_type=4, beginsAt=beginsAt, endsAt=endsAt) + 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 @@ -235,11 +283,3 @@ def guide(self, beginsAt: datetime = None, endsAt: datetime = None): # Potential show endpoint currently hanging, do not use # all_shows = self.shows(beginsAt, endsAt) # return all_movies + all_shows - - @property - def recordings(self): - return self.fetchItems('/media/subscriptions/scheduled') - - @property - def scheduled(self): - return self.fetchItems('/media/subscriptions') From e395f9e914e3825948821b0e5c61f61997e02a9b Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Wed, 17 Mar 2021 20:24:02 -0600 Subject: [PATCH 20/20] Fixed error when grabbing guide --- plexapi/livetv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/livetv.py b/plexapi/livetv.py index cafee6144..86ca5a737 100644 --- a/plexapi/livetv.py +++ b/plexapi/livetv.py @@ -235,7 +235,7 @@ def _guide_items(self, key, grid_type: int, beginsAt: datetime = None, endsAt: d 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) + key = '/%s/grid?type=%s' % (key, grid_type) if beginsAt: key += '&beginsAt%3C=%s' % utils.datetimeToEpoch(beginsAt) # %3C is <, so <= if endsAt: