PlexKodiConnect/resources/lib/entrypoint.py

549 lines
22 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Loads of different functions called in SEPARATE Python instances through
e.g. plugin://... calls. Hence be careful to only rely on window variables.
"""
from logging import getLogger
import sys
import copy
import xml.etree.ElementTree as etree
import xbmc
import xbmcplugin
from xbmcgui import ListItem
from . import utils
from . import path_ops
from .downloadutils import DownloadUtils as DU
from .plex_api import API, mass_api
from . import plex_functions as PF
from . import variables as v
# Be careful - your using app in another Python instance!
from . import app, widgets
from .library_sync.nodes import NODE_TYPES
LOG = getLogger('PLEX.entrypoint')
def guess_video_or_audio():
"""
Returns either 'video', 'audio' or 'image', based how the user navigated to
the current view.
Returns None if this failed, e.g. when the user picks widgets
"""
content_type = None
if xbmc.getCondVisibility('Window.IsActive(Videos)'):
content_type = 'video'
elif xbmc.getCondVisibility('Window.IsActive(Music)'):
content_type = 'audio'
elif xbmc.getCondVisibility('Window.IsActive(Pictures)'):
content_type = 'image'
elif xbmc.getCondVisibility('Container.Content(movies)'):
content_type = 'video'
elif xbmc.getCondVisibility('Container.Content(episodes)'):
content_type = 'video'
elif xbmc.getCondVisibility('Container.Content(seasons)'):
content_type = 'video'
elif xbmc.getCondVisibility('Container.Content(tvshows)'):
content_type = 'video'
elif xbmc.getCondVisibility('Container.Content(albums)'):
content_type = 'audio'
elif xbmc.getCondVisibility('Container.Content(artists)'):
content_type = 'audio'
elif xbmc.getCondVisibility('Container.Content(songs)'):
content_type = 'audio'
elif xbmc.getCondVisibility('Container.Content(pictures)'):
content_type = 'image'
LOG.debug('Guessed content type: %s', content_type)
return content_type
def _wait_for_auth():
"""
Call to be sure that PKC is authenticated, e.g. for widgets on Kodi startup.
Will wait for at most 30s, then fail if not authenticated. Will set
xbmcplugin.endOfDirectory(int(argv[1]), False) if failed
WARNING - this will potentially stall the shutdown of Kodi since we cannot
poll xbmc.Monitor().abortRequested() or waitForAbort()
"""
counter = 0
startupdelay = int(utils.settings('startupDelay') or 0)
# Wait for <startupdelay in seconds> + 10 seconds at most
startupdelay = 10 * startupdelay + 100
while utils.window('plex_authenticated') != 'true':
counter += 1
if counter == startupdelay:
LOG.error('Aborting view, we were not authenticated for PMS')
xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
return False
xbmc.sleep(100)
return True
def directory_item(label, path, folder=True):
"""
Adds a xbmcplugin.addDirectoryItem() directory itemlistitem
"""
listitem = ListItem(label, path=path)
listitem.setArt(
{'landscape':'special://home/addons/plugin.video.plexkodiconnect/fanart.jpg',
'fanart': 'special://home/addons/plugin.video.plexkodiconnect/fanart.jpg',
'thumb': 'special://home/addons/plugin.video.plexkodiconnect/icon.png'})
xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]),
url=path,
listitem=listitem,
isFolder=folder)
def show_main_menu(content_type=None):
"""
Shows the main PKC menu listing with all libraries, Channel, settings, etc.
"""
content_type = content_type or guess_video_or_audio()
LOG.debug('Do main listing for %s', content_type)
xbmcplugin.setContent(int(sys.argv[1]), v.CONTENT_TYPE_FILE)
# Get nodes from the window props
totalnodes = int(utils.window('Plex.nodes.total') or 0)
for i in range(totalnodes):
path = utils.window('Plex.nodes.%s.index' % i)
if not path:
continue
label = utils.window('Plex.nodes.%s.title' % i)
node_type = utils.window('Plex.nodes.%s.type' % i)
# because we do not use seperate entrypoints for each content type,
# we need to figure out which items to show in each listing. for
# now we just only show picture nodes in the picture library video
# nodes in the video library and all nodes in any other window
if node_type == v.CONTENT_TYPE_PHOTO and content_type == 'image':
directory_item(label, path)
elif node_type in (v.CONTENT_TYPE_ARTIST,
v.CONTENT_TYPE_ALBUM,
v.CONTENT_TYPE_SONG) and content_type == 'audio':
directory_item(label, path)
elif node_type in (v.CONTENT_TYPE_MOVIE,
v.CONTENT_TYPE_SHOW,
v.CONTENT_TYPE_MUSICVIDEO) and content_type == 'video':
directory_item(label, path)
elif content_type is None:
# To let the user pick this node as a WIDGET (content_type is None)
# Should only be called if the user selects widgets
LOG.info('Detected user selecting widgets')
directory_item(label, path)
# Playlists
if content_type != 'image':
path = 'plugin://%s?mode=playlists' % v.ADDON_ID
if content_type:
path += '&content_type=%s' % content_type
directory_item(utils.lang(136), path)
# Plex Hub
path = 'plugin://%s?mode=hub' % v.ADDON_ID
if content_type:
path += '&content_type=%s' % content_type
directory_item('Plex Hub', path)
# Plex Search "Search"
directory_item(utils.lang(137), "plugin://%s?mode=search" % v.ADDON_ID)
# Plex Watch later
if content_type not in ('image', 'audio'):
directory_item(utils.lang(39211),
"plugin://%s?mode=watchlater" % v.ADDON_ID)
# Plex Channels
directory_item(utils.lang(30173), "plugin://%s?mode=channels" % v.ADDON_ID)
# Plex user switch
directory_item('%s%s' % (utils.lang(39200), utils.settings('username')),
"plugin://%s?mode=switchuser" % v.ADDON_ID)
# some extra entries for settings and stuff
directory_item(utils.lang(39201), "plugin://%s?mode=settings" % v.ADDON_ID)
directory_item(utils.lang(39204),
"plugin://%s?mode=manualsync" % v.ADDON_ID)
xbmcplugin.endOfDirectory(int(sys.argv[1]))
def show_section(section_index):
"""
Displays menu for an entire Plex section. We're using add-on paths instead
of Kodi video library xmls to be able to use type="filter" library xmls
and thus set the "content"
Only used for synched Plex sections - otherwise, PMS xml for the section
is used directly
"""
LOG.debug('Do section listing for section index %s', section_index)
xbmcplugin.setContent(int(sys.argv[1]), v.CONTENT_TYPE_FILE)
# Get nodes from the window props
node = 'Plex.nodes.%s' % section_index
content = utils.window('%s.type' % node)
plex_type = v.PLEX_TYPE_MOVIE if content == v.CONTENT_TYPE_MOVIE \
else v.PLEX_TYPE_SHOW
for node_type, _, _, _, _ in NODE_TYPES[plex_type]:
label = utils.window('%s.%s.title' % (node, node_type))
path = utils.window('%s.%s.index' % (node, node_type))
directory_item(label, path)
xbmcplugin.endOfDirectory(int(sys.argv[1]))
def show_listing(xml, plex_type=None, section_id=None, synched=True, key=None):
"""
Pass synched=False if the items have not been synched to the Kodi DB
Kodi content type will be set using the very first item returned by the PMS
"""
try:
xml[0]
except IndexError:
LOG.info('xml received from the PMS is empty: %s, %s',
xml.tag, xml.attrib)
xbmcplugin.endOfDirectory(int(sys.argv[1]))
return
api = API(xml[0])
# Determine content type for Kodi's Container.content
if key == '/hubs/home/continueWatching':
# Mix of movies and episodes
plex_type = v.PLEX_TYPE_VIDEO
elif key == '/hubs/home/recentlyAdded?type=2':
# "Recently Added TV", potentially a mix of Seasons and Episodes
plex_type = v.PLEX_TYPE_VIDEO
elif api.plex_type is None and api.fast_key and '?collection=' in api.fast_key:
# Collections/Kodi sets
plex_type = v.PLEX_TYPE_SET
elif api.plex_type is None and plex_type:
# e.g. browse by folder - folders will be listed first
# Retain plex_type
pass
else:
plex_type = api.plex_type
content_type = v.CONTENT_FROM_PLEX_TYPE[plex_type]
LOG.debug('show_listing: section_id %s, synched %s, key %s, plex_type %s, '
'content type %s',
section_id, synched, key, plex_type, content_type)
xbmcplugin.setContent(int(sys.argv[1]), content_type)
# Initialization
widgets.PLEX_TYPE = plex_type
widgets.SYNCHED = synched
if plex_type == v.PLEX_TYPE_EPISODE and key and 'onDeck' in key:
widgets.APPEND_SHOW_TITLE = utils.settings('OnDeckTvAppendShow') == 'true'
widgets.APPEND_SXXEXX = utils.settings('OnDeckTvAppendSeason') == 'true'
if plex_type == v.PLEX_TYPE_EPISODE and key and 'recentlyAdded' in key:
widgets.APPEND_SHOW_TITLE = utils.settings('RecentTvAppendShow') == 'true'
widgets.APPEND_SXXEXX = utils.settings('RecentTvAppendSeason') == 'true'
if api.tag == 'Playlist':
# Only show video playlists if navigation started for videos
# and vice-versa for audio playlists
content = guess_video_or_audio()
if content:
for entry in reversed(xml):
tmp_api = API(entry)
if tmp_api.playlist_type() != content:
xml.remove(entry)
if xml.get('librarySectionID'):
widgets.SECTION_ID = utils.cast(int, xml.get('librarySectionID'))
elif section_id:
widgets.SECTION_ID = utils.cast(int, section_id)
if xml.get('viewGroup') == 'secondary':
# Need to chain keys for navigation
widgets.KEY = key
# Process all items to show
all_items = mass_api(xml)
all_items = [widgets.generate_item(api) for api in all_items]
all_items = [widgets.prepare_listitem(item) for item in all_items]
# fill that listing...
all_items = [widgets.create_listitem(item) for item in all_items]
xbmcplugin.addDirectoryItems(int(sys.argv[1]), all_items, len(all_items))
# end directory listing
xbmcplugin.addSortMethod(int(sys.argv[1]), xbmcplugin.SORT_METHOD_UNSORTED)
xbmcplugin.endOfDirectory(handle=int(sys.argv[1]))
def get_video_files(plex_id, params):
"""
GET VIDEO EXTRAS FOR LISTITEM
returns the video files for the item as plugin listing, can be used for
browsing the actual files or videoextras etc.
"""
if plex_id is None:
filename = params.get('filename')
if filename is not None:
filename = filename[0]
import re
regex = re.compile(r'''library/metadata/(\d+)''')
filename = regex.findall(filename)
try:
plex_id = filename[0]
except IndexError:
pass
if plex_id is None:
LOG.info('No Plex ID found, abort getting Extras')
return xbmcplugin.endOfDirectory(int(sys.argv[1]))
if not _wait_for_auth():
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
app.init(entrypoint=True)
item = PF.GetPlexMetadata(plex_id)
try:
path = item[0][0][0].attrib['file']
except (TypeError, IndexError, AttributeError, KeyError):
LOG.error('Could not get file path for item %s', plex_id)
return xbmcplugin.endOfDirectory(int(sys.argv[1]))
# Assign network protocol
if path.startswith('\\\\'):
path = path.replace('\\\\', 'smb://')
path = path.replace('\\', '/')
# Plex returns Windows paths as e.g. 'c:\slfkjelf\slfje\file.mkv'
elif '\\' in path:
path = path.replace('\\', '\\\\')
# Directory only, get rid of filename
path = path.replace(path_ops.path.basename(path), '')
if path_ops.exists(path):
for root, dirs, files in path_ops.walk(path):
for directory in dirs:
item_path = path_ops.path.join(root, directory)
listitem = ListItem(item_path, path=item_path)
xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]),
url=item_path,
listitem=listitem,
isFolder=True)
for file in files:
item_path = path_ops.path.join(root, file)
listitem = ListItem(item_path, path=item_path)
xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]),
url=file,
listitem=listitem)
break
else:
LOG.error('Kodi cannot access folder %s', path)
xbmcplugin.endOfDirectory(int(sys.argv[1]))
@utils.catch_exceptions(warnuser=False)
def extra_fanart(plex_id, plex_path):
"""
Get extrafanart for listitem
will be called by skinhelper script to get the extrafanart
for tvshows we get the plex_id just from the path
"""
LOG.debug('Called with plex_id: %s, plex_path: %s', plex_id, plex_path)
if not plex_id:
if "plugin.video.plexkodiconnect" in plex_path:
plex_id = plex_path.split("/")[-2]
if not plex_id:
LOG.error('Could not get a plex_id, aborting')
return xbmcplugin.endOfDirectory(int(sys.argv[1]))
# We need to store the images locally for this to work
# because of the caching system in xbmc
fanart_dir = path_ops.translate_path("special://thumbnails/plex/%s/"
% plex_id)
if not _wait_for_auth():
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
if not path_ops.exists(fanart_dir):
# Download the images to the cache directory
path_ops.makedirs(fanart_dir)
app.init(entrypoint=True)
xml = PF.GetPlexMetadata(plex_id)
if xml is None:
LOG.error('Could not download metadata for %s', plex_id)
return xbmcplugin.endOfDirectory(int(sys.argv[1]))
api = API(xml[0])
backdrops = api.artwork()['Backdrop']
for count, backdrop in enumerate(backdrops):
# Same ordering as in artwork
art_file = path_ops.path.join(fanart_dir, "fanart%.3d.jpg" % count)
listitem = ListItem("%.3d" % count, path=art_file)
xbmcplugin.addDirectoryItem(
handle=int(sys.argv[1]),
url=art_file,
listitem=listitem)
path_ops.copyfile(backdrop, art_file)
else:
LOG.info("Found cached backdrop.")
# Use existing cached images
fanart_dir = fanart_dir
for root, _, files in path_ops.walk(fanart_dir):
for file in files:
art_file = path_ops.path.join(root, file)
listitem = ListItem(file, path=art_file)
xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]),
url=art_file,
listitem=listitem)
xbmcplugin.endOfDirectory(int(sys.argv[1]))
def playlists(content_type):
"""
Lists all Plex playlists of the media type plex_playlist_type
content_type: 'audio', 'video'
"""
LOG.debug('Listing Plex playlists for content type %s', content_type)
if not _wait_for_auth():
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
app.init(entrypoint=True)
from .playlists.pms import all_playlists
xml = all_playlists()
if xml is None:
return xbmcplugin.endOfDirectory(handle=int(sys.argv[1]))
if content_type is not None:
# This will be skipped if user selects a widget
# Buggy xml.remove(child) requires reversed()
for entry in reversed(xml):
api = API(entry)
if not api.playlist_type() == content_type:
xml.remove(entry)
show_listing(xml)
def hub(content_type):
"""
Plus hub endpoint pms:port/hubs. Need to separate Kodi types with
content_type:
audio, video, image
"""
content_type = content_type or guess_video_or_audio()
LOG.debug('Showing Plex Hub entries for %s', content_type)
if not _wait_for_auth():
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
app.init(entrypoint=True)
xml = PF.get_plex_hub()
try:
xml.attrib
except AttributeError:
LOG.error('Could not get Plex hub listing')
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
# We need to make sure that only entries that WORK are displayed
# WARNING: using xml.remove(child) in for-loop requires traversing from
# the end!
pkc_cont_watching = None
for entry in reversed(xml):
api = API(entry)
append = False
if content_type == 'video' and api.plex_type in v.PLEX_VIDEOTYPES:
append = True
elif content_type == 'audio' and api.plex_type in v.PLEX_AUDIOTYPES:
append = True
elif content_type == 'image' and api.plex_type == v.PLEX_TYPE_PHOTO:
append = True
elif content_type != 'image' and api.plex_type == v.PLEX_TYPE_PLAYLIST:
append = True
elif content_type is None:
# Needed for widgets, where no content_type is provided
append = True
if not append:
xml.remove(entry)
# HACK ##################
# Merge Plex's "Continue watching" with "On deck"
if entry.get('key') == '/hubs/home/continueWatching':
pkc_cont_watching = copy.deepcopy(entry)
pkc_cont_watching.set('key', '/hubs/continueWatching')
title = pkc_cont_watching.get('title') or 'Continue Watching'
pkc_cont_watching.set('title', 'PKC %s' % title)
if pkc_cont_watching:
for i, entry in enumerate(xml):
if entry.get('key') == '/hubs/home/continueWatching':
xml.insert(i + 1, pkc_cont_watching)
break
# END HACK ##################
show_listing(xml)
def watchlater():
"""
Listing for plex.tv Watch Later section (if signed in to plex.tv)
"""
if not _wait_for_auth():
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
if utils.window('plex_token') == '':
LOG.error('No watch later - not signed in to plex.tv')
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
if utils.window('plex_restricteduser') == 'true':
LOG.error('No watch later - restricted user')
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
app.init(entrypoint=True)
xml = DU().downloadUrl('https://plex.tv/pms/playlists/queue/all',
authenticate=False,
headerOptions={'X-Plex-Token': utils.window('plex_token')})
if xml in (None, 401):
LOG.error('Could not download watch later list from plex.tv')
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
show_listing(xml)
def browse_plex(key=None, plex_type=None, section_id=None, synched=True,
args=None, prompt=None, query=None):
"""
Lists the content of a Plex folder, e.g. channels. Either pass in key (to
be used directly for PMS url {server}<key>) or the section_id
Pass synched=False if the items have NOT been synched to the Kodi DB
"""
LOG.debug('Browsing to key %s, section %s, plex_type: %s, synched: %s, '
'prompt "%s", args %s', key, section_id, plex_type, synched,
prompt, args)
if not _wait_for_auth():
xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
return
app.init(entrypoint=True)
args = args or {}
if query:
args['query'] = query
elif prompt:
prompt = utils.dialog('input', prompt)
if prompt is None:
# User cancelled
return
prompt = prompt.strip()
args['query'] = prompt
xml = DU().downloadUrl(utils.extend_url('{server}%s' % key, args))
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not browse to key %s, section %s',
key, section_id)
return
if xml[0].tag == 'Hub':
# E.g. when hitting the endpoint '/hubs/search'
answ = etree.Element(xml.tag, attrib=xml.attrib)
for hub in xml:
if not utils.cast(int, hub.get('size')):
# Empty category
continue
for entry in hub:
api = API(entry)
if api.plex_type == v.PLEX_TYPE_TAG:
# Append the type before the actual element for all "tags"
# like genres, actors, etc.
entry.attrib['tag'] = '%s: %s' % (hub.get('title'),
api.tag_label())
answ.append(entry)
xml = answ
show_listing(xml, plex_type, section_id, synched, key)
def extras(plex_id):
"""
Lists all extras for plex_id
"""
if not _wait_for_auth():
return xbmcplugin.endOfDirectory(int(sys.argv[1]), False)
app.init(entrypoint=True)
xml = PF.GetPlexMetadata(plex_id)
try:
xml[0].attrib
except (TypeError, IndexError, KeyError):
xbmcplugin.endOfDirectory(int(sys.argv[1]))
return
extras = API(xml[0]).extras()
if extras is None:
return
for child in xml:
xml.remove(child)
for i, child in enumerate(extras):
xml.insert(i, child)
show_listing(xml, synched=False, plex_type=v.PLEX_TYPE_MOVIE)