Merge pull request #722 from croneter/widget_overhaul

Giant overhaul of widgets
This commit is contained in:
croneter 2019-03-10 18:01:13 +01:00 committed by GitHub
commit bda58deb6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 2216 additions and 1624 deletions

View file

@ -41,33 +41,18 @@ class Main():
elif mode == 'plex_node': elif mode == 'plex_node':
self.play() self.play()
elif mode == 'ondeck':
entrypoint.on_deck_episodes(itemid,
params.get('tagname'),
int(params.get('limit')))
elif mode == 'recentepisodes':
entrypoint.recent_episodes(params.get('type'),
params.get('tagname'),
int(params.get('limit')))
elif mode == 'nextup':
entrypoint.next_up_episodes(params['tagname'],
int(params['limit']))
elif mode == 'inprogressepisodes':
entrypoint.in_progress_episodes(params['tagname'],
int(params['limit']))
elif mode == 'browseplex': elif mode == 'browseplex':
entrypoint.browse_plex(key=params.get('key'), entrypoint.browse_plex(key=params.get('key'),
plex_section_id=params.get('id')) plex_type=params.get('plex_type'),
section_id=params.get('section_id'),
synched=params.get('synched') != 'false',
prompt=params.get('prompt'))
elif mode == 'watchlater': elif mode == 'watchlater':
entrypoint.watchlater() entrypoint.watchlater()
elif mode == 'channels': elif mode == 'channels':
entrypoint.channels() entrypoint.browse_plex(key='/channels/all')
elif mode == 'route_to_extras': elif mode == 'route_to_extras':
# Hack so we can store this path in the Kodi DB # Hack so we can store this path in the Kodi DB
@ -86,20 +71,23 @@ class Main():
xbmc.executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID) xbmc.executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID)
elif mode == 'enterPMS': elif mode == 'enterPMS':
entrypoint.create_new_pms() LOG.info('Request to manually enter new PMS address')
transfer.plex_command('enter_new_pms_address')
elif mode == 'reset': elif mode == 'reset':
transfer.plex_command('RESET-PKC') transfer.plex_command('RESET-PKC')
elif mode == 'togglePlexTV': elif mode == 'togglePlexTV':
entrypoint.toggle_plex_tv_sign_in() LOG.info('Toggle of Plex.tv sign-in requested')
transfer.plex_command('toggle_plex_tv_sign_in')
elif mode == 'passwords': elif mode == 'passwords':
from resources.lib.windows import direct_path_sources from resources.lib.windows import direct_path_sources
direct_path_sources.start() direct_path_sources.start()
elif mode == 'switchuser': elif mode == 'switchuser':
entrypoint.switch_plex_user() LOG.info('Plex home user switch requested')
transfer.plex_command('switch_plex_user')
elif mode in ('manualsync', 'repair'): elif mode in ('manualsync', 'repair'):
if mode == 'repair': if mode == 'repair':
@ -114,7 +102,8 @@ class Main():
transfer.plex_command('textures-scan') transfer.plex_command('textures-scan')
elif mode == 'chooseServer': elif mode == 'chooseServer':
entrypoint.choose_pms_server() LOG.info("Choosing PMS server requested, starting")
transfer.plex_command('choose_pms_server')
elif mode == 'deviceid': elif mode == 'deviceid':
self.deviceid() self.deviceid()
@ -139,7 +128,7 @@ class Main():
entrypoint.playlists(params.get('content_type')) entrypoint.playlists(params.get('content_type'))
elif mode == 'hub': elif mode == 'hub':
entrypoint.hub(params.get('type')) entrypoint.hub(params.get('content_type'))
elif mode == 'select-libraries': elif mode == 'select-libraries':
LOG.info('User requested to select Plex libraries') LOG.info('User requested to select Plex libraries')

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,26 @@ from __future__ import absolute_import, division, unicode_literals
from json import loads, dumps from json import loads, dumps
from xbmc import executeJSONRPC from xbmc import executeJSONRPC
from . import timing from . import kodi_constants, timing, variables as v
JSON_FROM_KODITYPE = {
v.KODI_TYPE_MOVIE: ('VideoLibrary.GetMovieDetails',
kodi_constants.FIELDS_MOVIES),
v.KODI_TYPE_SHOW: ('VideoLibrary.GetTVShowDetails',
kodi_constants.FIELDS_TVSHOWS),
v.KODI_TYPE_SEASON: ('VideoLibrary.GetSeasonDetails',
kodi_constants.FIELDS_SEASON),
v.KODI_TYPE_EPISODE: ('VideoLibrary.GetEpisodeDetails',
kodi_constants.FIELDS_EPISODES),
v.KODI_TYPE_ARTIST: ('AudioLibrary.GetArtistDetails',
kodi_constants.FIELDS_ARTISTS),
v.KODI_TYPE_ALBUM: ('AudioLibrary.GetAlbumDetails',
kodi_constants.FIELDS_ALBUMS),
v.KODI_TYPE_SONG: ('AudioLibrary.GetSongDetails',
kodi_constants.FIELDS_SONGS),
v.KODI_TYPE_SET: ('VideoLibrary.GetMovieSetDetails',
[]),
}
class JsonRPC(object): class JsonRPC(object):
@ -557,3 +576,16 @@ def settings_setsettingvalue(setting, value):
'setting': setting, 'setting': setting,
'value': value 'value': value
}) })
def item_details(kodi_id, kodi_type):
'''
Returns the Kodi item dict for this item
'''
json, fields = JSON_FROM_KODITYPE[kodi_type]
ret = JsonRPC(json).execute({'%sid' % kodi_type: kodi_id,
'properties': fields})
try:
return ret['result']['%sdetails' % kodi_type]
except (KeyError, TypeError):
return {}

View file

@ -0,0 +1,92 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
'''
script.module.metadatautils
kodi_constants.py
Several common constants for use with Kodi json api
'''
FIELDS_BASE = ['dateadded', 'file', 'lastplayed', 'plot', 'title', 'art',
'playcount']
FIELDS_FILE = FIELDS_BASE + ['streamdetails', 'director', 'resume', 'runtime']
FIELDS_MOVIES = FIELDS_FILE + ['plotoutline', 'sorttitle', 'cast', 'votes',
'showlink', 'top250', 'trailer', 'year', 'country', 'studio', 'set',
'genre', 'mpaa', 'setid', 'rating', 'tag', 'tagline', 'writer',
'originaltitle', 'imdbnumber', 'uniqueid']
FIELDS_TVSHOWS = FIELDS_BASE + ['sorttitle', 'mpaa', 'premiered', 'year',
'episode', 'watchedepisodes', 'votes', 'rating', 'studio', 'season',
'genre', 'cast', 'episodeguide', 'tag', 'originaltitle', 'imdbnumber']
FIELDS_SEASON = ['art', 'playcount', 'season', 'showtitle', 'episode',
'tvshowid', 'watchedepisodes', 'userrating', 'fanart', 'thumbnail']
FIELDS_EPISODES = FIELDS_FILE + ['cast', 'productioncode', 'rating', 'votes',
'episode', 'showtitle', 'tvshowid', 'season', 'firstaired', 'writer',
'originaltitle']
FIELDS_MUSICVIDEOS = FIELDS_FILE + ['genre', 'artist', 'tag', 'album', 'track',
'studio', 'year']
FIELDS_FILES = FIELDS_FILE + ['plotoutline', 'sorttitle', 'cast', 'votes',
'trailer', 'year', 'country', 'studio', 'genre', 'mpaa', 'rating',
'tagline', 'writer', 'originaltitle', 'imdbnumber', 'premiered', 'episode',
'showtitle', 'firstaired', 'watchedepisodes', 'duration', 'season']
FIELDS_SONGS = ['artist', 'displayartist', 'title', 'rating', 'fanart',
'thumbnail', 'duration', 'disc', 'playcount', 'comment', 'file', 'album',
'lastplayed', 'genre', 'musicbrainzartistid', 'track', 'dateadded']
FIELDS_ALBUMS = ['title', 'fanart', 'thumbnail', 'genre', 'displayartist',
'artist', 'musicbrainzalbumartistid', 'year', 'rating', 'artistid',
'musicbrainzalbumid', 'theme', 'description', 'type', 'style', 'playcount',
'albumlabel', 'mood', 'dateadded']
FIELDS_ARTISTS = ['born', 'formed', 'died', 'style', 'yearsactive', 'mood',
'fanart', 'thumbnail', 'musicbrainzartistid', 'disbanded', 'description',
'instrument']
FIELDS_RECORDINGS = ['art', 'channel', 'directory', 'endtime', 'file', 'genre',
'icon', 'playcount', 'plot', 'plotoutline', 'resume', 'runtime',
'starttime', 'streamurl', 'title']
FIELDS_CHANNELS = ['broadcastnow', 'channeltype', 'hidden', 'locked',
'lastplayed', 'thumbnail', 'channel']
FILTER_UNWATCHED = {
'operator': 'lessthan',
'field': 'playcount',
'value': '1'
}
FILTER_WATCHED = {
'operator': 'isnot',
'field': 'playcount',
'value': '0'
}
FILTER_RATING = {
'operator': 'greaterthan',
'field': 'rating',
'value': '7'
}
FILTER_RATING_MUSIC = {
'operator': 'greaterthan',
'field': 'rating',
'value': '3'
}
FILTER_INPROGRESS = {
'operator': 'true',
'field': 'inprogress',
'value': ''
}
SORT_RATING = {
'method': 'rating',
'order': 'descending'
}
SORT_RANDOM = {
'method': 'random',
'order': 'descending'
}
SORT_TITLE = {
'method': 'title',
'order': 'ascending'
}
SORT_DATEADDED = {
'method': 'dateadded',
'order': 'descending'
}
SORT_LASTPLAYED = {
'method': 'lastplayed',
'order': 'descending'
}
SORT_EPISODE = {
'method': 'episode'
}

View file

@ -7,5 +7,4 @@ from .websocket import store_websocket_message, process_websocket_messages, \
WEBSOCKET_MESSAGES, PLAYSTATE_SESSIONS WEBSOCKET_MESSAGES, PLAYSTATE_SESSIONS
from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED
from .fanart import FanartThread, FanartTask from .fanart import FanartThread, FanartTask
from .videonodes import VideoNodes from .sections import force_full_sync, delete_files, clear_window_vars
from .sections import force_full_sync

View file

@ -166,14 +166,14 @@ class FullSync(common.fullsync_mixin):
app.SYNC.path_verified = False app.SYNC.path_verified = False
try: try:
# Sync new, updated and deleted items # Sync new, updated and deleted items
iterator = section['iterator'] iterator = section.iterator
# Tell the processing thread about this new section # Tell the processing thread about this new section
queue_info = InitNewSection(section['context'], queue_info = InitNewSection(section.context,
iterator.total, iterator.total,
iterator.get('librarySectionTitle', iterator.get('librarySectionTitle',
iterator.get('title1')), iterator.get('title1')),
section['section_id'], section.section_id,
section['plex_type']) section.plex_type)
self.queue.put(queue_info) self.queue.put(queue_info)
last = True last = True
# To keep track of the item-number in order to kill while loops # To keep track of the item-number in order to kill while loops
@ -206,37 +206,37 @@ class FullSync(common.fullsync_mixin):
@utils.log_time @utils.log_time
def playstate_per_section(self, section): def playstate_per_section(self, section):
LOG.debug('Processing %s playstates for library section %s', LOG.debug('Processing %s playstates for library section %s',
section['iterator'].total, section) section.iterator.total, section)
try: try:
# Sync new, updated and deleted items # Sync new, updated and deleted items
iterator = section['iterator'] iterator = section.iterator
# Tell the processing thread about this new section # Tell the processing thread about this new section
queue_info = InitNewSection(section['context'], queue_info = InitNewSection(section.context,
iterator.total, iterator.total,
section['section_name'], section.name,
section['section_id'], section.section_id,
section['plex_type']) section.plex_type)
self.queue.put(queue_info) self.queue.put(queue_info)
self.total = iterator.total self.total = iterator.total
self.section_name = section['section_name'] self.section_name = section.name
self.section_type_text = utils.lang( self.section_type_text = utils.lang(
v.TRANSLATION_FROM_PLEXTYPE[section['plex_type']]) v.TRANSLATION_FROM_PLEXTYPE[section.plex_type])
self.current = 0 self.current = 0
last = True last = True
loop = common.tag_last(iterator) loop = common.tag_last(iterator)
while True: while True:
with section['context'](self.current_sync) as itemtype: with section.context(self.current_sync) as itemtype:
for i, (last, xml_item) in enumerate(loop): for i, (last, xml_item) in enumerate(loop):
if self.isCanceled(): if self.isCanceled():
return False return False
if not itemtype.update_userdata(xml_item, section['plex_type']): if not itemtype.update_userdata(xml_item, section.plex_type):
# Somehow did not sync this item yet # Somehow did not sync this item yet
itemtype.add_update(xml_item, itemtype.add_update(xml_item,
section_name=section['section_name'], section_name=section.name,
section_id=section['section_id']) section_id=section.section_id)
itemtype.plexdb.update_last_sync(int(xml_item.attrib['ratingKey']), itemtype.plexdb.update_last_sync(int(xml_item.attrib['ratingKey']),
section['plex_type'], section.plex_type,
self.current_sync) self.current_sync)
self.current += 1 self.current += 1
self.update_progressbar() self.update_progressbar()
@ -256,28 +256,27 @@ class FullSync(common.fullsync_mixin):
try: try:
for kind in kinds: for kind in kinds:
for section in (x for x in sections.SECTIONS for section in (x for x in sections.SECTIONS
if x['plex_type'] == kind[1]): if x.section_type == kind[1]):
if self.isCanceled(): if self.isCanceled():
return return
if not section['sync_to_kodi']: if not section.sync_to_kodi:
LOG.info('User chose to not sync section %s', section) LOG.info('User chose to not sync section %s', section)
continue continue
element = copy.deepcopy(section) element = copy.deepcopy(section)
element['section_type'] = element['plex_type'] element.plex_type = kind[0]
element['plex_type'] = kind[0] element.section_type = element.plex_type
element['element_type'] = kind[1] element.context = kind[2]
element['context'] = kind[2] element.get_children = kind[3]
element['get_children'] = kind[3]
if self.repair or all_items: if self.repair or all_items:
updated_at = None updated_at = None
else: else:
updated_at = section['last_sync'] - UPDATED_AT_SAFETY \ updated_at = section.last_sync - UPDATED_AT_SAFETY \
if section['last_sync'] else None if section.last_sync else None
try: try:
element['iterator'] = PF.SectionItems(section['section_id'], element.iterator = PF.SectionItems(section.section_id,
plex_type=kind[0], plex_type=element.plex_type,
updated_at=updated_at, updated_at=updated_at,
last_viewed_at=None) last_viewed_at=None)
except RuntimeError: except RuntimeError:
LOG.warn('Sync at least partially unsuccessful') LOG.warn('Sync at least partially unsuccessful')
self.successful = False self.successful = False
@ -317,10 +316,10 @@ class FullSync(common.fullsync_mixin):
if section is None: if section is None:
break break
# Setup our variables # Setup our variables
self.plex_type = section['plex_type'] self.plex_type = section.plex_type
self.section_type = section['section_type'] self.section_type = section.section_type
self.context = section['context'] self.context = section.context
self.get_children = section['get_children'] self.get_children = section.get_children
# Now do the heavy lifting # Now do the heavy lifting
if self.isCanceled() or not self.addupdate_section(section): if self.isCanceled() or not self.addupdate_section(section):
return False return False
@ -329,7 +328,7 @@ class FullSync(common.fullsync_mixin):
# some items from the PMS # some items from the PMS
with PlexDB() as plexdb: with PlexDB() as plexdb:
# Set the new time mark for the next delta sync # Set the new time mark for the next delta sync
plexdb.update_section_last_sync(section['section_id'], plexdb.update_section_last_sync(section.section_id,
self.current_sync) self.current_sync)
common.update_kodi_library(video=True, music=True) common.update_kodi_library(video=True, music=True)
# In order to not delete all your songs again # In order to not delete all your songs again
@ -361,10 +360,10 @@ class FullSync(common.fullsync_mixin):
if section is None: if section is None:
break break
# Setup our variables # Setup our variables
self.plex_type = section['plex_type'] self.plex_type = section.plex_type
self.section_type = section['section_type'] self.section_type = section.section_type
self.context = section['context'] self.context = section.context
self.get_children = section['get_children'] self.get_children = section.get_children
# Now do the heavy lifting # Now do the heavy lifting
if self.isCanceled() or not self.playstate_per_section(section): if self.isCanceled() or not self.playstate_per_section(section):
return False return False
@ -410,9 +409,6 @@ class FullSync(common.fullsync_mixin):
@utils.log_time @utils.log_time
def _run(self): def _run(self):
self.current_sync = timing.plex_now() self.current_sync = timing.plex_now()
# Delete playlist and video node files from Kodi
utils.delete_playlists()
utils.delete_nodes()
# Get latest Plex libraries and build playlist and video node files # Get latest Plex libraries and build playlist and video node files
if not sections.sync_from_pms(self): if not sections.sync_from_pms(self):
return return

