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 6 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions plexapi/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
nwithan8 marked this conversation as resolved.
Show resolved Hide resolved
raise

def history(self, maxresults=9999999, mindate=None):
Expand Down
232 changes: 232 additions & 0 deletions plexapi/livetv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
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


@utils.registerPlexObject
class IPTVChannel(Video):
""" Represents a single IPTVChannel."""

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)


@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
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()
nwithan8 marked this conversation as resolved.
Show resolved Hide resolved
if data:
self.cloud_key = data.get('MediaContainer').get('Directory')[1].get('title')
nwithan8 marked this conversation as resolved.
Show resolved Hide resolved
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):
nwithan8 marked this conversation as resolved.
Show resolved Hide resolved
key = self.cloud_key + '/grid?type=' + str(grid_type)
nwithan8 marked this conversation as resolved.
Show resolved Hide resolved
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')
9 changes: 9 additions & 0 deletions plexapi/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
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
8 changes: 8 additions & 0 deletions plexapi/myplex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -682,6 +683,13 @@ 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})
nwithan8 marked this conversation as resolved.
Show resolved Hide resolved
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)


class MyPlexUser(PlexObject):
Expand Down
12 changes: 11 additions & 1 deletion plexapi/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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')
14 changes: 12 additions & 2 deletions plexapi/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Copy link
Collaborator

Choose a reason for hiding this comment

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

Im not sold on the changed to the video class. This is getting reusing many places. Would it be better for subclass used own class for live tv/recordings) ? Im not sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hasn't given me an issue, but I can subclass it just to make sure

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Strike that, the TAG and type are the same for Live videos and non-live videos, so I can't register a new Plex object. Plex doesn't make a distinction between them, so I think it's safe to just add the guid and live attributes, and the record method (checks if video is live first)

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'))
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down