Playlist sync support, part 2

This commit is contained in:
Croneter 2018-05-01 14:48:49 +02:00
parent ac8b8e6153
commit e38f99f088
7 changed files with 297 additions and 66 deletions

View file

@ -85,6 +85,19 @@ class API(object):
"""
return self.item.get('type')
def playlist_type(self):
"""
Returns the playlist type ('video', 'audio') or None
"""
return self.item.get('playlistType')
def updated_at(self):
"""
Returns the last time this item was updated as unicode, e.g.
'1524739868', or None
"""
return self.item.get('updatedAt')
def checksum(self):
"""
Returns a string, not int.

View file

@ -1527,7 +1527,7 @@ class LibrarySync(Thread):
last_time_sync = utils.unix_timestamp()
window('plex_dbScan', clear=True)
state.DB_SCAN = False
kodi_playlist_monitor = None
playlist_monitor = None
while not self.stopped():
# In the event the server goes offline
@ -1554,7 +1554,8 @@ class LibrarySync(Thread):
kodi_db_version_checked = True
last_sync = utils.unix_timestamp()
self.fanartthread.start()
kodi_playlist_monitor = playlists.kodi_playlist_monitor()
if playlists.full_sync():
playlist_monitor = playlists.kodi_playlist_monitor()
else:
LOG.error('Initial start-up full sync unsuccessful')
xbmc.executebuiltin('InhibitIdleShutdown(false)')
@ -1600,11 +1601,12 @@ class LibrarySync(Thread):
LOG.info('Done initial sync on Kodi startup')
artwork.Artwork().cache_major_artwork()
self.fanartthread.start()
if playlists.full_sync():
playlist_monitor = playlists.kodi_playlist_monitor()
else:
LOG.info('Startup sync has not yet been successful')
window('plex_dbScan', clear=True)
state.DB_SCAN = False
kodi_playlist_monitor = playlists.kodi_playlist_monitor()
# Currently no db scan, so we can start a new scan
elif state.DB_SCAN is False:
@ -1665,8 +1667,8 @@ class LibrarySync(Thread):
continue
xbmc.sleep(100)
# Shut down playlist monitoring
if kodi_playlist_monitor:
kodi_playlist_monitor.stop()
if playlist_monitor:
playlist_monitor.stop()
# doUtils could still have a session open due to interrupted sync
try:
DU().stopSession()

View file