View file

@ -0,0 +1,373 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
import urllib
from ..utils import etree
from .. import variables as v, utils
ICON_PATH = 'special://home/addons/plugin.video.plexkodiconnect/icon.png'
RECOMMENDED_SCORE_LOWER_BOUND = 7
# Logic of the following nodes:
# (node_type,
# label/node name,
# args for PKC add-on callback,
# Kodi "content",
# Bool: does this node's xml even point back to PKC add-on callback?
# )
NODE_TYPES = {
v.PLEX_TYPE_MOVIE: (
('ondeck',
utils.lang(39500), # "On Deck"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/onDeck',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
True),
('recent',
utils.lang(30174), # "Recently Added"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/recentlyAdded',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
False),
('all',
'{self.name}', # We're using this section's name
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/all',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
False),
('recommended',
utils.lang(30230), # "Recommended"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}&%s'
% urllib.urlencode({'sort': 'rating:desc'})),
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
False),
('genres',
utils.lang(135), # "Genres"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/genre',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
False),
('sets',
utils.lang(39501), # "Collections"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/collection',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
False),
('random',
utils.lang(30227), # "Random"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}&%s'
% urllib.urlencode({'sort': 'random'})),
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
False),
('lastplayed',
utils.lang(568), # "Last played"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/recentlyViewed',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
False),
('browse',
utils.lang(39702), # "Browse by folder"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/folder',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
True),
('more',
utils.lang(22082), # "More..."
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'movies',
True),
),
###########################################################
v.PLEX_TYPE_SHOW: (
('ondeck',
utils.lang(39500), # "On Deck"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/onDeck',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'episodes',
True),
('recent',
utils.lang(30174), # "Recently Added"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/recentlyAdded',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'episodes',
False),
('all',
'{self.name}', # We're using this section's name
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/all',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'tvshows',
False),
('recommended',
utils.lang(30230), # "Recommended"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}&%s'
% urllib.urlencode({'sort': 'rating:desc'})),
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'tvshows',
False),
('genres',
utils.lang(135), # "Genres"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/genre',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'tvshows',
False),
('sets',
utils.lang(39501), # "Collections"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/collection',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'tvshows',
True), # There are no sets/collections for shows with Kodi
('random',
utils.lang(30227), # "Random"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}&%s'
% urllib.urlencode({'sort': 'random'})),
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'tvshows',
False),
('lastplayed',
utils.lang(568), # "Last played"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}/recentlyViewed&%s'
% urllib.urlencode({'type': v.PLEX_TYPE_NUMBER_FROM_PLEX_TYPE[v.PLEX_TYPE_EPISODE]})),
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'episodes',
False),
('browse',
utils.lang(39702), # "Browse by folder"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/folder',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'episodes',
True),
('more',
utils.lang(22082), # "More..."
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}'
},
'episodes',
True),
),
}
def node_pms(section, node_name, args):
"""
Nodes where the logic resides with the PMS - we're NOT building an
xml that filters and sorts, but point to PKC add-on path
"""
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'folder'})
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml, 'path').text = section.addon_path(args)
return xml
def node_recent(section, node_name):
xml = etree.Element('node',
attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'descending'}).text = 'dateadded'
return xml
def node_all(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'ascending'}).text = 'sorttitle'
return xml
def node_recommended(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
# rule = etree.SubElement(xml, 'rule', attrib={'field': 'rating',
# 'operator': 'greaterthan'})
# etree.SubElement(rule, 'value').text = unicode(RECOMMENDED_SCORE_LOWER_BOUND)
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'descending'}).text = 'rating'
return xml
def node_genres(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'ascending'}).text = 'sorttitle'
etree.SubElement(xml, 'group').text = 'genres'
return xml
def node_sets(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
# "Collections"
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'ascending'}).text = 'sorttitle'
etree.SubElement(xml, 'group').text = 'sets'
return xml
def node_random(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'ascending'}).text = 'random'
return xml
def node_lastplayed(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
rule = etree.SubElement(xml, 'rule', attrib={'field': 'playcount',
'operator': 'greaterthan'})
etree.SubElement(rule, 'value').text = 0
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'descending'}).text = 'lastplayed'
return xml

View file

