Merge pull request #710 from croneter/select-library

Choose which Plex libraries get synched to Kodi
This commit is contained in:
croneter 2019-02-08 15:19:12 +01:00 committed by GitHub
commit aa27b4ad1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 262 additions and 130 deletions

View file

@ -141,6 +141,10 @@ class Main():
elif mode == 'hub':
entrypoint.hub(params.get('type'))
elif mode == 'select-libraries':
LOG.info('User requested to select Plex libraries')
transfer.plex_command('select-libraries')
else:
entrypoint.show_main_menu(content_type=params.get('content_type'))

View file

@ -558,6 +558,12 @@ msgctxt "#30523"
msgid "Also show sync progress for playstate and user data"
msgstr ""
# PKC Settings - Sync Options
msgctxt "#30524"
msgid "Select Plex libraries to sync"
msgstr ""
# PKC Settings - Playback
msgctxt "#30527"
msgid "Ignore specials in next episodes"

View file

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

View file

@ -68,6 +68,7 @@ class FullSync(common.fullsync_mixin):
self.context = None
self.get_children = None
self.successful = None
self.section_success = None
self.install_sync_done = utils.settings('SyncInstallRunDone') == 'true'
self.threader = backgroundthread.ThreaderManager(
worker=backgroundthread.NonstoppingBackgroundWorker,
@ -232,8 +233,8 @@ class FullSync(common.fullsync_mixin):
if not itemtype.update_userdata(xml_item, section['plex_type']):
# Somehow did not sync this item yet
itemtype.add_update(xml_item,
section['section_name'],
section['section_id'])
section_name=section['section_name'],
section_id=section['section_id'])
itemtype.plexdb.update_last_sync(int(xml_item.attrib['ratingKey']),
section['plex_type'],
self.current_sync)
@ -248,38 +249,39 @@ class FullSync(common.fullsync_mixin):
LOG.error('Could not entirely process section %s', section)
return False
def threaded_get_iterators(self, kinds, queue, updated_at=None,
last_viewed_at=None):
def threaded_get_iterators(self, kinds, queue, all_items=False):
"""
PF.SectionItems is costly, so let's do it asynchronous
"""
if self.repair:
updated_at = None
last_viewed_at = None
else:
updated_at = updated_at - UPDATED_AT_SAFETY if updated_at else None
last_viewed_at = last_viewed_at - LAST_VIEWED_AT_SAFETY \
if last_viewed_at else None
try:
for kind in kinds:
for section in (x for x in sections.SECTIONS
if x['plex_type'] == kind[1]):
if self.isCanceled():
return
if not section['sync_to_kodi']:
LOG.info('User chose to not sync section %s', section)
continue
element = copy.deepcopy(section)
element['section_type'] = element['plex_type']
element['plex_type'] = kind[0]
element['element_type'] = kind[1]
element['context'] = kind[2]
element['get_children'] = kind[3]
if self.repair or all_items:
updated_at = None
else:
updated_at = section['last_sync'] - UPDATED_AT_SAFETY \
if section['last_sync'] else None
try:
element['iterator'] = PF.SectionItems(section['section_id'],
plex_type=kind[0],
updated_at=updated_at,
last_viewed_at=last_viewed_at)
last_viewed_at=None)
except RuntimeError:
LOG.warn('Sync at least partially unsuccessful')
self.successful = False
self.section_success = False
else:
queue.put(element)
finally:
@ -303,14 +305,13 @@ class FullSync(common.fullsync_mixin):
# Already start setting up the iterators. We need to enforce
# syncing e.g. show before season before episode
iterator_queue = Queue.Queue()
updated_at = int(utils.settings('lastfullsync')) or None
task = backgroundthread.FunctionAsTask(self.threaded_get_iterators,
None,
kinds,
iterator_queue,
updated_at=updated_at)
iterator_queue)
backgroundthread.BGThreader.addTask(task)
while True:
self.section_success = True
section = iterator_queue.get()
iterator_queue.task_done()
if section is None:
@ -323,10 +324,14 @@ class FullSync(common.fullsync_mixin):
# Now do the heavy lifting
if self.isCanceled() or not self.addupdate_section(section):
return False
if self.section_success:
# Need to check because a thread might have missed to get
# some items from the PMS
with PlexDB() as plexdb:
# Set the new time mark for the next delta sync
plexdb.update_section_last_sync(section['section_id'],
self.current_sync)
common.update_kodi_library(video=True, music=True)
if self.successful:
# Set timestamp for next sync - neglecting playstates!
utils.settings('lastfullsync', value=str(int(self.current_sync)))
# In order to not delete all your songs again
if app.SYNC.enable_music:
kinds.extend([
@ -347,7 +352,8 @@ class FullSync(common.fullsync_mixin):
task = backgroundthread.FunctionAsTask(self.threaded_get_iterators,
None,
kinds,
iterator_queue)
iterator_queue,
all_items=True)
backgroundthread.BGThreader.addTask(task)
while True:
section = iterator_queue.get()
@ -364,7 +370,7 @@ class FullSync(common.fullsync_mixin):
return False
# Delete movies that are not on Plex anymore
LOG.info('Looking for items to delete')
LOG.debug('Looking for items to delete')
kinds = [
(v.PLEX_TYPE_MOVIE, itemtypes.Movie),
(v.PLEX_TYPE_SHOW, itemtypes.Show),

View file

@ -4,7 +4,7 @@ from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import copy
from . import common, videonodes
from . import videonodes
from ..utils import cast
from ..plex_db import PlexDB
from .. import kodi_db
@ -22,6 +22,16 @@ SECTIONS = []
IS_CANCELED = None
def force_full_sync():
"""
Resets the sync timestamp for all sections to 0, thus forcing a subsequent
full sync (not delta)
"""
LOG.info('Telling PKC to do a full sync instead of a delta sync')
with PlexDB() as plexdb:
plexdb.force_full_sync()
def sync_from_pms(parent_self):
"""
Sync the Plex library sections
@ -35,6 +45,7 @@ def sync_from_pms(parent_self):
def _sync_from_pms():
global PLAYLISTS, NODES, SECTIONS
sections = PF.get_plex_sections()
try:
sections.attrib
@ -45,7 +56,7 @@ def _sync_from_pms():
# Will reboot Kodi is new library detected
music.excludefromscan_music_folders(xml=sections)
global PLAYLISTS, NODES, SECTIONS
VNODES.clearProperties()
SECTIONS = []
NODES = {
v.PLEX_TYPE_MOVIE: [],
@ -54,64 +65,51 @@ def _sync_from_pms():
v.PLEX_TYPE_PHOTO: []
}
PLAYLISTS = copy.deepcopy(NODES)
sorted_sections = []
for section in sections:
if (section.attrib['type'] in
(v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW, v.PLEX_TYPE_PHOTO,
v.PLEX_TYPE_ARTIST)):
sorted_sections.append(section.attrib['title'])
LOG.debug('Sorted sections: %s', sorted_sections)
totalnodes = len(sorted_sections)
VNODES.clearProperties()
with PlexDB() as plexdb:
# Backup old sections to delete them later, if needed (at the end
# of this method, only unused sections will be left in old_sections)
old_sections = list(plexdb.section_ids())
old_sections = list(plexdb.all_sections())
with kodi_db.KodiVideoDB() as kodidb:
for section in sections:
for index, section in enumerate(sections):
_process_section(section,
kodidb,
plexdb,
sorted_sections,
old_sections,
totalnodes)
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.section_infos())
utils.window('Plex.nodes.total', str(totalnodes))
LOG.info("Finished processing library sections: %s", SECTIONS)
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, sorted_sections,
old_sections, totalnodes):
def _process_section(section_xml, kodidb, plexdb, index, old_sections):
global PLAYLISTS, NODES
folder = section_xml.attrib
plex_type = folder['type']
# 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 totalnodes
return
section_id = cast(int, folder['key'])
section_name = folder['title']
global PLAYLISTS, NODES
# 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)
try:
current_sectionname = section[1]
current_sectiontype = section[2]
current_tagid = section[3]
except TypeError:
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
@ -121,28 +119,30 @@ def _process_section(section_xml, kodidb, plexdb, sorted_sections,
playlists.append(section_name)
# Create the video node
if section_name not in nodes:
VNODES.viewNode(sorted_sections.index(section_name),
VNODES.viewNode(index,
section_name,
plex_type,
None,
section_id)
nodes.append(section_name)
totalnodes += 1
# Add view to plex database
plexdb.add_section(section_id, section_name, plex_type, tagid)
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, current_sectionname, current_sectiontype,
current_tagid)
section_id, section['section_name'], section['plex_type'],
section['kodi_tagid'])
# Remove views that are still valid to delete rest later
try:
old_sections.remove(section_id)
except ValueError:
# View was just created, nothing to remove
pass
for section in old_sections:
if section['section_id'] == section_id:
old_sections.remove(section)
break
# View was modified, update with latest info
if current_sectionname != section_name:
if section['section_name'] != section_name:
LOG.info('section id: %s new sectionname: %s',
section_id, section_name)
tagid = kodidb.create_tag(section_name)
@ -151,22 +151,24 @@ def _process_section(section_xml, kodidb, plexdb, sorted_sections,
plexdb.add_section(section_id,
section_name,
plex_type,
tagid)
tagid,
section['sync_to_kodi'], # Use "old" setting
section['last_sync'])
if plexdb.section_id_by_name(current_sectionname) is None:
if plexdb.section_id_by_name(section['section_name']) is None:
# The tag could be a combined view. Ensure there's
# no other tags with the same name before deleting
# playlist.
utils.playlist_xsp(plex_type,
current_sectionname,
section['section_name'],
section_id,
current_sectiontype,
section['plex_type'],
True)
# Delete video node
if plex_type != "musicvideos":
VNODES.viewNode(
indexnumber=sorted_sections.index(section_name),
tagname=current_sectionname,
indexnumber=index,
tagname=section['section_name'],
mediatype=plex_type,
viewtype=None,
viewid=section_id,
@ -179,17 +181,16 @@ def _process_section(section_xml, kodidb, plexdb, sorted_sections,
playlists.append(section_name)
# Add new video node
if section_name not in nodes and plex_type != "musicvideos":
VNODES.viewNode(sorted_sections.index(section_name),
VNODES.viewNode(index,
section_name,
plex_type,
None,
section_id)
nodes.append(section_name)
totalnodes += 1
# Update items with new tag
for kodi_id in plexdb.kodiid_by_sectionid(section_id, plex_type):
kodidb.update_tag(
current_tagid, tagid, kodi_id, current_sectiontype)
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
@ -200,14 +201,12 @@ def _process_section(section_xml, kodidb, plexdb, sorted_sections,
playlists.append(section_name)
# Create the video node if not already exists
if section_name not in nodes and plex_type != "musicvideos":
VNODES.viewNode(sorted_sections.index(section_name),
VNODES.viewNode(index,
section_name,
plex_type,
None,
section_id)
nodes.append(section_name)
totalnodes += 1
return totalnodes
def _delete_kodi_db_items(section_id, section_type):
@ -246,25 +245,69 @@ def delete_sections(old_sections):
Deletes all elements for a Plex section that has been deleted. (e.g. all
TV shows, Seasons and Episodes of a Show section)
"""
try:
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:
old_sections = [plexdb.section(x) for x in old_sections]
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[1]),
icon='{plex}',
sound=False)
if section[2] == v.PLEX_TYPE_PHOTO:
# not synced - just remove the link in our Plex sections table
pass
plexdb.remove_section(section['section_id'])
def choose_libraries():
"""
Displays a dialog for the user to select the libraries he wants synched
Returns True if this was successful, False if not
"""
# Re-set value in order to make sure we got the lastest user input
app.SYNC.enable_music = utils.settings('enableMusic') == 'true'
import xbmcgui
sections = []
preselect = []
index = 0
for section in SECTIONS:
if not app.SYNC.enable_music and section['plex_type'] == v.PLEX_TYPE_ARTIST:
LOG.info('Ignoring music section: %s', section)
continue
elif section['plex_type'] == v.PLEX_TYPE_PHOTO:
continue
else:
sections.append(section['section_name'])
if section['sync_to_kodi']:
preselect.append(index)
index += 1
# "Select Plex libraries to sync"
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:
if not _delete_kodi_db_items(section[0], section[2]):
return
# Only remove Plex entry if we've removed all items first
with PlexDB() as plexdb:
plexdb.remove_section(section[0])
finally:
common.update_kodi_library()
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',
value=app.CONN.machine_identifier)
return True