@ -139,9 +139,9 @@ class Playlist_Object(PlaylistObjectBaseclase):
except ValueError:
raise PlaylistError('Invalid path: %s' % path)
if path.startswith(v.PLAYLIST_PATH_VIDEO):
self.type = 'video'
self.type = v.KODI_TYPE_VIDEO_PLAYLIST
elif path.startswith(v.PLAYLIST_PATH_MUSIC):
self.type = 'music'
self.type = v.KODI_TYPE_AUDIO_PLAYLIST
else:
raise PlaylistError('Playlist type not supported: %s' % path)
if not self.plex_name:
@ -670,6 +670,7 @@ def get_all_playlists():
try:
xml.attrib
except (AttributeError, TypeError):
LOG.error('Could not download a list of all playlists')
xml = None
return xml
@ -682,9 +683,7 @@ def get_PMS_playlist(playlist, playlist_id=None):
Returns None if something went wrong
"""
playlist_id = playlist_id if playlist_id else playlist.id
xml = DU().downloadUrl(
"{server}/%ss/%s" % (playlist.kind, playlist_id),
headerOptions={'Accept': 'application/xml'})
xml = DU().downloadUrl("{server}/%ss/%s" % (playlist.kind, playlist_id))
try:
xml.attrib['%sID' % playlist.kind]
except (AttributeError, KeyError):

View file

@ -2,7 +2,8 @@
from logging import getLogger
import os
import sys
import re
from xbmcvfs import exists
import watchdog
import playlist_func as PL
@ -11,15 +12,12 @@ import kodidb_functions as kodidb
import plexdb_functions as plexdb
import utils
import variables as v
import state
###############################################################################
LOG = getLogger("PLEX." + __name__)
# Our PKC playlists. Keys: ID [int] of plex DB table playlists. Values:
# playlist_func.Playlist_Object()
PLAYLISTS = {}
# Which playlist formates are supported by PKC?
SUPPORTED_FILETYPES = (
'm3u',
@ -28,12 +26,11 @@ SUPPORTED_FILETYPES = (
# 'cue',
)
# m3u files do not have encoding specified
DEFAULT_ENCODING = sys.getdefaultencoding()
REGEX_PLEX_ID = re.compile(r'''plex_id=(\d+)''')
def create_plex_playlist(playlist):
def create_plex_playlist(playlist=None, path=None):
"""
Adds the playlist [Playlist_Object] to the PMS. If playlist.plex_id is
not None the existing Plex playlist will be overwritten; otherwise a new
@ -43,6 +40,9 @@ def create_plex_playlist(playlist):
Returns None or raises PL.PlaylistError
"""
if not playlist:
playlist = PL.Playlist_Object()
playlist.kodi_path = path
LOG.info('Creating Plex playlist from Kodi file: %s', playlist.kodi_path)
plex_ids = _playlist_file_to_plex_ids(playlist)
for pos, plex_id in enumerate(plex_ids):
@ -64,40 +64,50 @@ def delete_plex_playlist(playlist):
LOG.info('Deleting playlist %s from the PMS', playlist.plex_name)
try:
PL.delete_playlist_from_pms(playlist)
except PL.PlaylistError as err:
LOG.error('Could not delete Plex playlist: %s', err.strerror)
except PL.PlaylistError:
pass
else:
update_plex_table(playlist, delete=True)
def create_kodi_playlist(plex_id):
def create_kodi_playlist(plex_id=None):
"""
Creates a new Kodi playlist file. Will also add (or modify an existing) Plex
playlist table entry.
Assumes that the Plex playlist is indeed new. A NEW Kodi playlist will be
created in any case (not replaced)
created in any case (not replaced). Thus make sure that the "same" playlist
is deleted from both disk and the Plex database.
Returns the playlist or raises PL.PlaylistError
"""
LOG.info('Creating new Kodi playlist from Plex playlist %s', plex_id)
playlist = PL.Playlist_Object()
playlist.id = plex_id
xml = PL.get_PMS_playlist(playlist)
xml = PL.get_PMS_playlist(playlist_id=plex_id)
if not xml:
LOG.error('Could not create Kodi playlist for %s', plex_id)
LOG.error('Could not get Plex playlist %s', plex_id)
return
PL.get_playlist_details_from_xml(playlist, xml)
if xml.get('playlistType') == 'audio':
playlist.type = 'music'
elif xml.get('playlistType') == 'video':
playlist.type = 'video'
else:
raise RuntimeError('Plex playlist type unknown: %s'
% xml.get('playlistType'))
playlist.plex_name = xml.get('title')
name = utils.slugify(playlist.plex_name)
playlist.kodi_path = os.join(v.PLAYLIST_PATH,
api = API(xml)
playlist = PL.Playlist_Object()
playlist.id = api.plex_id()
playlist.type = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()]
playlist.plex_name = api.title()
playlist.plex_updatedat = api.updated_at()
LOG.info('Creating new Kodi playlist from Plex playlist %s: %s',
playlist.id, playlist.plex_name)
name = utils.valid_filename(playlist.plex_name)
path = os.join(v.PLAYLIST_PATH, playlist.type, '%s.m3u8' % name)
while exists(path) or playlist_object_from_db(path=path):
# In case the Plex playlist names are not unique
occurance = utils.REGEX_FILE_NUMBERING.search(path)
if not occurance:
path = os.join(v.PLAYLIST_PATH,
playlist.type,
'%s.m3u8' % name)
'%s_01.m3u8' % name[:min(len(name), 247)])
else:
occurance = int(occurance.group(1)) + 1
path = os.join(v.PLAYLIST_PATH,
playlist.type,
'%s_%02d.m3u8' % (name[:min(len(name), 247)],
occurance))
LOG.debug('Kodi playlist path: %s', path)
playlist.kodi_path = path
# Derive filename close to Plex playlist name
_write_playlist_to_file(playlist, xml)
update_plex_table(playlist, update_kodi_hash=True)
@ -114,9 +124,10 @@ def delete_kodi_playlist(playlist):
"""
try:
os.remove(playlist.kodi_path)
except OSError as err:
except (OSError, IOError) as err:
LOG.error('Could not delete Kodi playlist file %s. Error:\n %s: %s',
playlist.kodi_path, err.errno, err.strerror)
raise PL.PlaylistError('Could not delete %s' % playlist.kodi_path)
else:
update_plex_table(playlist, delete=True)
@ -181,7 +192,7 @@ def m3u_to_plex_ids(playlist):
playlist.kodi_path)
text = text.decode('ISO-8859-1')
for entry in _m3u_iterator(text):
plex_id = REGEX_PLEX_ID.search(entry)
plex_id = utils.REGEX_PLEX_ID.search(entry)
if plex_id:
plex_id = plex_id.group(1)
plex_ids.append(plex_id)
@ -210,8 +221,14 @@ def _write_playlist_to_file(playlist, xml):
% (api.runtime(), api.title(), api.path()))
text += '\n'
text = text.encode('utf-8')
try:
with open(playlist.kodi_path, 'wb') as f:
f.write(text)
except (OSError, IOError) as err:
LOG.error('Could not write Kodi playlist file: %s', playlist.kodi_path)
LOG.error('Error message %s: %s', err.errno, err.strerror)
raise PL.PlaylistError('Cannot write Kodi playlist to path %s'
% playlist.kodi_path)
def change_plex_playlist_name(playlist, new_name):
@ -233,24 +250,118 @@ def plex_id_from_playlist_path(path):
return plex_id
def playlist_object_from_db(path=None):
def playlist_object_from_db(path=None, kodi_hash=None, plex_id=None):
"""
Returns the playlist as a Playlist_Object for path [unicode] from the Plex
playlists table or None if not found.
Returns the playlist as a Playlist_Object for either the plex_id, path or
kodi_hash. kodi_hash will be more reliable as it includes path and file
content.
"""
playlist = PL.Playlist_Object()
with plexdb.Get_Plex_DB() as plex_db:
playlist = plex_db.retrieve_playlist(playlist, plex_id, path, kodi_hash)
return playlist
def _kodi_playlist_identical(xml_element):
"""
Feed with one playlist xml element from the PMS. Will return True if PKC
already synced this playlist, False if not or if the Play playlist has
changed in the meantime
"""
pass
def full_sync():
"""
Full sync of playlists between Kodi and Plex. Returns True is successful,
False otherwise
"""
LOG.info('Starting playlist full sync')
# Get all Plex playlists
xml = PL.get_all_playlists()
if not xml:
return False
for entry in xml:
# For each playlist, check Plex database to see whether we already synced
# before. If yes, make sure that hashes are identical. If not, sync it.
with plexdb.Get_Plex_DB() as plex_db:
old_plex_ids = plex_db.plex_ids_all_playlists()
for xml_playlist in xml:
api = API(xml_playlist)
if (not state.ENABLE_MUSIC and
api.playlist_type() == v.PLEX_TYPE_AUDIO_PLAYLIST):
continue
playlist = playlist_object_from_db(plex_id=api.plex_id())
try:
if not playlist:
LOG.debug('New Plex playlist %s discovered: %s',
api.plex_id(), api.title())
create_kodi_playlist(api.plex_id())
continue
elif playlist.plex_updatedat != api.updated_at():
LOG.debug('Detected changed Plex playlist %s: %s',
api.plex_id(), api.title())
delete_kodi_playlist(playlist)
create_kodi_playlist(api.plex_id())
except PL.PlaylistError:
LOG.info('Skipping playlist %s: %s', api.plex_id(), api.title())
old_plex_ids.remove(api.plex_id())
# Get rid of old Plex playlists that were deleted on the Plex side
for plex_id in old_plex_ids:
playlist = playlist_object_from_db(plex_id=api.plex_id())
if playlist:
LOG.debug('Removing outdated Plex playlist %s from %s',
playlist.plex_name, playlist.kodi_path)
try:
delete_kodi_playlist(playlist)
except PL.PlaylistError:
pass
# Look at all supported Kodi playlists. Check whether they are in the DB.
with plexdb.Get_Plex_DB() as plex_db:
old_kodi_hashes = plex_db.kodi_hashes_all_playlists()
master_paths = [v.PLAYLIST_PATH_VIDEO]
if state.ENABLE_MUSIC:
master_paths.append(v.PLAYLIST_PATH_MUSIC)
for master_path in master_paths:
for root, _, files in os.walk(master_path):
for file in files:
try:
extension = file.rsplit('.', 1)[1]
except IndexError:
continue
if extension not in SUPPORTED_FILETYPES:
continue
path = os.path.join(root, file)
kodi_hash = utils.generate_file_md5(path)
playlist = playlist_object_from_db(kodi_hash=kodi_hash)
playlist_2 = playlist_object_from_db(path=path)
if playlist:
# Nothing changed at all - neither path nor content
old_kodi_hashes.remove(kodi_hash)
continue
try:
if playlist_2:
LOG.debug('Changed Kodi playlist %s detected: %s',
playlist_2.plex_name, path)
playlist = PL.Playlist_Object()
playlist.id = playlist_2.id
playlist.kodi_path = path
playlist.plex_name = playlist_2.plex_name
delete_plex_playlist(playlist_2)
create_plex_playlist(playlist)
else:
LOG.debug('New Kodi playlist detected: %s', path)
# Make sure that we delete any playlist with other hash
create_plex_playlist(path=path)
except PL.PlaylistError:
LOG.info('Skipping Kodi playlist %s', path)
for kodi_hash in old_kodi_hashes:
playlist = playlist_object_from_db(kodi_hash=kodi_hash)
if playlist:
try:
delete_plex_playlist(playlist)
except PL.PlaylistError:
pass
return True
class PlaylistEventhandler(watchdog.events.FileSystemEventHandler):

