PlexKodiConnect/resources/lib/playlist_func.py
2019-05-12 14:38:31 +02:00

1310 lines
51 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Collection of functions associated with Kodi and Plex playlists and playqueues
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import threading
from .plex_api import API
from .plex_db import PlexDB
from . import plex_functions as PF
from .playutils import PlayUtils
from .kodi_db import kodiid_from_filename, KodiVideoDB
from .downloadutils import DownloadUtils as DU
from . import utils, json_rpc as js, variables as v, app, widgets
from .windows.resume import resume_dialog
###############################################################################
LOG = getLogger('PLEX.playlist_func')
###############################################################################
class PlaylistError(Exception):
"""
Exception for our playlist constructs
"""
pass
class PlayQueue(object):
"""
PKC object to represent PMS playQueues and Kodi playlist for queueing
playlistid = None [int] Kodi playlist id (0, 1, 2)
type = None [str] Kodi type: 'audio', 'video', 'picture'
kodi_pl = None Kodi xbmc.PlayList object
items = [] [list] of PlaylistItem
id = None [str] Plex playQueueID, unique Plex identifier
version = None [int] Plex version of the playQueue
selectedItemID = None
[str] Plex selectedItemID, playing element in queue
selectedItemOffset = None
[str] Offset of the playing element in queue
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
If Companion playback is initiated by another user:
plex_transient_token = None
"""
kind = 'playQueue'
def __init__(self):
self.id = None
self.type = None
self.playlistid = None
self.kodi_pl = None
self.items = []
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
self.shuffled = 0
self.repeat = 0
self.plex_transient_token = None
# Need a hack for detecting swaps of elements
self.old_kodi_pl = []
# Did PKC itself just change the playqueue so the PKC playqueue monitor
# should not pick up any changes?
self.pkc_edit = False
# Workaround to avoid endless loops of detecting PL clears
self._clear_list = []
# To keep track if Kodi playback was initiated from a Kodi playlist
# There are a couple of pitfalls, unfortunately...
self.kodi_playlist_playback = False
# Playlist position/index used when initiating the playqueue
self.index = None
self.force_transcode = None
def __unicode__(self):
return ("{{"
"'playlistid': {self.playlistid}, "
"'id': {self.id}, "
"'version': {self.version}, "
"'type': '{self.type}', "
"'items': {items}, "
"'selectedItemID': {self.selectedItemID}, "
"'selectedItemOffset': {self.selectedItemOffset}, "
"'shuffled': {self.shuffled}, "
"'repeat': {self.repeat}, "
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
"'pkc_edit': {self.pkc_edit}, "
"}}").format(**{
'items': ['%s/%s: %s' % (x.plex_id, x.id, x.name)
for x in self.items],
'self': self
})
def __str__(self):
return unicode(self).encode('utf-8')
__repr__ = __str__
def is_pkc_clear(self):
"""
Returns True if PKC has cleared the Kodi playqueue just recently.
Then this clear will be ignored from now on
"""
try:
self._clear_list.pop()
except IndexError:
return False
else:
return True
def clear(self, kodi=True):
"""
Resets the playlist object to an empty playlist.
Pass kodi=False in order to NOT clear the Kodi playqueue
"""
# kodi monitor's on_clear method will only be called if there were some
# items to begin with
if kodi and self.kodi_pl.size() != 0:
self._clear_list.append(None)
self.kodi_pl.clear() # Clear Kodi playlist object
self.items = []
self.id = None
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
self.shuffled = 0
self.repeat = 0
self.plex_transient_token = None
self.old_kodi_pl = []
self.kodi_playlist_playback = False
self.index = None
self.force_transcode = None
LOG.debug('Playlist cleared: %s', self)
def play(self, plex_id, plex_type=None, startpos=None, position=None,
synched=True, force_transcode=None):
"""
Initializes the playQueue with e.g. trailers and additional file parts
Pass synched=False if you're sure that this item has not been synched
to Kodi
Or resolves webservice paths to actual paths
"""
LOG.debug('Play called with plex_id %s, plex_type %s, position %s, '
'synched %s, force_transcode %s, startpos %s', plex_id,
plex_type, position, synched, force_transcode, startpos)
resolve = False
try:
if plex_id == self.items[startpos].plex_id:
resolve = True
except IndexError:
pass
if resolve:
LOG.info('Resolving playback')
self._resolve(plex_id, startpos)
else:
LOG.info('Initializing playback')
self.init(plex_id,
plex_type,
startpos,
position,
synched,
force_transcode)
def _resolve(self, plex_id, startpos):
"""
The Plex playqueue has already been initialized. We resolve the path
from original webservice http://127.0.0.1 to the "correct" Plex one
"""
self.index = startpos + 1
xml = PF.GetPlexMetadata(plex_id)
if xml in (None, 401):
raise PlaylistError('Could not get Plex metadata %s for %s',
plex_id, self.items[startpos])
api = API(xml[0])
resume = self._resume_playback(None, xml[0])
self._kodi_add_xml(xml[0], api, resume)
# Add additional file parts, if any exist
self._add_additional_parts(xml)
def init(self, plex_id, plex_type=None, startpos=None, position=None,
synched=True, force_transcode=None):
"""
Initializes the Plex and PKC playqueue for playback
"""
self.index = position
while len(self.items) < self.kodi_pl.size():
# The original item that Kodi put into the playlist, e.g.
# {
# u'title': u'',
# u'type': u'unknown',
# u'file': u'http://127.0.0.1:57578/plex/kodi/....',
# u'label': u''
# }
# We CANNOT delete that item right now - so let's add a dummy
# on the PKC side to keep all indicees lined up.
# The failing item will be deleted in webservice.py
LOG.debug('Adding a dummy item to our playqueue')
self.items.insert(0, PlaylistItemDummy())
self.force_transcode = force_transcode
if synched:
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_id(plex_id, plex_type)
else:
db_item = None
if db_item:
xml = None
section_uuid = db_item['section_uuid']
plex_type = db_item['plex_type']
else:
xml = PF.GetPlexMetadata(plex_id)
if xml in (None, 401):
raise PlaylistError('Could not get Plex metadata %s', plex_id)
section_uuid = xml.get('librarySectionUUID')
api = API(xml[0])
plex_type = api.plex_type()
resume = self._resume_playback(db_item, xml)
trailers = False
if (not resume and plex_type == v.PLEX_TYPE_MOVIE and
utils.settings('enableCinema') == 'true'):
if utils.settings('askCinema') == "true":
# "Play trailers?"
trailers = utils.yesno_dialog(utils.lang(29999),
utils.lang(33016)) or False
else:
trailers = True
LOG.debug('Playing trailers: %s', trailers)
xml = PF.init_plex_playqueue(plex_id,
section_uuid,
plex_type=plex_type,
trailers=trailers)
if xml is None:
LOG.error('Could not get playqueue for plex_id %s UUID %s for %s',
plex_id, section_uuid, self)
raise PlaylistError('Could not get playqueue')
# See that we add trailers, if they exist in the xml return
self._add_intros(xml)
# Add the main item after the trailers
# Look at the LAST item
api = API(xml[-1])
self._kodi_add_xml(xml[-1], api, resume)
# Add additional file parts, if any exist
self._add_additional_parts(xml)
self.update_details_from_xml(xml)
@staticmethod
def _resume_playback(db_item=None, xml=None):
'''
Pass in either db_item or xml
Resume item if available. Returns bool or raise an PlayStrmException if
resume was cancelled by user.
'''
resume = app.PLAYSTATE.resume_playback
app.PLAYSTATE.resume_playback = None
if app.PLAYSTATE.autoplay:
resume = False
LOG.info('Skip resume for autoplay')
elif resume is None:
if db_item:
with KodiVideoDB(lock=False) as kodidb:
resume = kodidb.get_resume(db_item['kodi_fileid'])
else:
api = API(xml)
resume = api.resume_point()
if resume:
resume = resume_dialog(resume)
LOG.info('User chose resume: %s', resume)
if resume is None:
raise PlaylistError('User backed out of resume dialog')
app.PLAYSTATE.autoplay = True
return resume
def _add_intros(self, xml):
'''
if we have any play them when the movie/show is not being resumed.
'''
if not len(xml) > 1:
LOG.debug('No trailers returned from the PMS')
return
for i, intro in enumerate(xml):
if i + 1 == len(xml):
# The main item we're looking at - skip!
break
api = API(intro)
LOG.debug('Adding trailer: %s', api.title())
self._kodi_add_xml(intro, api)
def _add_additional_parts(self, xml):
''' Create listitems and add them to the stack of playlist.
'''
api = API(xml[0])
for part, _ in enumerate(xml[0][0]):
if part == 0:
# The first part that we've already added
continue
api.set_part_number(part)
LOG.debug('Adding addional part for %s: %s', api.title(), part)
self._kodi_add_xml(xml[0], api)
def _kodi_add_xml(self, xml, api, resume=False):
playlistitem = PlaylistItem(xml_video_element=xml)
playlistitem.part = api.part
playlistitem.force_transcode = self.force_transcode
listitem = widgets.get_listitem(xml, resume=True)
listitem.setSubtitles(api.cache_external_subs())
play = PlayUtils(api, playlistitem)
url = play.getPlayUrl()
listitem.setPath(url.encode('utf-8'))
self.kodi_add_item(playlistitem, self.index, listitem)
self.items.insert(self.index, playlistitem)
self.index += 1
def update_details_from_xml(self, xml):
"""
Updates the playlist details from the xml provided
"""
self.id = utils.cast(int, xml.get('%sID' % self.kind))
self.version = utils.cast(int, xml.get('%sVersion' % self.kind))
self.shuffled = utils.cast(int, xml.get('%sShuffled' % self.kind))
self.selectedItemID = utils.cast(int,
xml.get('%sSelectedItemID' % self.kind))
self.selectedItemOffset = utils.cast(int,
xml.get('%sSelectedItemOffset'
% self.kind))
LOG.debug('Updated playlist from xml: %s', self)
def add_item(self, item, pos, listitem=None):
"""
Adds a PlaylistItem to both Kodi and Plex at position pos [int]
Also changes self.items
Raises PlaylistError
"""
self.kodi_add_item(item, pos, listitem)
self.plex_add_item(item, pos)
def kodi_add_item(self, item, pos, listitem=None):
"""
Adds a PlaylistItem to Kodi only. Will not change self.items
Raises PlaylistError
"""
if not isinstance(item, PlaylistItem):
raise PlaylistError('Wrong item %s of type %s received'
% (item, type(item)))
if pos > len(self.items):
raise PlaylistError('Position %s too large for playlist length %s'
% (pos, len(self.items)))
LOG.debug('Adding item to Kodi playlist at position %s: %s', pos, item)
if listitem:
self.kodi_pl.add(url=listitem.getPath(),
listitem=listitem,
index=pos)
elif item.kodi_id is not None and item.kodi_type is not None:
# This method ensures we have full Kodi metadata, potentially
# with more artwork, for example, than Plex provides
if pos == len(self.items):
answ = js.playlist_add(self.playlistid,
{'%sid' % item.kodi_type: item.kodi_id})
else:
answ = js.playlist_insert({'playlistid': self.playlistid,
'position': pos,
'item': {'%sid' % item.kodi_type: item.kodi_id}})
if 'error' in answ:
raise PlaylistError('Kodi did not add item to playlist: %s',
answ)
else:
if item.xml is None:
LOG.debug('Need to get metadata for item %s', item)
item.xml = PF.GetPlexMetadata(item.plex_id)
if item.xml in (None, 401):
raise PlaylistError('Could not get metadata for %s', item)
api = API(item.xml[0])
listitem = widgets.get_listitem(item.xml, resume=True)
url = 'http://127.0.0.1:%s/plex/play/file.strm' % v.WEBSERVICE_PORT
args = {
'plex_id': item.plex_id,
'plex_type': api.plex_type()
}
if item.force_transcode:
args['transcode'] = 'true'
url = utils.extend_url(url, args)
item.file = url
listitem.setPath(url.encode('utf-8'))
self.kodi_pl.add(url=url.encode('utf-8'),
listitem=listitem,
index=pos)
def plex_add_item(self, item, pos):
"""
Adds a new PlaylistItem to the playlist at position pos [int] only on
the Plex side of things. Also changes self.items
Raises PlaylistError
"""
if not isinstance(item, PlaylistItem) or not item.uri:
raise PlaylistError('Wrong item %s of type %s received'
% (item, type(item)))
if pos > len(self.items):
raise PlaylistError('Position %s too large for playlist length %s'
% (pos, len(self.items)))
LOG.debug('Adding item to Plex playlist at position %s: %s', pos, item)
url = '{server}/%ss/%s?uri=%s' % (self.kind, self.id, item.uri)
# Will usually put the new item at the end of the Plex playlist
xml = DU().downloadUrl(url, action_type='PUT')
try:
xml[0].attrib
except (TypeError, AttributeError, KeyError, IndexError):
raise PlaylistError('Could not add item %s to playlist %s'
% (item, self))
for actual_pos, xml_video_element in enumerate(xml):
api = API(xml_video_element)
if api.plex_id() == item.plex_id:
break
else:
raise PlaylistError('Something went wrong - Plex id not found')
item.from_xml(xml[actual_pos])
self.items.insert(actual_pos, item)
self.update_details_from_xml(xml)
if actual_pos != pos:
self.plex_move_item(actual_pos, pos)
LOG.debug('Added item %s on Plex side: %s', item, self)
def kodi_remove_item(self, pos):
"""
Only manipulates the Kodi playlist. Won't change self.items
"""
LOG.debug('Removing position %s on the Kodi side for %s', pos, self)
answ = js.playlist_remove(self.playlistid, pos)
if 'error' in answ:
raise PlaylistError('Could not remove item: %s' % answ['error'])
def plex_move_item(self, before, after):
"""
Moves playlist item from before [int] to after [int] for Plex only.
Will also change self.items
"""
if before > len(self.items) or after > len(self.items) or after == before:
raise PlaylistError('Illegal original position %s and/or desired '
'position %s for playlist length %s' %
(before, after, len(self.items)))
LOG.debug('Moving item from %s to %s on the Plex side for %s',
before, after, self)
if after == 0:
url = "{server}/%ss/%s/items/%s/move?after=0" % \
(self.kind,
self.id,
self.items[before].id)
elif after > before:
url = "{server}/%ss/%s/items/%s/move?after=%s" % \
(self.kind,
self.id,
self.items[before].id,
self.items[after].id)
else:
url = "{server}/%ss/%s/items/%s/move?after=%s" % \
(self.kind,
self.id,
self.items[before].id,
self.items[after - 1].id)
xml = DU().downloadUrl(url, action_type="PUT")
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
raise PlaylistError('Could not move playlist item from %s to %s '
'for %s' % (before, after, self))
self.update_details_from_xml(xml)
self.items.insert(after, self.items.pop(before))
LOG.debug('Done moving items for %s', self)
def init_from_xml(self, xml, offset=None, start_plex_id=None, repeat=None,
transient_token=None):
"""
Play all items contained in the xml passed in. Called by Plex Companion.
Either supply the ratingKey of the starting Plex element. Or set
playqueue.selectedItemID
offset [float]: will seek to position offset after playback start
start_plex_id [int]: the plex_id of the element that should be
played
repeat [int]: 0: don't repear
1: repeat item
2: repeat everything
transient_token [unicode]: temporary token received from the PMS
Will stop current playback and start playback at the end
"""
LOG.debug("init_from_xml called with offset %s, start_plex_id %s",
offset, start_plex_id)
app.APP.player.stop()
self.clear()
self.update_details_from_xml(xml)
self.repeat = 0 if not repeat else repeat
self.plex_transient_token = transient_token
for pos, xml_video_element in enumerate(xml):
playlistitem = PlaylistItem(xml_video_element=xml_video_element)
self.kodi_add_item(playlistitem, pos)
self.items.append(playlistitem)
# Where do we start playback?
if start_plex_id is not None:
for startpos, item in enumerate(self.items):
if item.plex_id == start_plex_id:
break
else:
startpos = 0
else:
for startpos, item in enumerate(self.items):
if item.id == self.selectedItemID:
break
else:
startpos = 0
self.start_playback(pos=startpos, offset=offset)
def start_playback(self, pos=0, offset=0):
"""
Seek immediately after kicking off playback is not reliable.
Threaded, since we need to return BEFORE seeking
"""
LOG.info('Starting playback at %s offset %s for %s', pos, offset, self)
thread = threading.Thread(target=self._threaded_playback,
args=(self.kodi_pl, pos, offset))
thread.start()
@staticmethod
def _threaded_playback(kodi_playlist, pos, offset):
app.APP.player.play(kodi_playlist, startpos=pos, windowed=False)
if offset:
i = 0
while not app.APP.is_playing:
app.APP.monitor.waitForAbort(0.1)
i += 1
if i > 50:
LOG.warn('Could not seek to %s', offset)
return
js.seek_to(offset)
class PlaylistItem(object):
"""
Object to fill our playqueues and playlists with.
id = None [int] Plex playlist/playqueue id, e.g. playQueueItemID
plex_id = None [int] Plex unique item id, "ratingKey"
plex_type = None [str] Plex type, e.g. 'movie', 'clip'
plex_uuid = None [str] Plex librarySectionUUID
kodi_id = None [int] Kodi unique kodi id (unique only within type!)
kodi_type = None [str] Kodi type: 'movie'
file = None [str] Path to the item's file. STRING!!
uri = None [str] Weird Plex uri path involving plex_uuid. STRING!
guid = None [str] Weird Plex guid
xml = None [etree] XML from PMS, 1 lvl below <MediaContainer>
playmethod = None [str] either 'DirectPlay', 'DirectStream', 'Transcode'
playcount = None [int] how many times the item has already been played
offset = None [int] the item's view offset UPON START in Plex time
part = 0 [int] part number if Plex video consists of mult. parts
force_transcode [bool] defaults to False
PlaylistItem compare as equal, if they
- have the same plex_id
- OR: have the same kodi_id AND kodi_type
- OR: have the same file
"""
def __init__(self, plex_id=None, plex_type=None, xml_video_element=None,
kodi_id=None, kodi_type=None, grab_xml=False,
lookup_kodi=True):
"""
Pass grab_xml=True in order to get Plex metadata from the PMS while
passing a plex_id.
Pass lookup_kodi=False to NOT check the plex.db for kodi_id and
kodi_type if they're missing (won't be done for clips anyway)
"""
self.name = None
self.id = None
self.plex_id = plex_id
self.plex_type = plex_type
self.plex_uuid = None
self.kodi_id = kodi_id
self.kodi_type = kodi_type
self.file = None
self.uri = None
self.guid = None
self.xml = None
self.playmethod = None
self.playcount = None
self.offset = None
self.part = 0
self.force_transcode = False
if grab_xml and plex_id is not None and xml_video_element is None:
xml_video_element = PF.GetPlexMetadata(plex_id)
try:
xml_video_element = xml_video_element[0]
except (TypeError, IndexError):
xml_video_element = None
if xml_video_element is not None:
self.from_xml(xml_video_element)
if (lookup_kodi and (kodi_id is None or kodi_type is None) and
self.plex_type != v.PLEX_TYPE_CLIP):
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_id(self.plex_id, self.plex_type)
if db_item is not None:
self.kodi_id = db_item['kodi_id']
self.kodi_type = db_item['kodi_type']
self.plex_uuid = db_item['section_uuid']
self.set_uri()
def __eq__(self, other):
if self.plex_id is not None and other.plex_id is not None:
return self.plex_id == other.plex_id
elif (self.kodi_id is not None and other.kodi_id is not None and
self.kodi_type and other.kodi_type):
return (self.kodi_id == other.kodi_id and
self.kodi_type == other.kodi_type)
elif self.file and other.file:
return self.file == other.file
raise RuntimeError('PlaylistItems not fully defined: %s, %s' %
(self, other))
def __ne__(self, other):
return not self == other
def __unicode__(self):
return ("{{"
"'name': '{self.name}', "
"'id': {self.id}, "
"'plex_id': {self.plex_id}, "
"'plex_type': '{self.plex_type}', "
"'kodi_id': {self.kodi_id}, "
"'kodi_type': '{self.kodi_type}', "
"'file': '{self.file}', "
"'uri': '{self.uri}', "
"'guid': '{self.guid}', "
"'playmethod': '{self.playmethod}', "
"'playcount': {self.playcount}, "
"'offset': {self.offset}, "
"'force_transcode': {self.force_transcode}, "
"'part': {self.part}"
"}}".format(self=self))
def __str__(self):
return unicode(self).encode('utf-8')
__repr__ = __str__
def from_xml(self, xml_video_element):
"""
xml_video_element: etree xml piece 1 level underneath <MediaContainer>
item.id will only be set if you passed in an xml_video_element from
e.g. a playQueue
"""
api = API(xml_video_element)
self.name = api.title()
self.plex_id = api.plex_id()
self.plex_type = api.plex_type()
self.id = api.item_id()
self.guid = api.guid_html_escaped()
self.playcount = api.viewcount()
self.offset = api.resume_point()
self.xml = xml_video_element
def from_kodi(self, playlist_item):
"""
playlist_item: dict contains keys 'id', 'type', 'file' (if applicable)
Will thus set the attributes kodi_id, kodi_type, file, if applicable
If kodi_id & kodi_type are provided, plex_id and plex_type will be
looked up (if not already set)
"""
self.kodi_id = playlist_item.get('id')
self.kodi_type = playlist_item.get('type')
self.file = playlist_item.get('file')
if self.plex_id is None and self.kodi_id is not None and self.kodi_type:
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_kodi_id(self.kodi_id, self.kodi_type)
if db_item:
self.plex_id = db_item['plex_id']
self.plex_type = db_item['plex_type']
self.plex_uuid = db_item['section_uuid']
if self.plex_id is None and self.file is not None:
try:
query = self.file.split('?', 1)[1]
except IndexError:
query = ''
query = dict(utils.parse_qsl(query))
self.plex_id = utils.cast(int, query.get('plex_id'))
self.plex_type = query.get('itemType')
self.set_uri()
LOG.debug('Made playlist item from Kodi: %s', self)
def set_uri(self):
if self.plex_id is None and self.file is not None:
self.uri = ('library://whatever/item/%s'
% utils.quote(self.file, safe=''))
elif self.plex_id is not None and self.plex_uuid is not None:
# TO BE VERIFIED - PLEX DOESN'T LIKE PLAYLIST ADDS IN THIS MANNER
self.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' %
(self.plex_uuid, self.plex_id))
elif self.plex_id is not None:
self.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' %
(self.plex_id, self.plex_id))
else:
self.uri = None
def plex_stream_index(self, kodi_stream_index, stream_type):
"""
Pass in the kodi_stream_index [int] in order to receive the Plex stream
index.
stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful
"""
stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type]
count = 0
if kodi_stream_index == -1:
# Kodi telling us "it's the last one"
iterator = list(reversed(self.xml[0][self.part]))
kodi_stream_index = 0
else:
iterator = self.xml[0][self.part]
# Kodi indexes differently than Plex
for stream in iterator:
if (stream.attrib['streamType'] == stream_type and
'key' in stream.attrib):
if count == kodi_stream_index:
return stream.attrib['id']
count += 1
for stream in iterator:
if (stream.attrib['streamType'] == stream_type and
'key' not in stream.attrib):
if count == kodi_stream_index:
return stream.attrib['id']
count += 1
def kodi_stream_index(self, plex_stream_index, stream_type):
"""
Pass in the kodi_stream_index [int] in order to receive the Plex stream
index.
stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful
"""
stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type]
count = 0
for stream in self.xml[0][self.part]:
if (stream.attrib['streamType'] == stream_type and
'key' in stream.attrib):
if stream.attrib['id'] == plex_stream_index:
return count
count += 1
for stream in self.xml[0][self.part]:
if (stream.attrib['streamType'] == stream_type and
'key' not in stream.attrib):
if stream.attrib['id'] == plex_stream_index:
return count
count += 1
class PlaylistItemDummy(PlaylistItem):
"""
Let e.g. Kodimonitor detect that this is a dummy item
"""
def __init__(self, *args, **kwargs):
super(PlaylistItemDummy, self).__init__(*args, **kwargs)
self.name = 'dummy item'
self.id = 0
self.plex_id = 0
def playlist_item_from_kodi(kodi_item):
"""
Turns the JSON answer from Kodi into a playlist element
Supply with data['item'] as returned from Kodi JSON-RPC interface.
kodi_item dict contains keys 'id', 'type', 'file' (if applicable)
"""
item = PlaylistItem()
item.kodi_id = kodi_item.get('id')
item.kodi_type = kodi_item.get('type')
if item.kodi_id:
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_kodi_id(kodi_item['id'], kodi_item['type'])
if db_item:
item.plex_id = db_item['plex_id']
item.plex_type = db_item['plex_type']
item.plex_uuid = db_item['section_uuid']
item.file = kodi_item.get('file')
if item.plex_id is None and item.file is not None:
try:
query = item.file.split('?', 1)[1]
except IndexError:
query = ''
query = dict(utils.parse_qsl(query))
item.plex_id = utils.cast(int, query.get('plex_id'))
item.plex_type = query.get('itemType')
if item.plex_id is None and item.file is not None:
item.uri = ('library://whatever/item/%s'
% utils.quote(item.file, safe=''))
else:
# TO BE VERIFIED - PLEX DOESN'T LIKE PLAYLIST ADDS IN THIS MANNER
item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' %
(item.plex_uuid, item.plex_id))
LOG.debug('Made playlist item from Kodi: %s', item)
return item
def verify_kodi_item(plex_id, kodi_item):
"""
Tries to lookup kodi_id and kodi_type for kodi_item (with kodi_item['file']
supplied) - if and only if plex_id is None.
Returns the kodi_item with kodi_item['id'] and kodi_item['type'] possibly
set to None if unsuccessful.
Will raise a PlaylistError if plex_id is None and kodi_item['file'] starts
with either 'plugin' or 'http'
"""
if plex_id is not None or kodi_item.get('id') is not None:
# Got all the info we need
return kodi_item
# Special case playlist startup - got type but no id
if (not app.SYNC.direct_paths and app.SYNC.enable_music and
kodi_item.get('type') == v.KODI_TYPE_SONG and
kodi_item['file'].startswith('http')):
kodi_item['id'], _ = kodiid_from_filename(kodi_item['file'],
v.KODI_TYPE_SONG)
LOG.debug('Detected song. Research results: %s', kodi_item)
return kodi_item
# Need more info since we don't have kodi_id nor type. Use file path.
if ((kodi_item['file'].startswith('plugin') and
not kodi_item['file'].startswith('plugin://%s' % v.ADDON_ID)) or
kodi_item['file'].startswith('http')):
LOG.info('kodi_item %s cannot be used for Plex playback', kodi_item)
raise PlaylistError
LOG.debug('Starting research for Kodi id since we didnt get one: %s',
kodi_item)
# Try the VIDEO DB first - will find both movies and episodes
kodi_id, kodi_type = kodiid_from_filename(kodi_item['file'],
db_type='video')
if not kodi_id:
# No movie or episode found - try MUSIC DB now for songs
kodi_id, kodi_type = kodiid_from_filename(kodi_item['file'],
db_type='music')
kodi_item['id'] = kodi_id
kodi_item['type'] = None if kodi_id is None else kodi_type
LOG.debug('Research results for kodi_item: %s', kodi_item)
return kodi_item
def playlist_item_from_plex(plex_id):
"""
Returns a playlist element providing the plex_id ("ratingKey")
Returns a Playlist_Item
"""
item = PlaylistItem()
item.plex_id = plex_id
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_id(plex_id)
if db_item:
item.plex_type = db_item['plex_type']
item.kodi_id = db_item['kodi_id']
item.kodi_type = db_item['kodi_type']
else:
raise KeyError('Could not find plex_id %s in database' % plex_id)
item.plex_uuid = plex_id
item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' %
(item.plex_uuid, plex_id))
LOG.debug('Made playlist item from plex: %s', item)
return item
def playlist_item_from_xml(xml_video_element, kodi_id=None, kodi_type=None):
"""
Returns a playlist element for the playqueue using the Plex xml
xml_video_element: etree xml piece 1 level underneath <MediaContainer>
"""
item = PlaylistItem()
api = API(xml_video_element)
item.plex_id = api.plex_id()
item.plex_type = api.plex_type()
# item.id will only be set if you passed in an xml_video_element from e.g.
# a playQueue
item.id = api.item_id()
if kodi_id is not None and kodi_type is not None:
item.kodi_id = kodi_id
item.kodi_type = kodi_type
item.guid = api.guid_html_escaped()
item.playcount = api.viewcount()
item.offset = api.resume_point()
item.xml = xml_video_element
LOG.debug('Created new playlist item from xml: %s', item)
return item
def _get_playListVersion_from_xml(playlist, xml):
"""
Takes a PMS xml as input to overwrite the playlist version (e.g. Plex
playQueueVersion).
Raises PlaylistError if unsuccessful
"""
playlist.version = utils.cast(int,
xml.get('%sVersion' % playlist.kind))
if playlist.version is None:
raise PlaylistError('Could not get new playlist Version for playlist '
'%s' % playlist)
def get_playlist_details_from_xml(playlist, xml):
"""
Takes a PMS xml as input and overwrites all the playlist's details, e.g.
playlist.id with the XML's playQueueID
Raises PlaylistError if something went wrong.
"""
playlist.id = utils.cast(int,
xml.get('%sID' % playlist.kind))
playlist.version = utils.cast(int,
xml.get('%sVersion' % playlist.kind))
playlist.shuffled = utils.cast(int,
xml.get('%sShuffled' % playlist.kind))
playlist.selectedItemID = utils.cast(int,
xml.get('%sSelectedItemID'
% playlist.kind))
playlist.selectedItemOffset = utils.cast(int,
xml.get('%sSelectedItemOffset'
% playlist.kind))
LOG.debug('Updated playlist from xml: %s', playlist)
def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
"""
Updates Kodi playlist using a new PMS playlist. Pass in playlist_id if we
need to fetch a new playqueue
If an xml is passed in, the playlist will be overwritten with its info
"""
if xml is None:
xml = get_PMS_playlist(playlist, playlist_id)
# Clear our existing playlist and the associated Kodi playlist
playlist.clear()
# Set new values
get_playlist_details_from_xml(playlist, xml)
for plex_item in xml:
playlist_item = add_to_Kodi_playlist(playlist, plex_item)
if playlist_item is not None:
playlist.items.append(playlist_item)
def init_plex_playqueue(playlist, plex_id=None, kodi_item=None):
"""
Initializes the Plex side without changing the Kodi playlists
WILL ALSO UPDATE OUR PLAYLISTS.
Returns the first PKC playlist item or raises PlaylistError
"""
LOG.debug('Initializing the playqueue on the Plex side: %s', playlist)
playlist.clear(kodi=False)
verify_kodi_item(plex_id, kodi_item)
try:
if plex_id:
item = playlist_item_from_plex(plex_id)
else:
item = playlist_item_from_kodi(kodi_item)
params = {
'next': 0,
'type': playlist.type,
'uri': item.uri
}
xml = DU().downloadUrl(url="{server}/%ss" % playlist.kind,
action_type="POST",
parameters=params)
get_playlist_details_from_xml(playlist, xml)
# Need to get the details for the playlist item
item = playlist_item_from_xml(xml[0])
except (KeyError, IndexError, TypeError):
LOG.error('Could not init Plex playlist: plex_id %s, kodi_item %s',
plex_id, kodi_item)
raise PlaylistError
playlist.items.append(item)
LOG.debug('Initialized the playqueue on the Plex side: %s', playlist)
return item
def add_listitem_to_playlist(playlist, pos, listitem, kodi_id=None,
kodi_type=None, plex_id=None, file=None):
"""
Adds a listitem to both the Kodi and Plex playlist at position pos [int].
If file is not None, file will overrule kodi_id!
file: str!!
"""
LOG.debug('add_listitem_to_playlist at position %s. Playlist before add: '
'%s', pos, playlist)
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
if playlist.id is None:
init_plex_playqueue(playlist, plex_id, kodi_item)
else:
add_item_to_plex_playqueue(playlist, pos, plex_id, kodi_item)
if kodi_id is None and playlist.items[pos].kodi_id:
kodi_id = playlist.items[pos].kodi_id
kodi_type = playlist.items[pos].kodi_type
if file is None:
file = playlist.items[pos].file
# Otherwise we double the item!
del playlist.items[pos]
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
add_listitem_to_Kodi_playlist(playlist,
pos,
listitem,
file,
kodi_item=kodi_item)
def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None,
plex_id=None, file=None):
"""
Adds an item to BOTH the Kodi and Plex playlist at position pos [int]
file: str!
Raises PlaylistError if something went wrong
"""
LOG.debug('add_item_to_playlist. Playlist before adding: %s', playlist)
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
if playlist.id is None:
item = init_plex_playqueue(playlist, plex_id, kodi_item)
else:
item = add_item_to_plex_playqueue(playlist, pos, plex_id, kodi_item)
params = {
'playlistid': playlist.playlistid,
'position': pos
}
if item.kodi_id is not None:
params['item'] = {'%sid' % item.kodi_type: int(item.kodi_id)}
else:
params['item'] = {'file': item.file}
reply = js.playlist_insert(params)
if reply.get('error') is not None:
raise PlaylistError('Could not add item to playlist. Kodi reply. %s'
% reply)
return item
def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
"""
Adds a new item to the playlist at position pos [int] only on the Plex
side of things (e.g. because the user changed the Kodi side)
WILL ALSO UPDATE OUR PLAYLISTS
Returns the PKC PlayList item or raises PlaylistError
"""
LOG.debug('Adding item to Plex playqueue with plex id %s, kodi_item %s at '
'position %s', plex_id, kodi_item, pos)
verify_kodi_item(plex_id, kodi_item)
if plex_id:
item = playlist_item_from_plex(plex_id)
else:
item = playlist_item_from_kodi(kodi_item)
url = '{server}/%ss/%s?uri=%s' % (playlist.kind, playlist.id, item.uri)
# Will always put the new item at the end of the Plex playlist
xml = DU().downloadUrl(url, action_type="PUT")
try:
xml[-1].attrib
except (TypeError, AttributeError, KeyError, IndexError):
raise PlaylistError('Could not add item %s to playlist %s'
% (kodi_item, playlist))
if len(xml) != len(playlist.items) + 1:
raise PlaylistError('Couldnt add item %s to playlist %s - wrong length'
% (kodi_item, playlist))
for actual_pos, xml_video_element in enumerate(xml):
api = API(xml_video_element)
if api.plex_id() == item.plex_id:
break
else:
raise PlaylistError('Something went terribly wrong!')
utils.dump_xml(xml)
LOG.debug('Plex added the new item at position %s', actual_pos)
item.xml = xml[actual_pos]
item.id = api.item_id()
item.guid = api.guid_html_escaped()
item.offset = api.resume_point()
item.playcount = api.viewcount()
playlist.items.insert(actual_pos, item)
_get_playListVersion_from_xml(playlist, xml)
if actual_pos != pos:
# Move the new item to the correct position
move_playlist_item(playlist, actual_pos, pos)
LOG.debug('Successfully added item on the Plex side: %s', playlist)
return item
def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None,
file=None, xml_video_element=None):
"""
Adds an item to the KODI playlist only. WILL ALSO UPDATE OUR PLAYLISTS
Returns the playlist item that was just added or raises PlaylistError
file: str!
"""
LOG.debug('Adding new item kodi_id: %s, kodi_type: %s, file: %s to Kodi '
'only at position %s for %s',
kodi_id, kodi_type, file, pos, playlist)
params = {
'playlistid': playlist.playlistid,
'position': pos
}
if kodi_id is not None:
params['item'] = {'%sid' % kodi_type: int(kodi_id)}
else:
params['item'] = {'file': file}
reply = js.playlist_insert(params)
if reply.get('error') is not None:
raise PlaylistError('Could not add item to playlist. Kodi reply. %s',
reply)
if xml_video_element is not None:
item = playlist_item_from_xml(xml_video_element)
item.kodi_id = kodi_id
item.kodi_type = kodi_type
item.file = file
elif kodi_id is not None:
item = playlist_item_from_kodi(
{'id': kodi_id, 'type': kodi_type, 'file': file})
if item.plex_id is not None:
xml = PF.GetPlexMetadata(item.plex_id)
item.xml = xml[-1]
playlist.items.insert(pos, item)
return item
def move_playlist_item(playlist, before_pos, after_pos):
"""
Moves playlist item from before_pos [int] to after_pos [int] for Plex only.
WILL ALSO CHANGE OUR PLAYLISTS.
"""
LOG.debug('Moving item from %s to %s on the Plex side for %s',
before_pos, after_pos, playlist)
if after_pos == 0:
url = "{server}/%ss/%s/items/%s/move?after=0" % \
(playlist.kind,
playlist.id,
playlist.items[before_pos].id)
else:
url = "{server}/%ss/%s/items/%s/move?after=%s" % \
(playlist.kind,
playlist.id,
playlist.items[before_pos].id,
playlist.items[after_pos - 1].id)
# We need to increment the playlistVersion
xml = DU().downloadUrl(url, action_type="PUT")
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not move playlist item')
return
_get_playListVersion_from_xml(playlist, xml)
utils.dump_xml(xml)
# Move our item's position in our internal playlist
playlist.items.insert(after_pos, playlist.items.pop(before_pos))
LOG.debug('Done moving for %s', playlist)
def get_PMS_playlist(playlist=None, playlist_id=None):
"""
Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we
need to fetch a new playlist
Returns None if something went wrong
"""
playlist_id = playlist_id if playlist_id else playlist.id
if playlist and playlist.kind == 'playList':
xml = DU().downloadUrl("{server}/playlists/%s/items" % playlist_id)
else:
xml = DU().downloadUrl("{server}/playQueues/%s" % playlist_id)
try:
xml.attrib
except AttributeError:
xml = None
return xml
def refresh_playlist_from_PMS(playlist):
"""
Only updates the selected item from the PMS side (e.g.
playQueueSelectedItemID). Will NOT check whether items still make sense.
"""
get_playlist_details_from_xml(playlist, get_PMS_playlist(playlist))
def delete_playlist_item_from_PMS(playlist, pos):
"""
Delete the item at position pos [int] on the Plex side and our playlists
"""
LOG.debug('Deleting position %s for %s on the Plex side', pos, playlist)
xml = DU().downloadUrl("{server}/%ss/%s/items/%s?repeat=%s" %
(playlist.kind,
playlist.id,
playlist.items[pos].id,
playlist.repeat),
action_type="DELETE")
_get_playListVersion_from_xml(playlist, xml)
del playlist.items[pos]
# Functions operating on the Kodi playlist objects ##########
def add_to_Kodi_playlist(playlist, xml_video_element):
"""
Adds a new item to the Kodi playlist via JSON (at the end of the playlist).
Pass in the PMS xml's video element (one level underneath MediaContainer).
Returns a Playlist_Item or raises PlaylistError
"""
item = playlist_item_from_xml(xml_video_element)
if item.kodi_id:
json_item = {'%sid' % item.kodi_type: item.kodi_id}
else:
json_item = {'file': item.file}
reply = js.playlist_add(playlist.playlistid, json_item)
if reply.get('error') is not None:
raise PlaylistError('Could not add item %s to Kodi playlist. Error: '
'%s', xml_video_element, reply)
return item
def add_listitem_to_Kodi_playlist(playlist, pos, listitem, file,
xml_video_element=None, kodi_item=None):
"""
Adds an xbmc listitem to the Kodi playlist.xml_video_element
WILL NOT UPDATE THE PLEX SIDE, BUT WILL UPDATE OUR PLAYLISTS
file: string!
"""
LOG.debug('Insert listitem at position %s for Kodi only for %s',
pos, playlist)
# Add the item into Kodi playlist
playlist.kodi_pl.add(url=file, listitem=listitem, index=pos)
# We need to add this to our internal queue as well
if xml_video_element is not None:
item = playlist_item_from_xml(xml_video_element)
else:
item = playlist_item_from_kodi(kodi_item)
if file is not None:
item.file = file
playlist.items.insert(pos, item)
LOG.debug('Done inserting for %s', playlist)
return item
def remove_from_kodi_playlist(playlist, pos):
"""
Removes the item at position pos from the Kodi playlist using JSON.
WILL NOT UPDATE THE PLEX SIDE, BUT WILL UPDATE OUR PLAYLISTS
"""
LOG.debug('Removing position %s from Kodi only from %s', pos, playlist)
reply = js.playlist_remove(playlist.playlistid, pos)
if reply.get('error') is not None:
LOG.error('Could not delete the item from the playlist. Error: %s',
reply)
return
try:
del playlist.items[pos]
except IndexError:
LOG.error('Cannot delete position %s for %s', pos, playlist)
def get_pms_playqueue(playqueue_id):
"""
Returns the Plex playqueue as an etree XML or None if unsuccessful
"""
xml = DU().downloadUrl(
"{server}/playQueues/%s" % playqueue_id,
headerOptions={'Accept': 'application/xml'})
try:
xml.attrib
except AttributeError:
LOG.error('Could not download Plex playqueue %s', playqueue_id)
xml = None
return xml
def get_plextype_from_xml(xml):
"""
Needed if PMS returns an empty playqueue. Will get the Plex type from the
empty playlist playQueueSourceURI. Feed with (empty) etree xml
returns None if unsuccessful
"""
try:
plex_id = utils.REGEX_PLEX_ID_FROM_URL.findall(
xml.attrib['playQueueSourceURI'])[0]
except IndexError:
LOG.error('Could not get plex_id from xml: %s', xml.attrib)
return
new_xml = PF.GetPlexMetadata(plex_id)
try:
new_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not get plex metadata for plex id %s', plex_id)
return
return new_xml[0].attrib.get('type').decode('utf-8')