Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LiveTV support (both DVR and free Plex streaming/IPTV) - Requesting code review #543

Draft
wants to merge 23 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
360a781
Initial commit for Live TV support
nwithan8 Mar 16, 2020
1e8b3ef
Initial commit for Live TV support
nwithan8 Mar 16, 2020
35aea18
Merge pull request #1 from pkkid/master
nwithan8 Jul 29, 2020
adea3d3
New Directory class for IPTV channels
nwithan8 Jul 29, 2020
11fea1c
New iptv() method to get Plex Live TV channel hubs
nwithan8 Jul 29, 2020
d86ba0a
Initial commit for limited Live TV (DVR) and IPTV (Free Plex streams)…
nwithan8 Aug 7, 2020
2d88f34
new datetimeToTimestamp method, bug fix
nwithan8 Sep 3, 2020
c1b77a3
Line length fix for linter
nwithan8 Sep 3, 2020
c7453d1
Reused sessions, datetime rather than int in guide item methods, prop…
nwithan8 Sep 3, 2020
4aecf28
Grab 'art' attribute for IPTVChannel, bug fix for iptv()
nwithan8 Dec 29, 2020
7a7d6f2
Fix conflicts
nwithan8 Dec 29, 2020
28e1faf
Merge branch 'master' into master
nwithan8 Dec 29, 2020
afc7c72
Merge branch 'master' into master
nwithan8 Mar 14, 2021
ead72e9
Added return type documentation
nwithan8 Mar 16, 2021
eb97cb1
Made some attributes (news, tidal, iptv, etc) as properties rather th…
nwithan8 Mar 16, 2021
faa59da
Fixed LiveTV import
nwithan8 Mar 16, 2021
a1f88b4
Added helper methods for XML parsing, xmltodict
nwithan8 Mar 18, 2021
a8b1b03
Abstracted server.query() with private function to get the raw reques…
nwithan8 Mar 18, 2021
963df3a
Fixed getting cloud key (now livetv_key)
nwithan8 Mar 18, 2021
605319c
Items and size cached, can be reloaded manually
nwithan8 Mar 18, 2021
b660c3b
Moved response code check out of queryReturnResponse
nwithan8 Mar 18, 2021
6cbf9df
Handle both kinds of livetv keys (cloud (ZIP code guide) and xmltv (l…
nwithan8 Mar 18, 2021
e395f9e
Fixed error when grabbing guide
nwithan8 Mar 18, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 11 additions & 1 deletion plexapi/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 15 additions & 7 deletions plexapi/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -1230,23 +1230,30 @@ 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')

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):
Expand Down Expand Up @@ -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
Expand Down
285 changes: 285 additions & 0 deletions plexapi/livetv.py
Original file line number Diff line number Diff line change
@@ -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
nwithan8 marked this conversation as resolved.
Show resolved Hide resolved
class Setting(PlexObject):
""" Represents a single DVRDevice Setting."""

TAG = 'Setting'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflicts with Setting(PlexObject) in settings.py


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
9 changes: 9 additions & 0 deletions plexapi/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.onAir = **utils.**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')
Expand Down