View file

@ -406,6 +406,60 @@ class Plex_DB_Functions():
plex_id = None
return plex_id
def plex_ids_all_playlists(self):
"""
Returns a list of all Plex ids of playlists.
"""
answ = []
self.plexcursor.execute('SELECT plex_id FROM playlists')
for entry in self.plexcursor.fetchall():
answ.append(entry[0])
return answ
def kodi_hashes_all_playlists(self):
"""
Returns a list of all Kodi hashes of playlists.
"""
answ = []
self.plexcursor.execute('SELECT kodi_hash FROM playlists')
for entry in self.plexcursor.fetchall():
answ.append(entry[0])
return answ
def retrieve_playlist(self, playlist, plex_id=None, path=None,
kodi_hash=None):
"""
Returns a complete Playlist_Object (empty one passed in via playlist)
for the entry with plex_id. Or None if not found
"""
query = '''
SELECT plex_id, plex_name, plex_updatedat, kodi_path, kodi_type,
kodi_hash
FROM playlists
WHERE %s = ?
LIMIT 1
'''
if plex_id:
query = query % 'plex_id'
var = plex_id
elif kodi_hash:
query = query % 'kodi_hash'
var = kodi_hash
else:
query = query % 'kodi_path'
var = path
self.plexcursor.execute(query, (var, ))
answ = self.plexcursor.fetchone()
if not answ:
return
playlist.plex_id = answ[0]
playlist.plex_name = answ[1]
playlist.plex_updatedat = answ[2]
playlist.kodi_path = answ[3]
playlist.kodi_type = answ[4]
playlist.kodi_hash = answ[5]
return playlist
def insert_playlist_entry(self, playlist):
"""
Inserts or modifies an existing entry in the Plex playlists table.

View file

@ -4,6 +4,7 @@ Various functions and decorators for PKC
"""
###############################################################################
from logging import getLogger
import os
from cProfile import Profile
from pstats import Stats
from sqlite3 import connect, OperationalError
@ -13,11 +14,11 @@ from time import localtime, strftime
from unicodedata import normalize
import xml.etree.ElementTree as etree
from functools import wraps, partial
from os.path import join
from os import remove, walk, makedirs
from shutil import rmtree
from urllib import quote_plus
import hashlib
import re
import unicodedata
import xbmc
import xbmcaddon
@ -35,6 +36,10 @@ WINDOW = xbmcgui.Window(10000)
ADDON = xbmcaddon.Addon(id='plugin.video.plexkodiconnect')
EPOCH = datetime.utcfromtimestamp(0)
REGEX_PLEX_ID = re.compile(r'''plex_id=(\d+)''')
REGEX_FILE_NUMBERING = re.compile(r'''_(\d+)\.\w+$''')
###############################################################################
# Main methods
@ -111,7 +116,7 @@ def exists_dir(path):
if v.KODIVERSION >= 17:
answ = exists(try_encode(path))
else:
dummyfile = join(try_decode(path), 'dummyfile.txt')
dummyfile = os.path.join(try_decode(path), 'dummyfile.txt')
try:
with open(dummyfile, 'w') as filer:
filer.write('text')
@ -285,6 +290,39 @@ def slugify(text):
return unicode(normalize('NFKD', text).encode('ascii', 'ignore'))
def valid_filename(text):
"""
Return a valid filename after passing it in [unicode].
"""
# Get rid of all whitespace except a normal space
text = re.sub(r'(?! )\s', '', text)
# ASCII characters 0 to 31 (non-printable, just in case)
text = re.sub(u'[\x00-\x1f]', '', text)
if v.PLATFORM == 'Windows':
# Whitespace at the end of the filename is illegal
text = text.strip()
# Dot at the end of a filename is illegal
text = re.sub(r'\.+$', '', text)
# Illegal Windows characters
text = re.sub(r'[/\\:*?"<>|\^]', '', text)
elif v.PLATFORM == 'MacOSX':
# Colon is illegal
text = re.sub(r':', '', text)
# Files cannot begin with a dot
text = re.sub(r'^\.+', '', text)
else:
# Linux
text = re.sub(r'/', '', text)
if not os.path.supports_unicode_filenames:
text = unicodedata.normalize('NFKD', text)
text = text.encode('ascii', 'ignore')
text = text.decode('ascii')
# Ensure that filename length is at most 255 chars (including 4 chars for
# filename extension and 1 dot to separate the extension)
text = text[:min(len(text), 250)]
return text
def escape_html(string):
"""
Escapes the following:
@ -478,7 +516,7 @@ def reset():
addon = xbmcaddon.Addon()
addondir = try_decode(xbmc.translatePath(addon.getAddonInfo('profile')))
LOG.info("Deleting: settings.xml")
remove("%ssettings.xml" % addondir)
os.remove("%ssettings.xml" % addondir)
reboot_kodi()
@ -630,9 +668,9 @@ class XmlKodiSetting(object):
top_element=None):
self.filename = filename
if path is None:
self.path = join(v.KODI_PROFILE, filename)
self.path = os.path.join(v.KODI_PROFILE, filename)
else:
self.path = join(path, filename)
self.path = os.path.join(path, filename)
self.force_create = force_create
self.top_element = top_element
self.tree = None
@ -929,13 +967,13 @@ def playlist_xsp(mediatype, tagname, viewid, viewtype="", delete=False):
# Create the playlist directory
if not exists(try_encode(path)):
LOG.info("Creating directory: %s", path)
makedirs(path)
os.makedirs(path)
# Only add the playlist if it doesn't already exists
if exists(try_encode(xsppath)):
LOG.info('Path %s does exist', xsppath)
if delete:
remove(xsppath)
os.remove(xsppath)
LOG.info("Successfully removed playlist: %s.", tagname)
return
@ -966,29 +1004,32 @@ def delete_playlists():
Clean up the playlists
"""
path = try_decode(xbmc.translatePath("special://profile/playlists/video/"))
for root, _, files in walk(path):
for root, _, files in os.walk(path):
for file in files:
if file.startswith('Plex'):
remove(join(root, file))
os.remove(os.path.join(root, file))
def delete_nodes():
"""
Clean up video nodes
"""
path = try_decode(xbmc.translatePath("special://profile/library/video/"))
for root, dirs, _ in walk(path):
for root, dirs, _ in os.walk(path):
for directory in dirs:
if directory.startswith('Plex-'):
rmtree(join(root, directory))
rmtree(os.path.join(root, directory))
break
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].
The hash includes the path and is thus different for the same file for
different filenames.
Returns a unique string containing only hexadecimal digits
"""
m = hashlib.md5()
m.update(path.encode('utf-8'))
with open(path, 'rb') as f:
while True:
piece = f.read(32768)

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from os.path import join
import os
import xbmc
from xbmcaddon import Addon
@ -41,10 +41,6 @@ KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
KODILONGVERSION = xbmc.getInfoLabel('System.BuildVersion')
KODI_PROFILE = try_decode(xbmc.translatePath("special://profile"))
PLAYLIST_PATH = join(KODI_PROFILE, 'playlist')
PLAYLIST_PATH_MIXED = join(PLAYLIST_PATH, 'mixed')
PLAYLIST_PATH_VIDEO = join(PLAYLIST_PATH, 'video')
PLAYLIST_PATH_MUSIC = join(PLAYLIST_PATH, 'music')
if xbmc.getCondVisibility('system.platform.osx'):
PLATFORM = "MacOSX"
@ -127,6 +123,21 @@ EXTERNAL_SUBTITLE_TEMP_PATH = try_decode(xbmc.translatePath(
# Multiply Plex time by this factor to receive Kodi time
PLEX_TO_KODI_TIMEFACTOR = 1.0 / 1000.0
# Playlist stuff
PLAYLIST_PATH = os.path.join(KODI_PROFILE, 'playlist')
PLAYLIST_PATH_MIXED = os.path.join(PLAYLIST_PATH, 'mixed')
PLAYLIST_PATH_VIDEO = os.path.join(PLAYLIST_PATH, 'video')
PLAYLIST_PATH_MUSIC = os.path.join(PLAYLIST_PATH, 'music')
PLEX_TYPE_AUDIO_PLAYLIST = 'audio'
PLEX_TYPE_VIDEO_PLAYLIST = 'video'
KODI_TYPE_AUDIO_PLAYLIST = 'music'
KODI_TYPE_VIDEO_PLAYLIST = 'video'
KODI_PLAYLIST_TYPE_FROM_PLEX = {
PLEX_TYPE_AUDIO_PLAYLIST: KODI_TYPE_AUDIO_PLAYLIST,
PLEX_TYPE_VIDEO_PLAYLIST: KODI_TYPE_VIDEO_PLAYLIST
}
# All the Plex types as communicated in the PMS xml replies
PLEX_TYPE_VIDEO = 'video'