View file

@ -194,7 +194,8 @@ def initialize():
section_name TEXT,
plex_type TEXT,
kodi_tagid INTEGER,
sync_to_kodi INTEGER)
sync_to_kodi INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS movie(

View file

@ -4,43 +4,39 @@ from __future__ import absolute_import, division, unicode_literals
class Sections(object):
def section_ids(self):
def all_sections(self):
"""
Returns an iterator for section Plex ids for all sections
"""
self.cursor.execute('SELECT section_id FROM sections')
return (x[0] for x in self.cursor)
def section_infos(self):
"""
Returns an iterator for dicts for all Plex libraries:
{
'section_id'
'section_name'
'plex_type'
'kodi_tagid'
'sync_to_kodi'
}
Returns an iterator for all sections
"""
self.cursor.execute('SELECT * FROM sections')
return ({'section_id': x[0],
'section_name': x[1],
'plex_type': x[2],
'kodi_tagid': x[3],
'sync_to_kodi': x[4]} for x in self.cursor)
return (self.entry_to_section(x) for x in self.cursor)
def section(self, section_id):
"""
For section_id, returns the tuple (or None)
For section_id, returns the dict
section_id INTEGER PRIMARY KEY,
section_name TEXT,
plex_type TEXT,
kodi_tagid INTEGER,
sync_to_kodi INTEGER
sync_to_kodi BOOL,
last_sync INTEGER
"""
self.cursor.execute('SELECT * FROM sections WHERE section_id = ? LIMIT 1',
(section_id, ))
return self.cursor.fetchone()
return self.entry_to_section(self.cursor.fetchone())
@staticmethod
def entry_to_section(entry):
if not entry:
return
return {
'section_id': entry[0],
'section_name': entry[1],
'plex_type': entry[2],
'kodi_tagid': entry[3],
'sync_to_kodi': entry[4] == 1,
'last_sync': entry[5]
}
def section_id_by_name(self, section_name):
"""
@ -54,22 +50,35 @@ class Sections(object):
pass
def add_section(self, section_id, section_name, plex_type, kodi_tagid,
sync_to_kodi=True):
sync_to_kodi, last_sync):
"""
Appends a Plex section to the Plex sections table
sync=False: Plex library won't be synced to Kodi
"""
query = '''
INSERT OR REPLACE INTO sections(
section_id, section_name, plex_type, kodi_tagid, sync_to_kodi)
VALUES (?, ?, ?, ?, ?)
section_id,
section_name,
plex_type,
kodi_tagid,
sync_to_kodi,
last_sync)
VALUES (?, ?, ?, ?, ?, ?)
'''
self.cursor.execute(query,
(section_id,
section_name,
plex_type,
kodi_tagid,
sync_to_kodi))
sync_to_kodi,
last_sync))
def update_section(self, section_id, section_name):
"""
Updates the section with section_id
"""
query = 'UPDATE sections SET section_name = ? WHERE section_id = ?'
self.cursor.execute(query, (section_name, section_id))
def remove_section(self, section_id):
"""
@ -77,3 +86,35 @@ class Sections(object):
"""
self.cursor.execute('DELETE FROM sections WHERE section_id = ?',
(section_id, ))
def update_section_sync(self, section_id, sync_to_kodi):
"""
Updates whether we should sync sections_id (sync=True) or not
"""
if sync_to_kodi:
query = '''
UPDATE sections
SET sync_to_kodi = ?
WHERE section_id = ?
'''
else:
# Set last_sync = 0 in order to force a full sync if reactivated
query = '''
UPDATE sections
SET sync_to_kodi = ?, last_sync = 0
WHERE section_id = ?
'''
self.cursor.execute(query, (sync_to_kodi, section_id))
def update_section_last_sync(self, section_id, last_sync):
"""
Updates the timestamp for the section
"""
self.cursor.execute('UPDATE sections SET last_sync = ? WHERE section_id = ?',
(last_sync, section_id))
def force_full_sync(self):
"""
Sets the last_sync flag to 0 for every section
"""
self.cursor.execute('UPDATE sections SET last_sync = 0')

View file

@ -9,7 +9,7 @@ import xbmcgui
from . import utils, clientinfo, timing
from . import initialsetup
from . import kodimonitor
from . import sync
from . import sync, library_sync
from . import websocket_client
from . import plex_companion
from . import plex_functions as PF, playqueue as PQ
@ -97,8 +97,7 @@ class Service():
# Load/Reset PKC entirely - important for user/Kodi profile switch
# Clear video nodes properties
from .library_sync import videonodes
videonodes.VideoNodes().clearProperties()
library_sync.VideoNodes().clearProperties()
clientinfo.getDeviceId()
# Init time-offset between Kodi and Plex
timing.KODI_PLEX_TIME_OFFSET = float(utils.settings('kodiplextimeoffset') or 0.0)
@ -222,7 +221,7 @@ class Service():
utils.delete_nodes()
app.ACCOUNT.set_unauthenticated()
# Force full sync after login
utils.settings('lastfullsync', value='0')
library_sync.force_full_sync()
app.SYNC.run_lib_scan = 'full'
# Enable the main loop to display user selection dialog
app.APP.suspend = False
@ -286,6 +285,33 @@ class Service():
LOG.info("Entering PMS address complete")
return True
def choose_plex_libraries(self):
if not app.CONN.online:
LOG.error('PMS not online to choose libraries')
# "{0} offline"
utils.dialog('notification',
utils.lang(29999),
utils.lang(39213).format(app.CONN.server_name or ''),
icon='{plex}')
return
if not app.ACCOUNT.authenticated:
LOG.error('Not yet authenticated for PMS to choose libraries')
# "Unauthorized for PMS"
utils.dialog('notification', utils.lang(29999), utils.lang(30017))
return
app.APP.suspend_threads()
from .library_sync import sections
try:
# Get newest sections from the PMS
if not sections.sync_from_pms(self):
return
if not sections.choose_libraries():
return
# Force a full sync
app.SYNC.run_lib_scan = 'full'
finally:
app.APP.resume_threads()
def _do_auth(self):
LOG.info('Authenticating user')
if app.ACCOUNT.plex_username and not app.ACCOUNT.force_login: # Found a user in the settings, try to authenticate
@ -435,6 +461,8 @@ class Service():
app.SYNC.run_lib_scan = 'fanart'
elif plex_command == 'textures-scan':
app.SYNC.run_lib_scan = 'textures'
elif plex_command == 'select-libraries':
self.choose_plex_libraries()
elif plex_command == 'RESET-PKC':
utils.reset()
if task:

View file

@ -466,7 +466,7 @@ def wipe_database():
kodi_db.reset_cached_images()
# reset the install run flag
settings('SyncInstallRunDone', value="false")
settings('lastfullsync', value="0")
settings('sections_asked_for_machine_identifier', value='')
init_dbs()
LOG.info('Wiping done')
if settings('kodi_db_has_been_wiped_clean') != 'true':

View file

@ -49,9 +49,11 @@
<setting id="dbCreatedWithVersion" type="text" default="" visible="false"/>
<setting id="plexid" type="text" default="" visible="false"/>
<setting id="userid" type="text" default="" visible="false"/>
<setting id="sections_asked_for_machine_identifier" type="text" default="" visible="false"/>
</category>
<category label="30506"><!-- Sync Options -->
<setting label="[COLOR yellow]$ADDON[plugin.video.plexkodiconnect 30524][/COLOR]" type="action" action="RunPlugin(plugin://plugin.video.plexkodiconnect?mode=select-libraries)" option="close" help="Choose which Plex library you want to sync"/><!-- Select Plex libraries to sync -->
<setting type="lsep" label="30537" /><!-- Restart if you make changes -->
<setting type="sep" />
<setting id="fullSyncInterval" type="number" label="39053" default="60" option="int" />
@ -84,7 +86,6 @@
<setting id="themoviedbAPIKey" type="text" default="19c90103adb9e98f2172c6a6a3d85dc4" visible="false"/>
<setting id="FanArtTVAPIKey" type="text" default="639191cb0774661597f28a47e7e2bad5" visible="false"/>
<setting id="syncEmptyShows" type="bool" label="30508" default="false" visible="false"/>
<setting id="lastfullsync" type="number" label="Time stamp when last successful full sync was conducted" default="0" visible="false" />
<setting id="kodi_db_has_been_wiped_clean" type="bool" label="PKC needs to completely clean the Kodi DB at least once, then reboot, to avoid Kodi error messages, e.g. OperationalError" default="false" visible="false" />
</category>