@ -2,25 +2,404 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
import urllib
import copy import copy
from . import videonodes from . import nodes
from ..utils import cast
from ..plex_db import PlexDB from ..plex_db import PlexDB
from ..plex_api import API
from .. import kodi_db from .. import kodi_db
from .. import itemtypes from .. import itemtypes, path_ops
from .. import plex_functions as PF, music, utils, variables as v, app from .. import plex_functions as PF, music, utils, variables as v, app
from ..utils import etree
LOG = getLogger('PLEX.sync.sections') LOG = getLogger('PLEX.sync.sections')
BATCH_SIZE = 500 BATCH_SIZE = 500
VNODES = videonodes.VideoNodes()
PLAYLISTS = {}
NODES = {}
SECTIONS = [] SECTIONS = []
# Need a way to interrupt # Need a way to interrupt our synching process
IS_CANCELED = None IS_CANCELED = None
LIBRARY_PATH = path_ops.translate_path('special://profile/library/video/')
# The video library might not yet exist for this user - create it
if not path_ops.exists(LIBRARY_PATH):
path_ops.copy_tree(
src=path_ops.translate_path('special://xbmc/system/library/video'),
dst=LIBRARY_PATH,
preserve_mode=0) # dont copy permission bits so we have write access!
PLAYLISTS_PATH = path_ops.translate_path("special://profile/playlists/video/")
if not path_ops.exists(PLAYLISTS_PATH):
path_ops.makedirs(PLAYLISTS_PATH)
# Windows variables we set for each node
WINDOW_ARGS = ('index', 'title', 'id', 'path', 'type', 'content', 'artwork')
class Section(object):
"""
Setting the attribute section_type will automatically set content and
sync_to_kodi
"""
def __init__(self, index=None, xml_element=None, section_db_element=None):
# Unique Plex id of this Plex library section
self._section_id = None # int
# Building block for window variable
self._node = None # unicode
# Index of this section (as section_id might not be subsequent)
# This follows 1:1 the sequence in with the PMS returns the sections
self._index = None # Codacy-bug
self.index = index # int
# This section's name for the user to display
self.name = None # unicode
# Library type section (NOT the same as the KODI_TYPE_...)
# E.g. 'movies', 'tvshows', 'episodes'
self.content = None # unicode
# Setting the section_type WILL re_set sync_to_kodi!
self._section_type = None # unicode
# Do we sync all items of this section to the Kodi DB?
# This will be set with section_type!!
self.sync_to_kodi = None # bool
# For sections to be synched, the section name will be recorded as a
# tag. This is the corresponding id for this tag
self.kodi_tagid = None # int
# When was this section last successfully/completely synched to the
# Kodi database?
self.last_sync = None # int
# Path to the Kodi userdata library FOLDER for this section
self._path = None # unicode
# Path to the smart playlist for this section
self._playlist_path = None
# "Poster" for this section
self.icon = None # unicode
# Background image for this section
self.artwork = None
# Thumbnail for this section, similar for each section type
self.thumb = None
# Order number in which xmls will be listed inside Kodei
self.order = None
# Original PMS xml for this section, including children
self.xml = None
# Attributes that will be initialized later by full_sync.py
self.iterator = None
self.context = None
self.get_children = None
# A section_type encompasses possible several plex_types! E.g. shows
# contain shows, seasons, episodes
self.plex_type = None
if xml_element is not None:
self.from_xml(xml_element)
elif section_db_element:
self.from_db_element(section_db_element)
def __repr__(self):
return ("{{"
"'index': {self.index}, "
"'name': '{self.name}', "
"'section_id': {self.section_id}, "
"'section_type': '{self.section_type}', "
"'sync_to_kodi': {self.sync_to_kodi}, "
"'last_sync': {self.last_sync}"
"}}").format(self=self)
__str__ = __repr__
def __nonzero__(self):
return (self.section_id is not None and
self.name is not None and
self.section_type is not None)
def __eq__(self, section):
return (self.section_id == section.section_id and
self.name == section.name and
self.section_type == section.section_type)
__ne__ = not __eq__
@property
def section_id(self):
return self._section_id
@section_id.setter
def section_id(self, value):
self._section_id = value
self._path = path_ops.path.join(LIBRARY_PATH, 'Plex-%s' % value, '')
self._playlist_path = path_ops.path.join(PLAYLISTS_PATH,
'Plex %s.xsp' % value)
@property
def section_type(self):
return self._section_type
@section_type.setter
def section_type(self, value):
self._section_type = value
self.content = v.MEDIATYPE_FROM_PLEX_TYPE[value]
# Default values whether we sync or not based on the Plex type
if value == v.PLEX_TYPE_PHOTO:
self.sync_to_kodi = False
elif not app.SYNC.enable_music and value == v.PLEX_TYPE_ARTIST:
self.sync_to_kodi = False
else:
self.sync_to_kodi = True
@property
def index(self):
return self._index
@index.setter
def index(self, value):
self._index = value
self._node = 'Plex.nodes.%s' % value
@property
def node(self):
return self._node
@property
def path(self):
return self._path
@property
def playlist_path(self):
return self._playlist_path
def from_db_element(self, section_db_element):
self.section_id = section_db_element['section_id']
self.name = section_db_element['section_name']
self.section_type = section_db_element['plex_type']
self.kodi_tagid = section_db_element['kodi_tagid']
self.sync_to_kodi = section_db_element['sync_to_kodi']
self.last_sync = section_db_element['last_sync']
def from_xml(self, xml_element):
"""
Reads section from a PMS xml (Plex id, name, Plex type)
"""
api = API(xml_element)
self.section_id = utils.cast(int, xml_element.get('key'))
self.name = api.title()
self.section_type = api.plex_type()
self.icon = api.one_artwork('composite')
self.artwork = api.one_artwork('art')
self.thumb = api.one_artwork('thumb')
self.xml = xml_element
def from_plex_db(self, section_id, plexdb=None):
"""
Reads section with id section_id from the plex.db
"""
if plexdb:
section = plexdb.section(section_id)
else:
with PlexDB(lock=False) as plexdb:
section = plexdb.section(section_id)
if section:
self.from_db_element(section)
def to_plex_db(self, plexdb=None):
"""
Writes this Section to the plex.db, potentially overwriting
(INSERT OR REPLACE)
"""
if not self:
raise RuntimeError('Section not clearly defined: %s' % self)
if plexdb:
plexdb.add_section(self.section_id,
self.name,
self.section_type,
self.kodi_tagid,
self.sync_to_kodi,
self.last_sync)
else:
with PlexDB(lock=False) as plexdb:
plexdb.add_section(self.section_id,
self.name,
self.section_type,
self.kodi_tagid,
self.sync_to_kodi,
self.last_sync)
def addon_path(self, args):
"""
Returns the plugin path pointing back to PKC for key in order to browse
args is a dict. Its values may contain string info of the form
{key: '{self.<Section attribute>}'}
"""
args = copy.deepcopy(args)
for key, value in args.iteritems():
args[key] = value.format(self=self)
return 'plugin://plugin.video.plexkodiconnect?%s' % urllib.urlencode(args)
def to_kodi(self):
"""
Writes this section's nodes to the library folder in the Kodi userdata
directory
Won't do anything if self.sync_to_kodi is not True
"""
if self.index is None:
raise RuntimeError('Index not initialized')
# Main list entry for this section - which will show the different
# nodes as "submenus" once the user navigates into this section
args = {
'mode': 'browseplex',
'key': '/library/sections/%s' % self.section_id,
'plex_type': self.section_type,
'section_id': unicode(self.section_id)
}
if not self.sync_to_kodi:
args['synched'] = 'false'
addon_path = self.addon_path(args)
if self.sync_to_kodi and self.section_type in v.PLEX_VIDEOTYPES:
path = 'library://video/Plex-%s' % self.section_id
else:
# No xmls to link to - let's show the listings on the fly
path = addon_path
utils.window('%s.title' % self.node, value=self.name)
utils.window('%s.type' % self.node, value=self.content)
utils.window('%s.content' % self.node, value=path)
utils.window('%s.path' % self.node,
value='ActivateWindow(Videos,%s,return)' % path)
utils.window('%s.id' % self.node, value=str(self.section_id))
# To let the user navigate into this node when selecting widgets
utils.window('%s.addon_path' % self.node, value=addon_path)
if not self.sync_to_kodi:
self.remove_files_from_kodi()
return
if self.section_type == v.PLEX_TYPE_ARTIST:
# Todo: Write window variables for music
return
if self.section_type == v.PLEX_TYPE_PHOTO:
# Todo: Write window variables for photos
return
# Create a dedicated directory for this section
if not path_ops.exists(self.path):
path_ops.makedirs(self.path)
# Create a tag just like the section name in the Kodi DB
with kodi_db.KodiVideoDB(lock=False) as kodidb:
self.kodi_tagid = kodidb.create_tag(self.name)
# The xmls are numbered in order of appearance
self.order = 0
if not path_ops.exists(path_ops.path.join(self.path, 'index.xml')):
LOG.debug('Creating index.xml for section %s', self.name)
xml = etree.Element('node',
attrib={'order': unicode(self.order)})
etree.SubElement(xml, 'label').text = self.name
etree.SubElement(xml, 'icon').text = self.icon or nodes.ICON_PATH
self._write_xml(xml, 'index.xml')
self.order += 1
# Create the one smart playlist for this section
if not path_ops.exists(self.playlist_path):
self._write_playlist()
# Now build all nodes for this section - potentially creating xmls
for node in nodes.NODE_TYPES[self.section_type]:
self._build_node(*node)
def _build_node(self, node_type, node_name, args, content, pms_node):
self.content = content
node_name = node_name.format(self=self)
xml_name = '%s_%s.xml' % (self.section_id, node_type)
path = path_ops.path.join(self.path, xml_name)
if not path_ops.exists(path):
if pms_node:
# Even the xml will point back to the PKC add-on
xml = nodes.node_pms(self, node_name, args)
else:
# Let's use Kodi's logic to sort/filter the Kodi library
xml = getattr(nodes, 'node_%s' % node_type)(self, node_name)
self._write_xml(xml, xml_name)
self.order += 1
self._window_node(path, node_name, node_type)
def _write_xml(self, xml, xml_name):
LOG.debug('Creating xml for section %s: %s', self.name, xml_name)
utils.indent(xml)
etree.ElementTree(xml).write(path_ops.path.join(self.path, xml_name),
encoding='utf-8',
xml_declaration=True)
def _write_playlist(self):
LOG.debug('Creating smart playlist for section %s: %s',
self.name, self.playlist_path)
xml = etree.Element('smartplaylist',
attrib={'type': v.MEDIATYPE_FROM_PLEX_TYPE[self.section_type]})
etree.SubElement(xml, 'name').text = self.name
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = self.name
utils.indent(xml)
etree.ElementTree(xml).write(self.playlist_path, encoding='utf-8')
def _window_node(self, path, node_name, node_type):
"""
Will save this section's node to the Kodi window variables
Uses the same conventions/logic as Emby for Kodi does
"""
if self.section_type == v.PLEX_TYPE_ARTIST:
window_path = 'ActivateWindow(Music,%s,return)' % path
elif self.section_type == v.PLEX_TYPE_PHOTO:
# Check: elif node_type in ('browse', 'homevideos', 'photos'):
window_path = path
else:
window_path = 'ActivateWindow(Videos,%s,return)' % path
# if node_type == 'all':
# var = self.node
# utils.window('%s.index' % var,
# value=path.replace('%s_all.xml' % self.section_id, ''))
# utils.window('%s.title' % var, value=self.name)
# else:
var = '%s.%s' % (self.node, node_type)
utils.window('%s.index' % var, value=path)
utils.window('%s.title' % var, value=node_name)
utils.window('%s.id' % var, value=str(self.section_id))
utils.window('%s.path' % var, value=window_path)
utils.window('%s.type' % var, value=self.content)
utils.window('%s.content' % var, value=path)
utils.window('%s.artwork' % var, value=self.artwork)
def remove_files_from_kodi(self):
"""
Removes this sections from the Kodi userdata library folder (if appl.)
Also removes the smart playlist
"""
if self.section_type in (v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_PHOTO):
# No files created for these types
return
if path_ops.exists(self.path):
path_ops.rmtree(self.path, ignore_errors=True)
if path_ops.exists(self.playlist_path):
try:
path_ops.remove(self.playlist_path)
except (OSError, IOError):
LOG.warn('Could not delete smart playlist for section %s: %s',
self.name, self.playlist_path)
def remove_window_vars(self):
"""
Removes all windows variables 'Plex.nodes.<section_id>.xxx'
"""
if self.index is not None:
_clear_window_vars(self.index)
def remove_from_plex(self, plexdb=None):
"""
Removes this sections completely from the Plex DB
"""
if plexdb:
plexdb.remove_section(self.section_id)
else:
with PlexDB(lock=False) as plexdb:
plexdb.remove_section(self.section_id)
def remove(self):
"""
Completely and utterly removes this section from Kodi and Plex DB
as well as from the window variables
"""
self.remove_files_from_kodi()
self.remove_window_vars()
self.remove_from_plex()
def force_full_sync(): def force_full_sync():
""" """
@ -32,193 +411,42 @@ def force_full_sync():
plexdb.force_full_sync() plexdb.force_full_sync()
def sync_from_pms(parent_self): def _save_sections_to_plex_db(sections):
"""
Sync the Plex library sections
"""
global IS_CANCELED
IS_CANCELED = parent_self.isCanceled
try:
return _sync_from_pms()
finally:
IS_CANCELED = None
def _sync_from_pms():
global PLAYLISTS, NODES, SECTIONS
sections = PF.get_plex_sections()
try:
sections.attrib
except AttributeError:
LOG.error("Error download PMS sections, abort")
return False
if app.SYNC.direct_paths is True and app.SYNC.enable_music is True:
# Will reboot Kodi is new library detected
music.excludefromscan_music_folders(xml=sections)
VNODES.clearProperties()
SECTIONS = []
NODES = {
v.PLEX_TYPE_MOVIE: [],
v.PLEX_TYPE_SHOW: [],
v.PLEX_TYPE_ARTIST: [],
v.PLEX_TYPE_PHOTO: []
}
PLAYLISTS = copy.deepcopy(NODES)
with PlexDB() as plexdb: with PlexDB() as plexdb:
# Backup old sections to delete them later, if needed (at the end for section in sections:
# of this method, only unused sections will be left in old_sections) section.to_plex_db(plexdb=plexdb)
old_sections = list(plexdb.all_sections())
with kodi_db.KodiVideoDB() as kodidb:
for index, section in enumerate(sections):
_process_section(section,
kodidb,
plexdb,
index,
old_sections)
if old_sections:
# Section has been deleted on the PMS
delete_sections(old_sections)
# update sections for all:
with PlexDB() as plexdb:
SECTIONS = list(plexdb.all_sections())
utils.window('Plex.nodes.total', str(len(sections)))
LOG.info("Finished processing %s library sections: %s", len(sections), SECTIONS)
if app.CONN.machine_identifier != utils.settings('sections_asked_for_machine_identifier'):
LOG.info('First time connecting to this PMS, choosing libraries')
if choose_libraries():
with PlexDB() as plexdb:
SECTIONS = list(plexdb.all_sections())
return True
def _process_section(section_xml, kodidb, plexdb, index, old_sections): def _retrieve_old_settings(sections, old_sections):
global PLAYLISTS, NODES """
folder = section_xml.attrib Overwrites the PKC settings for sections, grabing them from old_sections
plex_type = folder['type'] if a particular section is in both sections and old_sections
# Only process supported formats
if plex_type not in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW,
v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_PHOTO):
LOG.error('Unsupported Plex section type: %s', folder)
return
section_id = cast(int, folder['key'])
section_name = folder['title']
# Prevent duplicate for nodes of the same type
nodes = NODES[plex_type]
# Prevent duplicate for playlists of the same type
playlists = PLAYLISTS[plex_type]
# Get current media folders from plex database
section = plexdb.section(section_id)
if not section:
LOG.info('Creating section id: %s in Plex database.', section_id)
tagid = kodidb.create_tag(section_name)
# Create playlist for the video library
if (section_name not in playlists and
plex_type in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
utils.playlist_xsp(plex_type, section_name, section_id)
playlists.append(section_name)
# Create the video node
if section_name not in nodes:
VNODES.viewNode(index,
section_name,
plex_type,
None,
section_id)
nodes.append(section_name)
# Add view to plex database
plexdb.add_section(section_id,
section_name,
plex_type,
tagid,
True, # Sync this new section for now
None)
else:
LOG.info('Found library section id %s, name %s, type %s, tagid %s',
section_id, section['section_name'], section['plex_type'],
section['kodi_tagid'])
# Remove views that are still valid to delete rest later
for section in old_sections:
if section['section_id'] == section_id:
old_sections.remove(section)
break
# View was modified, update with latest info
if section['section_name'] != section_name:
LOG.info('section id: %s new sectionname: %s',
section_id, section_name)
tagid = kodidb.create_tag(section_name)
# Update view with new info Thus sets to the old values:
plexdb.add_section(section_id, section.last_sync
section_name, section.kodi_tagid
plex_type, section.sync_to_kodi
tagid, section.last_sync
section['sync_to_kodi'], # Use "old" setting """
section['last_sync']) for section in sections:
for old_section in old_sections:
if plexdb.section_id_by_name(section['section_name']) is None: if section == old_section:
# The tag could be a combined view. Ensure there's section.last_sync = old_section.last_sync
# no other tags with the same name before deleting section.kodi_tagid = old_section.kodi_tagid
# playlist. section.sync_to_kodi = old_section.sync_to_kodi
utils.playlist_xsp(plex_type, section.last_sync = old_section.last_sync
section['section_name'],
section_id,
section['plex_type'],
True)
# Delete video node
if plex_type != "musicvideos":
VNODES.viewNode(
indexnumber=index,
tagname=section['section_name'],
mediatype=plex_type,
viewtype=None,
viewid=section_id,
delete=True)
# Added new playlist
if section_name not in playlists and plex_type in v.KODI_VIDEOTYPES:
utils.playlist_xsp(plex_type,
section_name,
section_id)
playlists.append(section_name)
# Add new video node
if section_name not in nodes and plex_type != "musicvideos":
VNODES.viewNode(index,
section_name,
plex_type,
None,
section_id)
nodes.append(section_name)
# Update items with new tag
for kodi_id in plexdb.kodiid_by_sectionid(section_id, plex_type):
kodidb.update_tag(
section['kodi_tagid'], tagid, kodi_id, section['plex_type'])
else:
# Validate the playlist exists or recreate it
if (section_name not in playlists and plex_type in
(v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
utils.playlist_xsp(plex_type,
section_name,
section_id)
playlists.append(section_name)
# Create the video node if not already exists
if section_name not in nodes and plex_type != "musicvideos":
VNODES.viewNode(index,
section_name,
plex_type,
None,
section_id)
nodes.append(section_name)
def _delete_kodi_db_items(section_id, section_type): def _delete_kodi_db_items(section):
if section_type == v.PLEX_TYPE_MOVIE: if section.section_type == v.PLEX_TYPE_MOVIE:
kodi_context = kodi_db.KodiVideoDB kodi_context = kodi_db.KodiVideoDB
types = ((v.PLEX_TYPE_MOVIE, itemtypes.Movie), ) types = ((v.PLEX_TYPE_MOVIE, itemtypes.Movie), )
elif section_type == v.PLEX_TYPE_SHOW: elif section.section_type == v.PLEX_TYPE_SHOW:
kodi_context = kodi_db.KodiVideoDB kodi_context = kodi_db.KodiVideoDB
types = ((v.PLEX_TYPE_SHOW, itemtypes.Show), types = ((v.PLEX_TYPE_SHOW, itemtypes.Show),
(v.PLEX_TYPE_SEASON, itemtypes.Season), (v.PLEX_TYPE_SEASON, itemtypes.Season),
(v.PLEX_TYPE_EPISODE, itemtypes.Episode)) (v.PLEX_TYPE_EPISODE, itemtypes.Episode))
elif section_type == v.PLEX_TYPE_ARTIST: elif section.section_type == v.PLEX_TYPE_ARTIST:
kodi_context = kodi_db.KodiMusicDB kodi_context = kodi_db.KodiMusicDB
types = ((v.PLEX_TYPE_ARTIST, itemtypes.Artist), types = ((v.PLEX_TYPE_ARTIST, itemtypes.Artist),
(v.PLEX_TYPE_ALBUM, itemtypes.Album), (v.PLEX_TYPE_ALBUM, itemtypes.Album),
@ -226,7 +454,7 @@ def _delete_kodi_db_items(section_id, section_type):
for plex_type, context in types: for plex_type, context in types:
while True: while True:
with PlexDB() as plexdb: with PlexDB() as plexdb:
plex_ids = list(plexdb.plexid_by_sectionid(section_id, plex_ids = list(plexdb.plexid_by_sectionid(section.section_id,
plex_type, plex_type,
BATCH_SIZE)) BATCH_SIZE))
with kodi_context(texture_db=True) as kodidb: with kodi_context(texture_db=True) as kodidb:
@ -240,74 +468,175 @@ def _delete_kodi_db_items(section_id, section_type):
return True return True
def delete_sections(old_sections): def _choose_libraries(sections):
"""
Deletes all elements for a Plex section that has been deleted. (e.g. all
TV shows, Seasons and Episodes of a Show section)
"""
LOG.info("Removing entire Plex library sections: %s", old_sections)
for section in old_sections:
# "Deleting <section_name>"
utils.dialog('notification',
heading='{plex}',
message='%s %s' % (utils.lang(30052), section['section_name']),
icon='{plex}',
sound=False)
if section['plex_type'] == v.PLEX_TYPE_PHOTO:
# not synced - just remove the link in our Plex sections table
pass
else:
if not _delete_kodi_db_items(section['section_id'], section['plex_type']):
return
# Only remove Plex entry if we've removed all items first
with PlexDB() as plexdb:
plexdb.remove_section(section['section_id'])
def choose_libraries():
""" """
Displays a dialog for the user to select the libraries he wants synched Displays a dialog for the user to select the libraries he wants synched
Returns True if this was successful, False if not Returns True if the user chose new sections, False if he aborted
""" """
# Re-set value in order to make sure we got the lastest user input
app.SYNC.enable_music = utils.settings('enableMusic') == 'true'
import xbmcgui import xbmcgui
sections = [] selectable_sections = []
preselect = [] preselected = []
index = 0 index = 0
for section in SECTIONS: for section in sections:
if not app.SYNC.enable_music and section['plex_type'] == v.PLEX_TYPE_ARTIST: if not app.SYNC.enable_music and section.section_type == v.PLEX_TYPE_ARTIST:
LOG.info('Ignoring music section: %s', section) LOG.info('Ignoring music section: %s', section)
continue continue
elif section['plex_type'] == v.PLEX_TYPE_PHOTO: elif section.section_type == v.PLEX_TYPE_PHOTO:
# We won't ever show Photo sections
continue continue
else: else:
sections.append(section['section_name']) # Offer user the new section
if section['sync_to_kodi']: selectable_sections.append(section.name)
preselect.append(index) # Sections have been either preselected by the user or they are new
if section.sync_to_kodi:
preselected.append(index)
index += 1 index += 1
# "Select Plex libraries to sync" # Don't ask the user again for this PMS even if user cancel the sync dialog
selected = xbmcgui.Dialog().multiselect(utils.lang(30524),
sections,
preselect=preselect,
useDetails=False)
if selected is None:
# User canceled
return False
index = 0
with PlexDB() as plexdb:
for section in SECTIONS:
if not app.SYNC.enable_music and section['plex_type'] == v.PLEX_TYPE_ARTIST:
continue
elif section['plex_type'] == v.PLEX_TYPE_PHOTO:
continue
else:
sync = True if index in selected else False
plexdb.update_section_sync(section['section_id'], sync)
index += 1
sections = list(plexdb.all_sections())
LOG.info('Plex libraries to sync: %s', sections)
utils.settings('sections_asked_for_machine_identifier', utils.settings('sections_asked_for_machine_identifier',
value=app.CONN.machine_identifier) value=app.CONN.machine_identifier)
# "Select Plex libraries to sync"
selected_sections = xbmcgui.Dialog().multiselect(utils.lang(30524),
selectable_sections,
preselect=preselected,
useDetails=False)
if selectable_sections is None:
LOG.info('User chose not to select which libraries to sync')
return False
index = 0
for section in sections:
if not app.SYNC.enable_music and section.section_type == v.PLEX_TYPE_ARTIST:
continue
elif section.section_type == v.PLEX_TYPE_PHOTO:
continue
else:
section.sync_to_kodi = index in selected_sections
index += 1
return True return True
def delete_playlists():
"""
Clean up the playlists
"""
path = path_ops.translate_path('special://profile/playlists/video/')
for root, _, files in path_ops.walk(path):
for file in files:
if file.startswith('Plex'):
path_ops.remove(path_ops.path.join(root, file))
def delete_nodes():
"""
Clean up video nodes
"""
path = path_ops.translate_path("special://profile/library/video/")
for root, dirs, _ in path_ops.walk(path):
for directory in dirs:
if directory.startswith('Plex-'):
path_ops.rmtree(path_ops.path.join(root, directory))
break
def delete_files():
"""
Deletes both all the Plex-xxx video node xmls as well as smart playlists
"""
delete_nodes()
delete_playlists()
def sync_from_pms(parent_self, pick_libraries=False):
"""
Sync the Plex library sections.
pick_libraries=True will prompt the user the select the libraries he
wants to sync
"""
global IS_CANCELED
LOG.info('Starting synching sections from the PMS')
IS_CANCELED = parent_self.isCanceled
try:
return _sync_from_pms(pick_libraries)
finally:
IS_CANCELED = None
LOG.info('Done synching sections from the PMS: %s', SECTIONS)
def _sync_from_pms(pick_libraries):
global SECTIONS
# Re-set value in order to make sure we got the lastest user input
app.SYNC.enable_music = utils.settings('enableMusic') == 'true'
xml = PF.get_plex_sections()
if xml is None:
LOG.error("Error download PMS sections, abort")
return False
sections = []
old_sections = []
for i, xml_element in enumerate(xml.findall('Directory')):
sections.append(Section(index=i, xml_element=xml_element))
with PlexDB() as plexdb:
for section_db in plexdb.all_sections():
old_sections.append(Section(section_db_element=section_db))
# Update our latest PMS sections with info saved in the PMS DB
_retrieve_old_settings(sections, old_sections)
if (app.CONN.machine_identifier != utils.settings('sections_asked_for_machine_identifier') or
pick_libraries):
if not pick_libraries:
LOG.info('First time connecting to this PMS, choosing libraries')
_choose_libraries(sections)
# We got everything - save to Plex db in case Kodi restarts before we're
# done here
_save_sections_to_plex_db(sections)
# Tweak some settings so Kodi does NOT scan the music folders
if app.SYNC.direct_paths is True:
# Will reboot Kodi is new library detected
music.excludefromscan_music_folders(sections)
# Delete all old sections that are obsolete
# This will also delete sections whose name (or type) have changed
for old_section in old_sections:
for section in sections:
if old_section == section:
break
else:
if not old_section.sync_to_kodi:
continue
LOG.info('Deleting entire section: %s', old_section)
# Remove all linked items
if not _delete_kodi_db_items(old_section):
return False
# Remove the section itself
old_section.remove()
# Time to write the sections to Kodi
for section in sections:
section.to_kodi()
# Counter that tells us how many sections we have - e.g. for skins and
# listings
utils.window('Plex.nodes.total', str(len(sections)))
SECTIONS = sections
return True
def _clear_window_vars(index):
node = 'Plex.nodes.%s' % index
utils.window('%s.title' % node, clear=True)
utils.window('%s.type' % node, clear=True)
utils.window('%s.content' % node, clear=True)
utils.window('%s.path' % node, clear=True)
utils.window('%s.id' % node, clear=True)
utils.window('%s.addon_path' % node, clear=True)
for kind in WINDOW_ARGS:
node = 'Plex.nodes.%s.%s' % (index, kind)
utils.window(node, clear=True)
def clear_window_vars():
"""
Removes all references to sections stored in window vars 'Plex.nodes...'
"""
number_of_nodes = int(utils.window('Plex.nodes.total') or 0)
utils.window('Plex.nodes.total', clear=True)
for index in range(number_of_nodes):
_clear_window_vars(index)

View file

@ -23,9 +23,7 @@ def sync_pms_time():
# Get all Plex libraries # Get all Plex libraries
sections = PF.get_plex_sections() sections = PF.get_plex_sections()
try: if not sections:
sections.attrib
except AttributeError:
LOG.error("Error download PMS views, abort sync_pms_time") LOG.error("Error download PMS views, abort sync_pms_time")
return False return False

View file

@ -1,488 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from ..utils import etree
from .. import utils, path_ops, variables as v, app
###############################################################################
LOG = getLogger('PLEX.videonodes')
###############################################################################
class VideoNodes(object):
@staticmethod
def commonRoot(order, label, tagname, roottype=1):
if roottype == 0:
# Index
root = etree.Element('node', {'order': "%s" % order})
elif roottype == 1:
# Filter
root = etree.Element('node',
{'order': "%s" % order, 'type': "filter"})
etree.SubElement(root, 'match').text = "all"
# Add tag rule
rule = etree.SubElement(root,
'rule',
{'field': "tag", 'operator': "is"})
etree.SubElement(rule, 'value').text = tagname
else:
# Folder
root = etree.Element('node',
{'order': "%s" % order, 'type': "folder"})
etree.SubElement(root, 'label').text = label
etree.SubElement(root, 'icon').text = "special://home/addons/plugin.video.plexkodiconnect/icon.png"
return root
def viewNode(self, indexnumber, tagname, mediatype, viewtype, viewid,
delete=False):
# Plex: reassign mediatype due to Kodi inner workings
# How many items do we get at most?
limit = unicode(app.APP.fetch_pms_item_number)
mediatypes = {
'movie': 'movies',
'show': 'tvshows',
'photo': 'photos',
'homevideo': 'homevideos',
'musicvideos': 'musicvideos',
'artist': 'albums'
}
mediatype = mediatypes[mediatype]
if viewtype == "mixed":
dirname = "%s-%s" % (viewid, mediatype)
else:
dirname = viewid
# Returns strings
path = path_ops.translate_path('special://profile/library/video/')
nodepath = path_ops.translate_path(
'special://profile/library/video/Plex-%s/' % dirname)
if delete:
if path_ops.exists(nodepath):
path_ops.rmtree(nodepath)
LOG.info("Sucessfully removed videonode: %s." % tagname)
return
# Verify the video directory
if not path_ops.exists(path):
path_ops.copy_tree(
src=path_ops.translate_path(
'special://xbmc/system/library/video'),
dst=path_ops.translate_path('special://profile/library/video'),
preserve_mode=0) # do not copy permission bits!
# Create the node directory
if mediatype != "photos":
if not path_ops.exists(nodepath):
# folder does not exist yet
LOG.debug('Creating folder %s' % nodepath)
path_ops.makedirs(nodepath)
# Create index entry
nodeXML = "%sindex.xml" % nodepath
# Set windows property
path = "library://video/Plex-%s/" % dirname
for i in range(1, indexnumber):
# Verify to make sure we don't create duplicates
if utils.window('Plex.nodes.%s.index' % i) == path:
return
if mediatype == "photos":
path = "plugin://plugin.video.plexkodiconnect?mode=browseplex&key=/library/sections/%s&id=%s" % (viewid, viewid)
utils.window('Plex.nodes.%s.index' % indexnumber, value=path)
# Root
if not mediatype == "photos":
if viewtype == "mixed":
specialtag = "%s-%s" % (tagname, mediatype)
root = self.commonRoot(order=0,
label=specialtag,
tagname=tagname,
roottype=0)
else:
root = self.commonRoot(order=0,
label=tagname,
tagname=tagname,
roottype=0)
utils.indent(root)
etree.ElementTree(root).write(nodeXML, encoding="UTF-8")
nodetypes = {
'1': "all",
'2': "recent",
'3': "recentepisodes",
'4': "inprogress",
'5': "inprogressepisodes",
'6': "unwatched",
'7': "nextepisodes",
'8': "sets",
'9': "genres",
'10': "random",
'11': "recommended",
'12': "ondeck",
'13': 'browsefiles',
}
mediatypes = {
# label according to nodetype per mediatype
'movies':
{
'1': tagname,
'2': 30174,
# '4': 30177,
# '6': 30189,
'8': 39501,
'9': 135,
'10': 30227,
'11': 30230,
'12': 39500,
'13': 39702,
},
'tvshows':
{
'1': tagname,
# '2': 30170,
'3': 30174,
# '4': 30171,
# '5': 30178,
# '7': 30179,
'9': 135,
'10': 30227,
# '11': 30230,
'12': 39500,
'13': 39702,
},
'homevideos':
{
'1': tagname,
'2': 30251,
'11': 30253,
'13': 39702,
'14': 136
},
'photos':
{
'1': tagname,
'2': 30252,
'8': 30255,
'11': 30254,
'13': 39702
},
'musicvideos':
{
'1': tagname,
'2': 30256,
'4': 30257,
'6': 30258,
'13': 39702
},
'albums':
{
'1': tagname,
'2': 517, # Recently played albums
'2': 359, # Recently added albums
'13': 39702, # browse by folder
}
}
# Key: nodetypes, value: sort order in Kodi
sortorder = {
'1': '3', # "all",
'2': '2', # "recent",
'3': '2', # "recentepisodes",
# '4': # "inprogress",
# '5': # "inprogressepisodes",
# '6': # "unwatched",
# '7': # "nextepisodes",
'8': '7', # "sets",
'9': '6', # "genres",
'10': '8', # "random",
'11': '5', # "recommended",
'12': '1', # "ondeck"
'13': '9', # browse by folder
}
nodes = mediatypes[mediatype]
for node in nodes:
nodetype = nodetypes[node]
nodeXML = "%s%s_%s.xml" % (nodepath, viewid, nodetype)
# Get label
stringid = nodes[node]
if node != "1":
label = utils.lang(stringid)
else:
label = stringid
# Set window properties
if ((mediatype == "homevideos" or mediatype == "photos") and
nodetype == "all"):
# Custom query
path = ("plugin://plugin.video.plexkodiconnect/?id=%s&mode=browseplex&type=%s"
% (viewid, mediatype))
elif (mediatype == "homevideos" or mediatype == "photos"):
# Custom query
path = ("plugin://plugin.video.plexkodiconnect/?id=%s&mode=browseplex&type=%s&folderid=%s"
% (viewid, mediatype, nodetype))
elif nodetype == "nextepisodes":
# Custom query
path = "plugin://plugin.video.plexkodiconnect/?id=%s&mode=nextup&limit=%s" % (tagname, limit)
# elif v.KODIVERSION == 14 and nodetype == "recentepisodes":
elif nodetype == "recentepisodes":
# Custom query
path = ("plugin://plugin.video.plexkodiconnect/?id=%s&mode=recentepisodes&type=%s&tagname=%s&limit=%s"
% (viewid, mediatype, tagname, limit))
elif v.KODIVERSION == 14 and nodetype == "inprogressepisodes":
# Custom query
path = "plugin://plugin.video.plexkodiconnect/?id=%s&mode=inprogressepisodes&limit=%s" % (tagname, limit)
elif nodetype == 'ondeck':
# PLEX custom query
if mediatype == "tvshows":
path = ("plugin://plugin.video.plexkodiconnect/?id=%s&mode=ondeck&type=%s&tagname=%s&limit=%s"
% (viewid, mediatype, tagname, limit))
elif mediatype =="movies":
# Reset nodetype; we got the label
nodetype = 'inprogress'
elif nodetype == 'browsefiles':
path = 'plugin://plugin.video.plexkodiconnect?mode=browseplex&key=/library/sections/%s/folder' % viewid
else:
path = "library://video/Plex-%s/%s_%s.xml" % (dirname, viewid, nodetype)
if mediatype == "photos":
windowpath = "ActivateWindow(Pictures,%s,return)" % path
else:
if v.KODIVERSION >= 17:
# Krypton
windowpath = "ActivateWindow(Videos,%s,return)" % path
else:
windowpath = "ActivateWindow(Video,%s,return)" % path
if nodetype == "all":
if viewtype == "mixed":
templabel = "%s-%s" % (tagname, mediatype)
else:
templabel = label
embynode = "Plex.nodes.%s" % indexnumber
utils.window('%s.title' % embynode, value=templabel)
utils.window('%s.path' % embynode, value=windowpath)
utils.window('%s.content' % embynode, value=path)
utils.window('%s.type' % embynode, value=mediatype)
else:
embynode = "Plex.nodes.%s.%s" % (indexnumber, nodetype)
utils.window('%s.title' % embynode, value=label)
utils.window('%s.path' % embynode, value=windowpath)
utils.window('%s.content' % embynode, value=path)
if mediatype == "photos":
# For photos, we do not create a node in videos but we do want
# the window props to be created. To do: add our photos nodes to
# kodi picture sources somehow
continue
if path_ops.exists(nodeXML):
# Don't recreate xml if already exists
continue
# Create the root
if (nodetype in ("nextepisodes",
"ondeck",
'recentepisodes',
'browsefiles') or mediatype == "homevideos"):
# Folder type with plugin path
root = self.commonRoot(order=sortorder[node],
label=label,
tagname=tagname,
roottype=2)
else:
root = self.commonRoot(order=sortorder[node],
label=label,
tagname=tagname)
# Set the content type
if mediatype == 'tvshows' and nodetype != 'all':
etree.SubElement(root, 'content').text = 'episodes'
else:
etree.SubElement(root, 'content').text = mediatype
# Now fill the view
if (nodetype in ("nextepisodes",
"ondeck",
'recentepisodes',
'browsefiles') or mediatype == "homevideos"):
etree.SubElement(root, 'path').text = path
else:
# Elements per nodetype
if nodetype == "all":
etree.SubElement(root,
'order',
{'direction': "ascending"}).text = "sorttitle"
elif nodetype == "recent":
etree.SubElement(root,
'order',
{'direction': "descending"}).text = "dateadded"
etree.SubElement(root, 'limit').text = limit
if utils.settings('MovieShowWatched') == 'false':
rule = etree.SubElement(root,
'rule',
{'field': "playcount",
'operator': "is"})
etree.SubElement(rule, 'value').text = "0"
elif nodetype == "inprogress":
etree.SubElement(root,
'rule',
{'field': "inprogress", 'operator': "true"})
etree.SubElement(root, 'limit').text = limit
etree.SubElement(
root,
'order',
{'direction': 'descending'}
).text = 'lastplayed'
elif nodetype == "genres":
etree.SubElement(root,
'order',
{'direction': "ascending"}).text = "sorttitle"
etree.SubElement(root, 'group').text = "genres"
elif nodetype == "unwatched":
etree.SubElement(root,
'order',
{'direction': "ascending"}).text = "sorttitle"
rule = etree.SubElement(root,
"rule",
{'field': "playcount", 'operator': "is"})
etree.SubElement(rule, 'value').text = "0"
elif nodetype == "sets":
etree.SubElement(root,
'order',
{'direction': "ascending"}).text = "sorttitle"
etree.SubElement(root, 'group').text = "tags"
elif nodetype == "random":
etree.SubElement(root,
'order',
{'direction': "ascending"}).text = "random"
etree.SubElement(root, 'limit').text = limit
elif nodetype == "recommended":
etree.SubElement(root,
'order',
{'direction': "descending"}).text = "rating"
etree.SubElement(root, 'limit').text = limit
rule = etree.SubElement(root,
'rule',
{'field': "playcount", 'operator': "is"})
etree.SubElement(rule, 'value').text = "0"
rule2 = etree.SubElement(root,
'rule',
{'field': "rating", 'operator': "greaterthan"})
etree.SubElement(rule2, 'value').text = "7"
elif nodetype == "recentepisodes":
# Kodi Isengard, Jarvis
etree.SubElement(root,
'order',
{'direction': "descending"}).text = "dateadded"
etree.SubElement(root, 'limit').text = limit
rule = etree.SubElement(root,
'rule',
{'field': "playcount", 'operator': "is"})
etree.SubElement(rule, 'value').text = "0"
elif nodetype == "inprogressepisodes":
# Kodi Isengard, Jarvis
etree.SubElement(root, 'limit').text = limit
rule = etree.SubElement(root,
'rule',
{'field': "inprogress", 'operator':"true"})
utils.indent(root)
etree.ElementTree(root).write(path_ops.encode_path(nodeXML),
encoding="UTF-8")
def singleNode(self, indexnumber, tagname, mediatype, itemtype):
cleantagname = utils.normalize_nodes(tagname)
nodepath = path_ops.translate_path('special://profile/library/video/')
nodeXML = "%splex_%s.xml" % (nodepath, cleantagname)
path = "library://video/plex_%s.xml" % cleantagname
if v.KODIVERSION >= 17:
# Krypton
windowpath = "ActivateWindow(Videos,%s,return)" % path
else:
windowpath = "ActivateWindow(Video,%s,return)" % path
# Create the video node directory
if not path_ops.exists(nodepath):
# We need to copy over the default items
path_ops.copy_tree(
src=path_ops.translate_path(
'special://xbmc/system/library/video'),
dst=path_ops.translate_path('special://profile/library/video'),
preserve_mode=0) # do not copy permission bits!
labels = {
'Favorite movies': 30180,
'Favorite tvshows': 30181,
'channels': 30173
}
label = utils.lang(labels[tagname])
embynode = "Plex.nodes.%s" % indexnumber
utils.window('%s.title' % embynode, value=label)
utils.window('%s.path' % embynode, value=windowpath)
utils.window('%s.content' % embynode, value=path)
utils.window('%s.type' % embynode, value=itemtype)
if path_ops.exists(nodeXML):
# Don't recreate xml if already exists
return
if itemtype == "channels":
root = self.commonRoot(order=1,
label=label,
tagname=tagname,
roottype=2)
etree.SubElement(root,
'path').text = "plugin://plugin.video.plexkodiconnect/?id=0&mode=channels"
else:
root = self.commonRoot(order=1, label=label, tagname=tagname)
etree.SubElement(root,
'order',
{'direction': "ascending"}).text = "sorttitle"
etree.SubElement(root, 'content').text = mediatype
utils.indent(root)
etree.ElementTree(root).write(nodeXML, encoding="UTF-8")
@staticmethod
def clearProperties():
LOG.info("Clearing nodes properties.")
plexprops = utils.window('Plex.nodes.total')
propnames = [
"index","path","title","content",
"inprogress.content","inprogress.title",
"inprogress.content","inprogress.path",
"nextepisodes.title","nextepisodes.content",
"nextepisodes.path","unwatched.title",
"unwatched.content","unwatched.path",
"recent.title","recent.content","recent.path",
"recentepisodes.title","recentepisodes.content",
"recentepisodes.path", "inprogressepisodes.title",
"inprogressepisodes.content","inprogressepisodes.path"
]
if plexprops:
totalnodes = int(plexprops)
for i in range(totalnodes):
for prop in propnames:
utils.window('Plex.nodes.%s.%s' % (str(i), prop),
clear=True)

View file

@ -3,8 +3,8 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
from . import utils
from .plex_api import API from .plex_api import API
from . import utils
from . import variables as v from . import variables as v
############################################################################### ###############################################################################
@ -12,7 +12,7 @@ LOG = getLogger('PLEX.music')
############################################################################### ###############################################################################
def excludefromscan_music_folders(xml): def excludefromscan_music_folders(sections):
""" """
Gets a complete list of paths for music libraries from the PMS. Sets them Gets a complete list of paths for music libraries from the PMS. Sets them
to be excluded in the advancedsettings.xml from being scanned by Kodi. to be excluded in the advancedsettings.xml from being scanned by Kodi.
@ -24,16 +24,17 @@ def excludefromscan_music_folders(xml):
paths = [] paths = []
reboot = False reboot = False
api = API(item=None) api = API(item=None)
for library in xml: for section in sections:
if library.attrib['type'] != v.PLEX_TYPE_ARTIST: if section.plex_type != v.PLEX_TYPE_ARTIST:
# Only look at music libraries # Only look at music libraries
continue continue
for location in library: if not section.sync_to_kodi:
if location.tag == 'Location': continue
path = api.validate_playurl(location.attrib['path'], for location in section.xml.findall('Location'):
typus=v.PLEX_TYPE_ARTIST, path = api.validate_playurl(location.attrib['path'],
omit_check=True) typus=v.PLEX_TYPE_ARTIST,
paths.append(__turn_to_regex(path)) omit_check=True)
paths.append(_turn_to_regex(path))
try: try:
with utils.XmlKodiSetting( with utils.XmlKodiSetting(
'advancedsettings.xml', 'advancedsettings.xml',
@ -73,7 +74,7 @@ def excludefromscan_music_folders(xml):
utils.reboot_kodi(utils.lang(39711)) utils.reboot_kodi(utils.lang(39711))
def __turn_to_regex(path): def _turn_to_regex(path):
""" """
Turns a path into regex expression to be fed to Kodi's advancedsettings.xml Turns a path into regex expression to be fed to Kodi's advancedsettings.xml
""" """

View file

@ -59,7 +59,10 @@ def translate_path(path):
def exists(path): def exists(path):
"""Returns True if the path [unicode] exists""" """
Returns True if the path [unicode] exists. Folders NEED a trailing slash or
backslash!!
"""
return xbmcvfs.exists(path.encode(KODI_ENCODING, 'strict')) == 1 return xbmcvfs.exists(path.encode(KODI_ENCODING, 'strict')) == 1

View file

@ -35,6 +35,7 @@ from logging import getLogger
from re import sub from re import sub
from urllib import urlencode, unquote, quote from urllib import urlencode, unquote, quote
from urlparse import parse_qsl from urlparse import parse_qsl
from xbmcgui import ListItem from xbmcgui import ListItem
from .plex_db import PlexDB from .plex_db import PlexDB
@ -149,6 +150,30 @@ class API(object):
omit_check=True) omit_check=True)
return path return path
def directory_path(self, section_id=None, plex_type=None, old_key=None,
synched=True):
key = self.item.get('fastKey')
if not key:
key = self.item.get('key')
if old_key:
key = '%s/%s' % (old_key, key)
elif not key.startswith('/'):
key = '/library/sections/%s/%s' % (section_id, key)
params = {
'mode': 'browseplex',
'key': key,
'plex_type': plex_type or self.plex_type()
}
if not synched:
# No item to be found in the Kodi DB
params['synched'] = 'false'
if self.item.get('prompt'):
# User input needed, e.g. search for a movie or episode
params['prompt'] = self.item.get('prompt')
if section_id:
params['id'] = section_id
return 'plugin://%s/?%s' % (v.ADDON_ID, urlencode(params))
def path_and_plex_id(self): def path_and_plex_id(self):
""" """
Returns the Plex key such as '/library/metadata/246922' or None Returns the Plex key such as '/library/metadata/246922' or None
@ -335,6 +360,26 @@ class API(object):
'UserRating': userrating 'UserRating': userrating
} }
def leave_count(self):
"""
Returns the following dict or None
{
'totalepisodes': unicode('leafCount'),
'watchedepisodes': unicode('viewedLeafCount'),
'unwatchedepisodes': unicode(totalepisodes - watchedepisodes)
}
"""
try:
total = int(self.item.attrib['leafCount'])
watched = int(self.item.attrib['viewedLeafCount'])
return {
'totalepisodes': unicode(total),
'watchedepisodes': unicode(watched),
'unwatchedepisodes': unicode(total - watched)
}
except (KeyError, TypeError):
pass
def collection_list(self): def collection_list(self):
""" """
Returns a list of tuples of the collection id and tags or an empty list Returns a list of tuples of the collection id and tags or an empty list
@ -522,7 +567,7 @@ class API(object):
def resume_point(self): def resume_point(self):
""" """
Returns the resume point of time in seconds as int. 0 if not found Returns the resume point of time in seconds as float. 0.0 if not found
""" """
try: try:
resume = float(self.item.attrib['viewOffset']) resume = float(self.item.attrib['viewOffset'])
@ -588,7 +633,7 @@ class API(object):
def premiere_date(self): def premiere_date(self):
""" """
Returns the "originallyAvailableAt" or None Returns the "originallyAvailableAt", e.g. "2018-11-16" or None
""" """
return self.item.get('originallyAvailableAt') return self.item.get('originallyAvailableAt')
@ -859,7 +904,20 @@ class API(object):
'subtitle': subtitlelanguages 'subtitle': subtitlelanguages
} }
def one_artwork(self, art_kind): def one_artwork(self, art_kind, aspect=None):
"""
aspect can be: 'square', '16:9', 'poster'. Defaults to 'poster'
"""
aspect = 'poster' if not aspect else aspect
if aspect == 'poster':
width = 1000
height = 1500
elif aspect == '16:9':
width = 1920
height = 1080
elif aspect == 'square':
width = 1000
height = 1000
artwork = self.item.get(art_kind) artwork = self.item.get(art_kind)
if artwork and not artwork.startswith('http'): if artwork and not artwork.startswith('http'):
if '/composite/' in artwork: if '/composite/' in artwork:
@ -870,27 +928,48 @@ class API(object):
args = dict(parse_qsl(args)) args = dict(parse_qsl(args))
width = int(args.get('width', 400)) width = int(args.get('width', 400))
height = int(args.get('height', 400)) height = int(args.get('height', 400))
# Adjust to 4k resolution 3,840x2,160 # Adjust to 4k resolution 1920x1080
scaling = 3840.0 / float(max(width, height)) scaling = 1920.0 / float(max(width, height))
width = int(scaling * width) width = int(scaling * width)
height = int(scaling * height) height = int(scaling * height)
except ValueError: except ValueError:
# e.g. playlists # e.g. playlists
width = 3840 pass
height = 3840
artwork = '%s?width=%s&height=%s' % (artwork, width, height) artwork = '%s?width=%s&height=%s' % (artwork, width, height)
artwork = ('%s/photo/:/transcode?width=3840&height=3840&' artwork = ('%s/photo/:/transcode?width=1920&height=1920&'
'minSize=1&upscale=0&url=%s' 'minSize=1&upscale=0&url=%s'
% (app.CONN.server, quote(artwork))) % (app.CONN.server, quote(artwork)))
artwork = self.attach_plex_token_to_url(artwork) artwork = self.attach_plex_token_to_url(artwork)
return artwork return artwork
def artwork_episode(self, full_artwork):
"""
Episodes are special, they only get the thumb, because all the other
artwork will be saved under season and show EXCEPT if you're
constructing a listitem and the item has NOT been synched to the Kodi db
"""
artworks = {}
# Item is currently NOT in the Kodi DB
art = self.one_artwork('thumb')
if art:
artworks['thumb'] = art
if not full_artwork:
# For episodes, only get the thumb. Everything else stemms from
# either the season or the show
return artworks
for kodi_artwork, plex_artwork in \
v.KODI_TO_PLEX_ARTWORK_EPISODE.iteritems():
art = self.one_artwork(plex_artwork)
if art:
artworks[kodi_artwork] = art
return artworks
def artwork(self, kodi_id=None, kodi_type=None, full_artwork=False): def artwork(self, kodi_id=None, kodi_type=None, full_artwork=False):
""" """
Gets the URLs to the Plex artwork. Dict keys will be missing if there Gets the URLs to the Plex artwork. Dict keys will be missing if there
is no corresponding artwork. is no corresponding artwork.
Pass kodi_id and kodi_type to grab the artwork saved in the Kodi DB Pass kodi_id and kodi_type to grab the artwork saved in the Kodi DB
(thus potentially more artwork, e.g. clearart, discart) (thus potentially more artwork, e.g. clearart, discart).
Output ('max' version) Output ('max' version)
{ {
@ -905,44 +984,9 @@ class API(object):
Passing full_artwork=True returns ALL the artwork for the item, so not Passing full_artwork=True returns ALL the artwork for the item, so not
just 'thumb' for episodes, but also season and show artwork just 'thumb' for episodes, but also season and show artwork
""" """
artworks = {}
if self.plex_type() == v.PLEX_TYPE_EPISODE: if self.plex_type() == v.PLEX_TYPE_EPISODE:
# Artwork lookup for episodes is broken for addon paths return self.artwork_episode(full_artwork)
# Episodes is a bit special, only get the thumb, because all artworks = {}
# the other artwork will be saved under season and show
# EXCEPT if you're constructing a listitem
if not full_artwork:
art = self.one_artwork('thumb')
if art:
artworks['thumb'] = art
return artworks
for kodi_artwork, plex_artwork in \
v.KODI_TO_PLEX_ARTWORK_EPISODE.iteritems():
art = self.one_artwork(plex_artwork)
if art:
artworks[kodi_artwork] = art
if not full_artwork:
return artworks
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_id(self.plex_id(),
v.PLEX_TYPE_EPISODE)
if db_item:
season_id = db_item['parent_id']
show_id = db_item['grandparent_id']
else:
return artworks
# Grab artwork from the season
with KodiVideoDB(lock=False) as kodidb:
season_art = kodidb.get_art(season_id, v.KODI_TYPE_SEASON)
for kodi_art in season_art:
artworks['season.%s' % kodi_art] = season_art[kodi_art]
# Grab more artwork from the show
with KodiVideoDB(lock=False) as kodidb:
show_art = kodidb.get_art(show_id, v.KODI_TYPE_SHOW)
for kodi_art in show_art:
artworks['tvshow.%s' % kodi_art] = show_art[kodi_art]
return artworks
if kodi_id: if kodi_id:
# in Kodi database, potentially with additional e.g. clearart # in Kodi database, potentially with additional e.g. clearart
if self.plex_type() in v.PLEX_VIDEOTYPES: if self.plex_type() in v.PLEX_VIDEOTYPES:
@ -952,9 +996,6 @@ class API(object):
with KodiMusicDB(lock=False) as kodidb: with KodiMusicDB(lock=False) as kodidb:
return kodidb.get_art(kodi_id, kodi_type) return kodidb.get_art(kodi_id, kodi_type)
# Grab artwork from Plex
# if self.plex_type() == v.PLEX_TYPE_EPISODE:
for kodi_artwork, plex_artwork in v.KODI_TO_PLEX_ARTWORK.iteritems(): for kodi_artwork, plex_artwork in v.KODI_TO_PLEX_ARTWORK.iteritems():
art = self.one_artwork(plex_artwork) art = self.one_artwork(plex_artwork)
if art: if art:
@ -976,6 +1017,10 @@ class API(object):
art = self.one_artwork('thumb') art = self.one_artwork('thumb')
if art: if art:
artworks['thumb'] = art artworks['thumb'] = art
if self.plex_type() == v.PLEX_TYPE_PLAYLIST:
art = self.one_artwork('composite')
if art:
artworks['thumb'] = art
return artworks return artworks
def fanart_artwork(self, artworks): def fanart_artwork(self, artworks):
@ -1649,6 +1694,96 @@ class API(object):
pass pass
return listitem return listitem
def _create_folder_listitem(self, listitem=None):
"""
Use for video items only
Call on a child level of PMS xml response (e.g. in a for loop)
listitem : existing xbmcgui.ListItem to work with
otherwise, a new one is created
append_show_title : True to append TV show title to episode title
append_sxxexx : True to append SxxExx to episode title
Returns XBMC listitem for this PMS library item
"""
title = self.title()
typus = self.plex_type()
if listitem is None:
listitem = ListItem(title)
else:
listitem.setLabel(title)
# Necessary; Kodi won't start video otherwise!
listitem.setProperty('IsPlayable', 'true')
# Video items, e.g. movies and episodes or clips
people = self.people()
userdata = self.userdata()
metadata = {
'genre': self.genre_list(),
'country': self.country_list(),
'year': self.year(),
'rating': self.audience_rating(),
'playcount': userdata['PlayCount'],
'cast': people['Cast'],
'director': people['Director'],
'plot': self.plot(),
'sorttitle': self.sorttitle(),
'duration': userdata['Runtime'],
'studio': self.music_studio_list(),
'tagline': self.tagline(),
'writer': people.get('Writer'),
'premiered': self.premiere_date(),
'dateadded': self.date_created(),
'lastplayed': userdata['LastPlayedDate'],
'mpaa': self.content_rating(),
'aired': self.premiere_date(),
}
# Do NOT set resumetime - otherwise Kodi always resumes at that time
# even if the user chose to start element from the beginning
# listitem.setProperty('resumetime', str(userdata['Resume']))
listitem.setProperty('totaltime', str(userdata['Runtime']))
if typus == v.PLEX_TYPE_EPISODE:
metadata['mediatype'] = 'episode'
_, _, show, season, episode = self.episode_data()
season = -1 if season is None else int(season)
episode = -1 if episode is None else int(episode)
metadata['episode'] = episode
metadata['sortepisode'] = episode
metadata['season'] = season
metadata['sortseason'] = season
metadata['tvshowtitle'] = show
if season and episode:
if append_sxxexx is True:
title = "S%.2dE%.2d - %s" % (season, episode, title)
if append_show_title is True:
title = "%s - %s " % (show, title)
if append_show_title or append_sxxexx:
listitem.setLabel(title)
elif typus == v.PLEX_TYPE_MOVIE:
metadata['mediatype'] = 'movie'
else:
# E.g. clips, trailers, ...
pass
plex_id = self.plex_id()
listitem.setProperty('plexid', str(plex_id))
with PlexDB() as plexdb:
db_item = plexdb.item_by_id(plex_id, self.plex_type())
if db_item:
metadata['dbid'] = db_item['kodi_id']
metadata['title'] = title
# Expensive operation
listitem.setInfo('video', infoLabels=metadata)
try:
# Add context menu entry for information screen
listitem.addContextMenuItems([(utils.lang(30032),
'XBMC.Action(Info)',)])
except TypeError:
# Kodi fuck-up
pass
return listitem
def disc_number(self): def disc_number(self):
""" """
Returns the song's disc number as an int or None if not found Returns the song's disc number as an int or None if not found

View file

@ -74,7 +74,10 @@ class PlexDBBase(object):
answ = self.album(plex_id) answ = self.album(plex_id)
elif plex_type == v.PLEX_TYPE_ARTIST: elif plex_type == v.PLEX_TYPE_ARTIST:
answ = self.artist(plex_id) answ = self.artist(plex_id)
else: elif plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_PHOTO, v.PLEX_TYPE_PLAYLIST):
# Will never be synched to Kodi
pass
elif plex_type is None:
# SLOW - lookup plex_id in all our tables # SLOW - lookup plex_id in all our tables
for kind in (v.PLEX_TYPE_MOVIE, for kind in (v.PLEX_TYPE_MOVIE,
v.PLEX_TYPE_EPISODE, v.PLEX_TYPE_EPISODE,

View file

@ -819,7 +819,12 @@ def get_plex_sections():
""" """
Returns all Plex sections (libraries) of the PMS as an etree xml Returns all Plex sections (libraries) of the PMS as an etree xml
""" """
return DU().downloadUrl('{server}/library/sections') xml = DU().downloadUrl('{server}/library/sections')
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
xml = None
return xml
def init_plex_playqueue(plex_id, librarySectionUUID, mediatype='movie', def init_plex_playqueue(plex_id, librarySectionUUID, mediatype='movie',

View file

@ -86,9 +86,6 @@ class Service(object):
for prop in WINDOW_PROPERTIES: for prop in WINDOW_PROPERTIES:
utils.window(prop, clear=True) utils.window(prop, clear=True)
# Load/Reset PKC entirely - important for user/Kodi profile switch
# Clear video nodes properties
library_sync.VideoNodes().clearProperties()
clientinfo.getDeviceId() clientinfo.getDeviceId()
# Init time-offset between Kodi and Plex # Init time-offset between Kodi and Plex
timing.KODI_PLEX_TIME_OFFSET = float(utils.settings('kodiplextimeoffset') or 0.0) timing.KODI_PLEX_TIME_OFFSET = float(utils.settings('kodiplextimeoffset') or 0.0)
@ -212,10 +209,8 @@ class Service(object):
def switch_plex_user(self): def switch_plex_user(self):
self.log_out() self.log_out()
# First remove playlists of old user # First remove playlists and video nodes of old user
utils.delete_playlists() library_sync.delete_files()
# Remove video nodes
utils.delete_nodes()
app.ACCOUNT.set_unauthenticated() app.ACCOUNT.set_unauthenticated()
# Force full sync after login # Force full sync after login
library_sync.force_full_sync() library_sync.force_full_sync()
@ -300,9 +295,7 @@ class Service(object):
from .library_sync import sections from .library_sync import sections
try: try:
# Get newest sections from the PMS # Get newest sections from the PMS
if not sections.sync_from_pms(self): if not sections.sync_from_pms(self, pick_libraries=True):
return
if not sections.choose_libraries():
return return
# Force a full sync # Force a full sync
app.SYNC.run_lib_scan = 'full' app.SYNC.run_lib_scan = 'full'
@ -508,6 +501,9 @@ class Service(object):
# Tell all threads to terminate (e.g. several lib sync threads) # Tell all threads to terminate (e.g. several lib sync threads)
LOG.debug('Aborting all threads') LOG.debug('Aborting all threads')
app.APP.stop_pkc = True app.APP.stop_pkc = True
# Load/Reset PKC entirely - important for user/Kodi profile switch
# Clear video nodes properties
library_sync.clear_window_vars()
# Will block until threads have quit # Will block until threads have quit
app.APP.stop_threads() app.APP.stop_threads()
utils.window('plex_service_started', clear=True) utils.window('plex_service_started', clear=True)

View file

@ -17,6 +17,7 @@ from functools import wraps
import hashlib import hashlib
import re import re
import gc import gc
import xbmc import xbmc
import xbmcaddon import xbmcaddon
import xbmcgui import xbmcgui
@ -429,11 +430,10 @@ def wipe_database():
Will also delete all cached artwork. Will also delete all cached artwork.
""" """
LOG.warn('Start wiping') LOG.warn('Start wiping')
# Clean up the playlists from .library_sync.sections import delete_files
delete_playlists()
# Clean up the video nodes
delete_nodes()
from . import kodi_db, plex_db from . import kodi_db, plex_db
# Clean up the playlists and video nodes
delete_files()
# First get the paths to all synced playlists # First get the paths to all synced playlists
playlist_paths = [] playlist_paths = []
try: try:
@ -814,76 +814,6 @@ class XmlKodiSetting(object):
return element return element
def playlist_xsp(mediatype, tagname, viewid, viewtype="", delete=False):
"""
Feed with tagname as unicode
"""
path = path_ops.translate_path("special://profile/playlists/video/")
if viewtype == "mixed":
plname = "%s - %s" % (tagname, mediatype)
xsppath = "%sPlex %s - %s.xsp" % (path, viewid, mediatype)
else:
plname = tagname
xsppath = "%sPlex %s.xsp" % (path, viewid)
# Create the playlist directory
if not path_ops.exists(path):
LOG.info("Creating directory: %s", path)
path_ops.makedirs(path)
# Only add the playlist if it doesn't already exists
if path_ops.exists(xsppath):
LOG.info('Path %s does exist', xsppath)
if delete:
path_ops.remove(xsppath)
LOG.info("Successfully removed playlist: %s.", tagname)
return
# Using write process since there's no guarantee the xml declaration works
# with etree
kinds = {
'homevideos': 'movies',
'movie': 'movies',
'show': 'tvshows'
}
LOG.info("Writing playlist file to: %s", xsppath)
with open(path_ops.encode_path(xsppath), 'wb') as filer:
filer.write(try_encode(
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>\n'
'<smartplaylist type="%s">\n\t'
'<name>Plex %s</name>\n\t'
'<match>all</match>\n\t'
'<rule field="tag" operator="is">\n\t\t'
'<value>%s</value>\n\t'
'</rule>\n'
'</smartplaylist>\n'
% (kinds.get(mediatype, mediatype), plname, tagname)))
LOG.info("Successfully added playlist: %s", tagname)
def delete_playlists():
"""
Clean up the playlists
"""
path = path_ops.translate_path('special://profile/playlists/video/')
for root, _, files in path_ops.walk(path):
for file in files:
if file.startswith('Plex'):
path_ops.remove(path_ops.path.join(root, file))
def delete_nodes():
"""
Clean up video nodes
"""
path = path_ops.translate_path("special://profile/library/video/")
for root, dirs, _ in path_ops.walk(path):
for directory in dirs:
if directory.startswith('Plex-'):
path_ops.rmtree(path_ops.path.join(root, directory))
break
def generate_file_md5(path): def generate_file_md5(path):
""" """
Generates the md5 hash value for the file located at path [unicode]. Generates the md5 hash value for the file located at path [unicode].

View file

@ -179,6 +179,9 @@ PLEX_TYPE_MUSICVIDEO = 'musicvideo'
PLEX_TYPE_PHOTO = 'photo' PLEX_TYPE_PHOTO = 'photo'
PLEX_TYPE_PLAYLIST = 'playlist'
PLEX_TYPE_CHANNEL = 'channel'
# Used for /:/timeline XML messages # Used for /:/timeline XML messages
PLEX_PLAYLIST_TYPE_VIDEO = 'video' PLEX_PLAYLIST_TYPE_VIDEO = 'video'
PLEX_PLAYLIST_TYPE_AUDIO = 'music' PLEX_PLAYLIST_TYPE_AUDIO = 'music'
@ -212,6 +215,8 @@ KODI_TYPE_MUSICVIDEO = 'musicvideo'
KODI_TYPE_PHOTO = 'photo' KODI_TYPE_PHOTO = 'photo'
KODI_TYPE_PLAYLIST = 'playlist'
KODI_VIDEOTYPES = ( KODI_VIDEOTYPES = (
KODI_TYPE_VIDEO, KODI_TYPE_VIDEO,
KODI_TYPE_MOVIE, KODI_TYPE_MOVIE,
@ -251,7 +256,12 @@ ADDON_TYPE = {
PLEX_TYPE_MOVIE: 'plugin.video.plexkodiconnect.movies', PLEX_TYPE_MOVIE: 'plugin.video.plexkodiconnect.movies',
PLEX_TYPE_CLIP: 'plugin.video.plexkodiconnect.movies', PLEX_TYPE_CLIP: 'plugin.video.plexkodiconnect.movies',
PLEX_TYPE_EPISODE: 'plugin.video.plexkodiconnect.tvshows', PLEX_TYPE_EPISODE: 'plugin.video.plexkodiconnect.tvshows',
PLEX_TYPE_SONG: 'plugin.video.plexkodiconnect' PLEX_TYPE_SEASON: 'plugin.video.plexkodiconnect.tvshows',
PLEX_TYPE_SHOW: 'plugin.video.plexkodiconnect.tvshows',
PLEX_TYPE_SONG: 'plugin.video.plexkodiconnect',
PLEX_TYPE_ALBUM: 'plugin.video.plexkodiconnect',
PLEX_TYPE_ARTIST: 'plugin.video.plexkodiconnect',
PLEX_TYPE_PLAYLIST: 'plugin.video.plexkodiconnect'
} }
ITEMTYPE_FROM_PLEXTYPE = { ITEMTYPE_FROM_PLEXTYPE = {
@ -276,6 +286,7 @@ ITEMTYPE_FROM_KODITYPE = {
KODITYPE_FROM_PLEXTYPE = { KODITYPE_FROM_PLEXTYPE = {
PLEX_TYPE_MOVIE: KODI_TYPE_MOVIE, PLEX_TYPE_MOVIE: KODI_TYPE_MOVIE,
PLEX_TYPE_CLIP: KODI_TYPE_CLIP,
PLEX_TYPE_EPISODE: KODI_TYPE_EPISODE, PLEX_TYPE_EPISODE: KODI_TYPE_EPISODE,
PLEX_TYPE_SEASON: KODI_TYPE_SEASON, PLEX_TYPE_SEASON: KODI_TYPE_SEASON,
PLEX_TYPE_SHOW: KODI_TYPE_SHOW, PLEX_TYPE_SHOW: KODI_TYPE_SHOW,
@ -284,7 +295,8 @@ KODITYPE_FROM_PLEXTYPE = {
PLEX_TYPE_ALBUM: KODI_TYPE_ALBUM, PLEX_TYPE_ALBUM: KODI_TYPE_ALBUM,
PLEX_TYPE_PHOTO: KODI_TYPE_PHOTO, PLEX_TYPE_PHOTO: KODI_TYPE_PHOTO,
'XXXXXX': 'musicvideo', 'XXXXXX': 'musicvideo',
'XXXXXXX': 'genre' 'XXXXXXX': 'genre',
PLEX_TYPE_PLAYLIST: KODI_TYPE_PLAYLIST
} }
PLEX_TYPE_FROM_KODI_TYPE = { PLEX_TYPE_FROM_KODI_TYPE = {
@ -316,7 +328,6 @@ KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE = {
PLEX_TYPE_PHOTO: KODI_TYPE_PHOTO PLEX_TYPE_PHOTO: KODI_TYPE_PHOTO
} }
KODI_PLAYLIST_TYPE_FROM_KODI_TYPE = { KODI_PLAYLIST_TYPE_FROM_KODI_TYPE = {
KODI_TYPE_VIDEO: KODI_TYPE_VIDEO, KODI_TYPE_VIDEO: KODI_TYPE_VIDEO,
KODI_TYPE_MOVIE: KODI_TYPE_VIDEO, KODI_TYPE_MOVIE: KODI_TYPE_VIDEO,
@ -344,6 +355,22 @@ REMAP_TYPE_FROM_PLEXTYPE = {
} }
ICON_FROM_PLEXTYPE = {
PLEX_TYPE_VIDEO: 'DefaultAddonAlbumInfo.png',
PLEX_TYPE_MOVIE: 'DefaultMovies.png',
PLEX_TYPE_EPISODE: 'DefaultTvShows.png',
PLEX_TYPE_SEASON: 'DefaultTvShows.png',
PLEX_TYPE_SHOW: 'DefaultTvShows.png',
PLEX_TYPE_CLIP: 'DefaultAddonAlbumInfo.png',
PLEX_TYPE_ARTIST: 'DefaultArtist.png',
PLEX_TYPE_ALBUM: 'DefaultAlbumCover.png',
PLEX_TYPE_SONG: 'DefaultMusicSongs.png',
PLEX_TYPE_AUDIO: 'DefaultAddonAlbumInfo.png',
PLEX_TYPE_PHOTO: 'DefaultPicture.png',
PLEX_TYPE_PLAYLIST: 'DefaultPlaylist.png',
'mixed': 'DefaultAddonAlbumInfo.png'
}
TRANSLATION_FROM_PLEXTYPE = { TRANSLATION_FROM_PLEXTYPE = {
PLEX_TYPE_MOVIE: 342, PLEX_TYPE_MOVIE: 342,
PLEX_TYPE_EPISODE: 20360, PLEX_TYPE_EPISODE: 20360,
@ -396,6 +423,21 @@ PLEX_TYPE_NUMBER_FROM_PLEX_TYPE = {
} }
# To be used with e.g. Kodi Widgets
MEDIATYPE_FROM_PLEX_TYPE = {
PLEX_TYPE_MOVIE: 'movies',
PLEX_TYPE_SHOW: 'tvshows',
PLEX_TYPE_SEASON: 'tvshows',
PLEX_TYPE_EPISODE: 'episodes',
PLEX_TYPE_ARTIST: 'artists',
PLEX_TYPE_ALBUM: 'albumbs',
PLEX_TYPE_SONG: 'songs',
PLEX_TYPE_CLIP: 'videos',
PLEX_TYPE_SET: 'movies',
PLEX_TYPE_PHOTO: 'photos',
'mixed': 'tvshows',
}
KODI_TO_PLEX_ARTWORK = { KODI_TO_PLEX_ARTWORK = {
'poster': 'thumb', 'poster': 'thumb',
'banner': 'banner', 'banner': 'banner',
@ -403,10 +445,10 @@ KODI_TO_PLEX_ARTWORK = {
} }
KODI_TO_PLEX_ARTWORK_EPISODE = { KODI_TO_PLEX_ARTWORK_EPISODE = {
'thumb': 'thumb', 'season.poster': 'parentThumb',
'poster': 'grandparentThumb', 'tvshow.poster': 'grandparentThumb',
'banner': 'banner', 'tvshow.fanart': 'grandparentArt',
'fanart': 'art' # 'tvshow.banner': 'banner' # Not included in PMS episode metadata
} }
# Might be implemented in the future: 'icon', 'landscape' (16:9) # Might be implemented in the future: 'icon', 'landscape' (16:9)

624
resources/lib/widgets.py Normal file
View file

@ -0,0 +1,624 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Code from script.module.metadatautils, kodidb.py
Loads of different functions called in SEPARATE Python instances through
e.g. plugin://... calls. Hence be careful to only rely on window variables.
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import urllib
try:
from multiprocessing.pool import ThreadPool
SUPPORTS_POOL = True
except Exception:
SUPPORTS_POOL = False
import xbmc
import xbmcgui
import xbmcvfs
from .plex_api import API
from .plex_db import PlexDB
from . import json_rpc as js, utils, variables as v
LOG = getLogger('PLEX.widget')
# To easily use threadpool, we can only pass one argument
PLEX_TYPE = None
SECTION_ID = None
APPEND_SHOW_TITLE = None
APPEND_SXXEXX = None
SYNCHED = True
# Need to chain the PMS keys
KEY = None
def process_method_on_list(method_to_run, items):
"""
helper method that processes a method on each listitem with pooling if the
system supports it
"""
all_items = []
if SUPPORTS_POOL:
pool = ThreadPool()
try:
all_items = pool.map(method_to_run, items)
except Exception:
# catch exception to prevent threadpool running forever
utils.ERROR(notify=True)
pool.close()
pool.join()
else:
all_items = [method_to_run(items) for item in items]
all_items = filter(None, all_items)
return all_items
def get_clean_image(image):
'''helper to strip all kodi tags/formatting of an image path/url'''
if not image:
return ""
if "music@" in image:
# fix for embedded images
thumbcache = xbmc.getCacheThumbName(image).replace(".tbn", ".jpg")
thumbcache = "special://thumbnails/%s/%s" % (thumbcache[0], thumbcache)
if not xbmcvfs.exists(thumbcache):
xbmcvfs.copy(image, thumbcache)
image = thumbcache
if image and "image://" in image:
image = image.replace("image://", "")
image = urllib.unquote(image.encode("utf-8"))
if image.endswith("/"):
image = image[:-1]
if not isinstance(image, unicode):
image = image.decode("utf8")
return image
def generate_item(xml_element):
"""
Meant to be consumed by metadatautils.kodidb.prepare_listitem(), and then
subsequently by metadatautils.kodidb.create_listitem()
Do NOT set resumetime - otherwise Kodi always resumes at that time
even if the user chose to start element from the beginning
listitem.setProperty('resumetime', str(userdata['Resume']))
The key 'file' needs to be set later with the item's path
"""
try:
if xml_element.tag in ('Directory', 'Playlist', 'Hub'):
return _generate_folder(xml_element)
else:
return _generate_content(xml_element)
except Exception:
# Usefull to catch everything here since we're using threadpool
LOG.error('xml that caused the crash: "%s": %s',
xml_element.tag, xml_element.attrib)
utils.ERROR(notify=True)
def _generate_folder(xml_element):
'''Generates "folder"/"directory" items that user can further navigate'''
api = API(xml_element)
art = api.artwork()
return {
'title': api.title(),
'label': api.title(),
'file': api.directory_path(section_id=SECTION_ID,
plex_type=PLEX_TYPE,
old_key=KEY),
'icon': 'DefaultFolder.png',
'art': {
'thumb': art['thumb'] if 'thumb' in art else
(art['poster'] if 'poster' in art else
'special://home/addons/%s/icon.png' % v.ADDON_ID),
'fanart': art['fanart'] if 'fanart' in art else
'special://home/addons/%s/fanart.jpg' % v.ADDON_ID},
'isFolder': True,
'type': '',
'IsPlayable': 'false',
}
def _generate_content(xml_element):
api = API(xml_element)
plex_type = api.plex_type()
kodi_type = v.KODITYPE_FROM_PLEXTYPE[plex_type]
userdata = api.userdata()
_, _, tvshowtitle, season_no, episode_no = api.episode_data()
db_item = xml_element.get('pkc_db_item')
if db_item:
# Item is synched to the Kodi db - let's use that info
# (will thus e.g. include additional artwork or metadata)
item = js.item_details(db_item['kodi_id'], kodi_type)
else:
people = api.people()
cast = [{
'name': x[0],
'thumbnail': x[1],
'role': x[2],
'order': x[3],
} for x in api.people_list()['actor']]
item = {
'cast': cast,
'country': api.country_list(),
'dateadded': api.date_created(), # e.g '2019-01-03 19:40:59'
'director': people['Director'], # list of [str]
'duration': userdata['Runtime'],
'episode': episode_no,
# 'file': '', # e.g. 'videodb://tvshows/titles/20'
'genre': api.genre_list(),
# 'imdbnumber': '', # e.g.'341663'
'label': api.title(), # e.g. '1x05. Category 55 Emergency Doomsday Crisis'
'lastplayed': userdata['LastPlayedDate'], # e.g. '2019-01-04 16:05:03'
'mpaa': api.content_rating(), # e.g. 'TV-MA'
'originaltitle': '', # e.g. 'Titans (2018)'
'playcount': userdata['PlayCount'], # [int]
'plot': api.plot(), # [str]
'plotoutline': api.tagline(),
'premiered': api.premiere_date(), # '2018-10-12'
'rating': api.audience_rating(), # [float]
'season': season_no,
'sorttitle': api.sorttitle(), # 'Titans (2018)'
'studio': api.music_studio_list(), # e.g. 'DC Universe'
'tag': [], # List of tags this item belongs to
'tagline': api.tagline(),
'thumbnail': '', # e.g. 'image://https%3a%2f%2fassets.tv'
'title': api.title(), # 'Titans (2018)'
'type': kodi_type,
'trailer': api.trailer(),
'tvshowtitle': tvshowtitle,
'uniqueid': {
'imdbnumber': api.provider('imdb') or '',
'tvdb_id': api.provider('tvdb') or ''
},
'votes': '0', # [str]!
'writer': people['Writer'], # list of [str]
'year': api.year(), # [int]
}
if plex_type in (v.PLEX_TYPE_EPISODE, v.PLEX_TYPE_SEASON, v.PLEX_TYPE_SHOW):
leaves = api.leave_count()
if leaves:
item['extraproperties'] = leaves
# Add all the artwork we can
item['art'] = api.artwork(full_artwork=True)
# Add all info for e.g. video and audio streams
item['streamdetails'] = api.mediastreams()
# Cleanup required due to the way metadatautils works
if not item['lastplayed']:
del item['lastplayed']
for stream in item['streamdetails']['video']:
stream['height'] = utils.cast(int, stream['height'])
stream['width'] = utils.cast(int, stream['width'])
stream['aspect'] = utils.cast(float, stream['aspect'])
item['streamdetails']['subtitle'] = [{'language': x} for x in item['streamdetails']['subtitle']]
# Resume point
resume = api.resume_point()
if resume:
item['resume'] = {
'position': resume,
'total': userdata['Runtime']
}
item['icon'] = v.ICON_FROM_PLEXTYPE[plex_type]
# Some customization
if plex_type == v.PLEX_TYPE_EPISODE:
# Prefix to the episode's title/label
if season_no is not None and episode_no is not None:
if APPEND_SXXEXX is True:
item['label'] = "S%.2dE%.2d - %s" % (season_no, episode_no, item['label'])
if APPEND_SHOW_TITLE is True:
item['label'] = "%s - %s " % (tvshowtitle, item['label'])
# Determine the path for this item
key = api.path_and_plex_id()
if key.startswith('/system/services') or key.startswith('http'):
params = {
'mode': 'plex_node',
'key': key,
'offset': xml_element.attrib.get('viewOffset', '0'),
}
url = "plugin://%s?%s" % (v.ADDON_ID, urllib.urlencode(params))
elif plex_type == v.PLEX_TYPE_PHOTO:
url = api.get_picture_path()
else:
url = api.path()
if not db_item and plex_type == v.PLEX_TYPE_EPISODE:
# Hack - Item is not synched to the Kodi database
# We CANNOT use paths that show up in the Kodi paths table!
url = url.replace('plugin.video.plexkodiconnect.tvshows',
'plugin.video.plexkodiconnect')
item['file'] = url
return item
def attach_kodi_ids(xml):
"""
Attaches the kodi db_item to the xml's children, attribute 'pkc_db_item'
"""
if not SYNCHED:
return
with PlexDB(lock=False) as plexdb:
for child in xml:
api = API(child)
db_item = plexdb.item_by_id(api.plex_id(), api.plex_type())
child.set('pkc_db_item', db_item)
return xml
def prepare_listitem(item):
"""
helper to convert kodi output from json api to compatible format for
listitems
Code from script.module.metadatautils, kodidb.py
"""
try:
# fix values returned from json to be used as listitem values
properties = item.get("extraproperties", {})
# set type
for idvar in [
('episode', 'DefaultTVShows.png'),
('tvshow', 'DefaultTVShows.png'),
('movie', 'DefaultMovies.png'),
('song', 'DefaultAudio.png'),
('album', 'DefaultAudio.png'),
('artist', 'DefaultArtist.png'),
('musicvideo', 'DefaultMusicVideos.png'),
('recording', 'DefaultTVShows.png'),
('channel', 'DefaultAddonPVRClient.png')]:
dbid = item.get(idvar[0] + "id")
if dbid:
properties["DBID"] = str(dbid)
if not item.get("type"):
item["type"] = idvar[0]
if not item.get("icon"):
item["icon"] = idvar[1]
break
# general properties
if "genre" in item and isinstance(item['genre'], list):
item["genre"] = " / ".join(item['genre'])
if "studio" in item and isinstance(item['studio'], list):
item["studio"] = " / ".join(item['studio'])
if "writer" in item and isinstance(item['writer'], list):
item["writer"] = " / ".join(item['writer'])
if 'director' in item and isinstance(item['director'], list):
item["director"] = " / ".join(item['director'])
if 'artist' in item and not isinstance(item['artist'], list):
item["artist"] = [item['artist']]
if 'artist' not in item:
item["artist"] = []
if item['type'] == "album" and 'album' not in item and 'label' in item:
item['album'] = item['label']
if "duration" not in item and "runtime" in item:
if (item["runtime"] / 60) > 300:
item["duration"] = item["runtime"] / 60
else:
item["duration"] = item["runtime"]
if "plot" not in item and "comment" in item:
item["plot"] = item["comment"]
if "tvshowtitle" not in item and "showtitle" in item:
item["tvshowtitle"] = item["showtitle"]
if "premiered" not in item and "firstaired" in item:
item["premiered"] = item["firstaired"]
if "firstaired" in item and "aired" not in item:
item["aired"] = item["firstaired"]
if "imdbnumber" not in properties and "imdbnumber" in item:
properties["imdbnumber"] = item["imdbnumber"]
if "imdbnumber" not in properties and "uniqueid" in item:
for value in item["uniqueid"].values():
if value.startswith("tt"):
properties["imdbnumber"] = value
properties["dbtype"] = item["type"]
properties["DBTYPE"] = item["type"]
properties["type"] = item["type"]
properties["path"] = item.get("file")
# cast
list_cast = []
list_castandrole = []
item["cast_org"] = item.get("cast", [])
if "cast" in item and isinstance(item["cast"], list):
for castmember in item["cast"]:
if isinstance(castmember, dict):
list_cast.append(castmember.get("name", ""))
list_castandrole.append((castmember["name"], castmember["role"]))
else:
list_cast.append(castmember)
list_castandrole.append((castmember, ""))
item["cast"] = list_cast
item["castandrole"] = list_castandrole
if "season" in item and "episode" in item:
properties["episodeno"] = "s%se%s" % (item.get("season"), item.get("episode"))
if "resume" in item:
properties["resumetime"] = str(item['resume']['position'])
properties["totaltime"] = str(item['resume']['total'])
properties['StartOffset'] = str(item['resume']['position'])
# streamdetails
if "streamdetails" in item:
streamdetails = item["streamdetails"]
audiostreams = streamdetails.get('audio', [])
videostreams = streamdetails.get('video', [])
subtitles = streamdetails.get('subtitle', [])
if len(videostreams) > 0:
stream = videostreams[0]
height = stream.get("height", "")
width = stream.get("width", "")
if height and width:
resolution = ""
if width <= 720 and height <= 480:
resolution = "480"
elif width <= 768 and height <= 576:
resolution = "576"
elif width <= 960 and height <= 544:
resolution = "540"
elif width <= 1280 and height <= 720:
resolution = "720"
elif width <= 1920 and height <= 1080:
resolution = "1080"
elif width * height >= 6000000:
resolution = "4K"
properties["VideoResolution"] = resolution
if stream.get("codec", ""):
properties["VideoCodec"] = str(stream["codec"])
if stream.get("aspect", ""):
properties["VideoAspect"] = str(round(stream["aspect"], 2))
item["streamdetails"]["video"] = stream
# grab details of first audio stream
if len(audiostreams) > 0:
stream = audiostreams[0]
properties["AudioCodec"] = stream.get('codec', '')
properties["AudioChannels"] = str(stream.get('channels', ''))
properties["AudioLanguage"] = stream.get('language', '')
item["streamdetails"]["audio"] = stream
# grab details of first subtitle
if len(subtitles) > 0:
properties["SubtitleLanguage"] = subtitles[0].get('language', '')
item["streamdetails"]["subtitle"] = subtitles[0]
else:
item["streamdetails"] = {}
item["streamdetails"]["video"] = {'duration': item.get('duration', 0)}
# additional music properties
if 'album_description' in item:
properties["Album_Description"] = item.get('album_description')
# pvr properties
if "channellogo" in item:
properties["channellogo"] = item["channellogo"]
properties["channelicon"] = item["channellogo"]
if "episodename" in item:
properties["episodename"] = item["episodename"]
if "channel" in item:
properties["channel"] = item["channel"]
properties["channelname"] = item["channel"]
item["label2"] = item["title"]
# artwork
art = item.get("art", {})
if item["type"] in ["episode", "season"]:
if not art.get("fanart") and art.get("season.fanart"):
art["fanart"] = art["season.fanart"]
if not art.get("poster") and art.get("season.poster"):
art["poster"] = art["season.poster"]
if not art.get("landscape") and art.get("season.landscape"):
art["poster"] = art["season.landscape"]
if not art.get("fanart") and art.get("tvshow.fanart"):
art["fanart"] = art.get("tvshow.fanart")
if not art.get("poster") and art.get("tvshow.poster"):
art["poster"] = art.get("tvshow.poster")
if not art.get("clearlogo") and art.get("tvshow.clearlogo"):
art["clearlogo"] = art.get("tvshow.clearlogo")
if not art.get("banner") and art.get("tvshow.banner"):
art["banner"] = art.get("tvshow.banner")
if not art.get("landscape") and art.get("tvshow.landscape"):
art["landscape"] = art.get("tvshow.landscape")
if not art.get("fanart") and item.get('fanart'):
art["fanart"] = item.get('fanart')
if not art.get("thumb") and item.get('thumbnail'):
art["thumb"] = get_clean_image(item.get('thumbnail'))
if not art.get("thumb") and art.get('poster'):
art["thumb"] = get_clean_image(art.get('poster'))
if not art.get("thumb") and item.get('icon'):
art["thumb"] = get_clean_image(item.get('icon'))
if not item.get("thumbnail") and art.get('thumb'):
item["thumbnail"] = art["thumb"]
# clean art
for key, value in art.iteritems():
if not isinstance(value, (str, unicode)):
art[key] = ""
elif value:
art[key] = get_clean_image(value)
item["art"] = art
item["extraproperties"] = properties
# return the result
return item
except Exception:
utils.ERROR(notify=True)
LOG.error('item that caused crash: %s', item)
def create_listitem(item, as_tuple=True, offscreen=True):
"""
helper to create a kodi listitem from kodi compatible dict with mediainfo
WARNING: paths, so item['file'] for items NOT synched to the Kodi DB
shall NOT occur in the Kodi paths table!
Kodi information screen does not work otherwise
Code from script.module.metadatautils, kodidb.py
"""
try:
if v.KODIVERSION > 17:
liz = xbmcgui.ListItem(
label=item.get("label", ""),
label2=item.get("label2", ""),
path=item['file'],
offscreen=offscreen)
else:
liz = xbmcgui.ListItem(
label=item.get("label", ""),
label2=item.get("label2", ""),
path=item['file'])
# only set isPlayable prop if really needed
if item.get("isFolder", False):
liz.setProperty('IsPlayable', 'false')
elif "plugin://script.skin.helper" not in item['file']:
liz.setProperty('IsPlayable', 'true')
nodetype = "Video"
if item["type"] in ["song", "album", "artist"]:
nodetype = "Music"
# extra properties
for key, value in item["extraproperties"].iteritems():
liz.setProperty(key, value)
# video infolabels
if nodetype == "Video":
infolabels = {
"title": item.get("title"),
"size": item.get("size"),
"genre": item.get("genre"),
"year": item.get("year"),
"top250": item.get("top250"),
"tracknumber": item.get("tracknumber"),
"rating": item.get("rating"),
"playcount": item.get("playcount"),
"overlay": item.get("overlay"),
"cast": item.get("cast"),
"castandrole": item.get("castandrole"),
"director": item.get("director"),
"mpaa": item.get("mpaa"),
"plot": item.get("plot"),
"plotoutline": item.get("plotoutline"),
"originaltitle": item.get("originaltitle"),
"sorttitle": item.get("sorttitle"),
"duration": item.get("duration"),
"studio": item.get("studio"),
"tagline": item.get("tagline"),
"writer": item.get("writer"),
"tvshowtitle": item.get("tvshowtitle"),
"premiered": item.get("premiered"),
"status": item.get("status"),
"code": item.get("imdbnumber"),
"imdbnumber": item.get("imdbnumber"),
"aired": item.get("aired"),
"credits": item.get("credits"),
"album": item.get("album"),
"artist": item.get("artist"),
"votes": item.get("votes"),
"trailer": item.get("trailer"),
# "progress": item.get('progresspercentage')
}
if item["type"] == "episode":
infolabels["season"] = item["season"]
infolabels["episode"] = item["episode"]
# streamdetails
if item.get("streamdetails"):
liz.addStreamInfo("video", item["streamdetails"].get("video", {}))
liz.addStreamInfo("audio", item["streamdetails"].get("audio", {}))
liz.addStreamInfo("subtitle", item["streamdetails"].get("subtitle", {}))
if "dateadded" in item:
infolabels["dateadded"] = item["dateadded"]
if "date" in item:
infolabels["date"] = item["date"]
# music infolabels
else:
infolabels = {
"title": item.get("title"),
"size": item.get("size"),
"genre": item.get("genre"),
"year": item.get("year"),
"tracknumber": item.get("track"),
"album": item.get("album"),
"artist": " / ".join(item.get('artist')),
"rating": str(item.get("rating", 0)),
"lyrics": item.get("lyrics"),
"playcount": item.get("playcount")
}
if "date" in item:
infolabels["date"] = item["date"]
if "duration" in item:
infolabels["duration"] = item["duration"]
if "lastplayed" in item:
infolabels["lastplayed"] = item["lastplayed"]
# setting the dbtype and dbid is supported from kodi krypton and up
# PKC hack: ignore empty type
if item["type"] not in ["recording", "channel", "favourite", ""]:
infolabels["mediatype"] = item["type"]
# setting the dbid on music items is not supported ?
if nodetype == "Video" and "DBID" in item["extraproperties"]:
infolabels["dbid"] = item["extraproperties"]["DBID"]
if "lastplayed" in item:
infolabels["lastplayed"] = item["lastplayed"]
# assign the infolabels
liz.setInfo(type=nodetype, infoLabels=infolabels)
# artwork
liz.setArt(item.get("art", {}))
if "icon" in item:
liz.setIconImage(item['icon'])
if "thumbnail" in item:
liz.setThumbnailImage(item['thumbnail'])
# contextmenu
if item["type"] in ["episode", "season"] and "season" in item and "tvshowid" in item:
# add series and season level to widgets
if "contextmenu" not in item:
item["contextmenu"] = []
item["contextmenu"] += [
(xbmc.getLocalizedString(20364), "ActivateWindow(Video,videodb://tvshows/titles/%s/,return)"
% (item["tvshowid"])),
(xbmc.getLocalizedString(20373), "ActivateWindow(Video,videodb://tvshows/titles/%s/%s/,return)"
% (item["tvshowid"], item["season"]))]
if "contextmenu" in item:
liz.addContextMenuItems(item["contextmenu"])
if as_tuple:
return (item["file"], liz, item.get("isFolder", False))
else:
return liz
except Exception:
utils.ERROR(notify=True)
LOG.error('item that should have been turned into a listitem: %s', item)
def create_main_entry(item):
'''helper to create a simple (directory) listitem'''
return {
'title': item[0],
'label': item[0],
'file': item[1],
'icon': item[2],
'art': {
'thumb': 'special://home/addons/%s/icon.png' % v.ADDON_ID,
'fanart': 'special://home/addons/%s/fanart.jpg' % v.ADDON_ID},
'isFolder': True,
'type': '',
'IsPlayable': 'false'
}