Refactor code

This commit is contained in:
croneter 2018-07-11 21:24:27 +02:00
parent e0108eeb89
commit c0d78bd273
11 changed files with 973 additions and 805 deletions

View file

@ -1189,8 +1189,8 @@ class LibrarySync(Thread):
if typus == 'playlist': if typus == 'playlist':
if not state.SYNC_PLAYLISTS: if not state.SYNC_PLAYLISTS:
continue continue
playlists.process_websocket(plex_id=unicode(item['itemID']), playlists.websocket(plex_id=unicode(item['itemID']),
status=status) status=status)
elif status == 9: elif status == 9:
# Immediately and always process deletions (as the PMS will # Immediately and always process deletions (as the PMS will
# send additional message with other codes) # send additional message with other codes)

View file

@ -1,3 +1,4 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Collection of functions associated with Kodi and Plex playlists and playqueues Collection of functions associated with Kodi and Plex playlists and playqueues
@ -60,47 +61,6 @@ class PlaylistObjectBaseclase(object):
return answ + '}}' return answ + '}}'
class Playlist_Object(PlaylistObjectBaseclase):
"""
To be done for synching Plex playlists to Kodi
"""
kind = 'playList'
def __init__(self):
self.plex_name = None
self.plex_updatedat = None
self._kodi_path = None
self.kodi_filename = None
self.kodi_extension = None
self.kodi_hash = None
PlaylistObjectBaseclase.__init__(self)
@property
def kodi_path(self):
return self._kodi_path
@kodi_path.setter
def kodi_path(self, path):
if not isinstance(path, unicode):
raise RuntimeError('Path is %s, not unicode!' % type(path))
file = path_ops.path.basename(path)
try:
self.kodi_filename, self.kodi_extension = file.split('.', 1)
except ValueError:
LOG.error('Trying to set invalid path: %s', path)
raise PlaylistError('Invalid path: %s' % path)
if path.startswith(v.PLAYLIST_PATH_VIDEO):
self.type = v.KODI_TYPE_VIDEO_PLAYLIST
elif path.startswith(v.PLAYLIST_PATH_MUSIC):
self.type = v.KODI_TYPE_AUDIO_PLAYLIST
else:
LOG.error('Playlist type not supported for %s', path)
raise PlaylistError('Playlist type not supported: %s' % path)
if not self.plex_name:
self.plex_name = self.kodi_filename
self._kodi_path = path
class Playqueue_Object(PlaylistObjectBaseclase): class Playqueue_Object(PlaylistObjectBaseclase):
""" """
PKC object to represent PMS playQueues and Kodi playlist for queueing PKC object to represent PMS playQueues and Kodi playlist for queueing
@ -478,34 +438,6 @@ def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
playlist.items.append(playlist_item) playlist.items.append(playlist_item)
def init_plex_playlist(playlist, plex_id):
"""
Initializes a new playlist on the PMS side. Will set playlist.id and
playlist.plex_updatedat. Will raise PlaylistError if something went wrong.
"""
LOG.debug('Initializing the playlist with Plex id %s on the Plex side: %s',
plex_id, playlist)
params = {
'type': v.PLEX_PLAYLIST_TYPE_FROM_KODI[playlist.type],
'title': playlist.plex_name,
'smart': 0,
'uri': ('library://None/item/%s' % (urllib.quote('/library/metadata/%s'
% plex_id, safe='')))
}
xml = DU().downloadUrl(url='{server}/playlists',
action_type='POST',
parameters=params)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not initialize playlist on Plex side with plex id %s',
plex_id)
raise PlaylistError('Could not initialize Plex playlist %s', plex_id)
api = API(xml[0])
playlist.id = api.plex_id()
playlist.plex_updatedat = api.updated_at()
def init_plex_playqueue(playlist, plex_id=None, kodi_item=None): def init_plex_playqueue(playlist, plex_id=None, kodi_item=None):
""" """
Initializes the Plex side without changing the Kodi playlists Initializes the Plex side without changing the Kodi playlists
@ -601,30 +533,6 @@ def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None,
return item return item
def add_item_to_plex_playlist(playlist, plex_id):
"""
Adds the item with plex_id to the existing Plex playlist (at the end).
Will set playlist.plex_updatedat
Raises PlaylistError if that did not work out.
"""
params = {
'uri': ('library://None/item/%s' % (urllib.quote('/library/metadata/%s'
% plex_id, safe='')))
}
xml = DU().downloadUrl(url='{server}/playlists/%s/items' % playlist.id,
action_type='PUT',
parameters=params)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not initialize playlist on Plex side with plex id %s',
plex_id)
raise PlaylistError('Could not item %s to Plex playlist %s',
plex_id, playlist)
api = API(xml[0])
playlist.plex_updatedat = api.updated_at()
def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None): def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
""" """
Adds a new item to the playlist at position pos [int] only on the Plex Adds a new item to the playlist at position pos [int] only on the Plex
@ -731,32 +639,6 @@ def move_playlist_item(playlist, before_pos, after_pos):
LOG.debug('Done moving for %s', playlist) LOG.debug('Done moving for %s', playlist)
def get_all_playlists():
"""
Returns an XML with all Plex playlists or None
"""
xml = DU().downloadUrl("{server}/playlists",
headerOptions={'Accept': 'application/xml'})
try:
xml.attrib
except (AttributeError, TypeError):
LOG.error('Could not download a list of all playlists')
xml = None
return xml
def get_pms_playlist_metadata(plex_id):
"""
Returns an xml with the entire metadata like updatedAt.
"""
xml = DU().downloadUrl('{server}/playlists/%s' % plex_id)
try:
xml.attrib
except AttributeError:
xml = None
return xml
def get_PMS_playlist(playlist, playlist_id=None): def get_PMS_playlist(playlist, playlist_id=None):
""" """
Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we
@ -898,11 +780,3 @@ def get_plextype_from_xml(xml):
LOG.error('Could not get plex metadata for plex id %s', plex_id) LOG.error('Could not get plex metadata for plex id %s', plex_id)
return return
return new_xml[0].attrib.get('type') return new_xml[0].attrib.get('type')
def delete_playlist_from_pms(playlist):
"""
Deletes the playlist from the PMS
"""
DU().downloadUrl("{server}/%ss/%s" % (playlist.kind.lower(), playlist.id),
action_type="DELETE")

View file

@ -1,666 +0,0 @@
# -*- coding: utf-8 -*-
from logging import getLogger
import Queue
import xbmc
from .watchdog import events
from .watchdog.observers import Observer
from .watchdog.utils.bricks import OrderedSetQueue
from . import playlist_func as PL
from .plex_api import API
from . import kodidb_functions as kodidb
from . import plexdb_functions as plexdb
from . import utils
from . import path_ops
from . import variables as v
from . import state
###############################################################################
LOG = getLogger('PLEX.playlists')
# Safety margin for playlist filesystem operations
FILESYSTEM_TIMEOUT = 1
# These filesystem events are considered similar
SIMILAR_EVENTS = (events.EVENT_TYPE_CREATED, events.EVENT_TYPE_MODIFIED)
# Which playlist formates are supported by PKC?
SUPPORTED_FILETYPES = (
'm3u',
# 'm3u8'
# 'pls',
# 'cue',
)
def create_plex_playlist(playlist):
"""
Adds the playlist [Playlist_Object] to the PMS. If playlist.id is
not None the existing Plex playlist will be overwritten; otherwise a new
playlist will be generated and stored accordingly in the playlist object.
Will also add (or modify an existing) Plex playlist table entry.
Make sure that playlist.kodi_hash is set!
Returns None or raises PL.PlaylistError
"""
LOG.debug('Creating Plex playlist from Kodi file: %s', playlist)
plex_ids = _playlist_file_to_plex_ids(playlist)
if not plex_ids:
LOG.info('No Plex ids found for playlist %s', playlist)
raise PL.PlaylistError
for pos, plex_id in enumerate(plex_ids):
try:
if pos == 0 or not playlist.id:
PL.init_plex_playlist(playlist, plex_id)
else:
PL.add_item_to_plex_playlist(playlist, plex_id=plex_id)
except PL.PlaylistError:
continue
update_plex_table(playlist)
LOG.debug('Done creating Plex playlist %s', playlist)
def delete_plex_playlist(playlist):
"""
Removes the playlist [Playlist_Object] from the PMS. Will also delete the
entry in the Plex playlist table.
Returns None or raises PL.PlaylistError
"""
LOG.debug('Deleting playlist from PMS: %s', playlist)
PL.delete_playlist_from_pms(playlist)
update_plex_table(playlist, delete=True)
def create_kodi_playlist(plex_id):
"""
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). Thus make sure that the "same" playlist
is deleted from both disk and the Plex database.
Returns the playlist or raises PL.PlaylistError
"""
xml_metadata = PL.get_pms_playlist_metadata(plex_id)
if xml_metadata is None:
LOG.error('Could not get Plex playlist metadata %s', plex_id)
raise PL.PlaylistError('Could not get Plex playlist %s' % plex_id)
api = API(xml_metadata[0])
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.debug('Creating new Kodi playlist from Plex playlist: %s', playlist)
# Derive filename close to Plex playlist name
name = utils.valid_filename(playlist.plex_name)
path = path_ops.path.join(v.PLAYLIST_PATH, playlist.type, '%s.m3u' % name)
while path_ops.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 = path_ops.path.join(v.PLAYLIST_PATH,
playlist.type,
'%s_01.m3u' % name[:min(len(name), 248)])
else:
occurance = int(occurance.group(1)) + 1
path = path_ops.path.join(v.PLAYLIST_PATH,
playlist.type,
'%s_%02d.m3u' % (name[:min(len(name),
248)],
occurance))
LOG.debug('Kodi playlist path: %s', path)
playlist.kodi_path = path
xml_playlist = PL.get_PMS_playlist(playlist, playlist_id=plex_id)
if xml_playlist is None:
LOG.error('Could not get Plex playlist %s', plex_id)
raise PL.PlaylistError('Could not get Plex playlist %s' % plex_id)
_write_playlist_to_file(playlist, xml_playlist)
playlist.kodi_hash = utils.generate_file_md5(path)
update_plex_table(playlist)
LOG.debug('Created Kodi playlist based on Plex playlist: %s', playlist)
def delete_kodi_playlist(playlist):
"""
Removes the corresponding Kodi file for playlist [Playlist_Object] from
disk. Be sure that playlist.kodi_path is set. Will also delete the entry in
the Plex playlist table.
Returns None or raises PL.PlaylistError
"""
if path_ops.exists(playlist.kodi_path):
try:
path_ops.remove(playlist.kodi_path)
except (OSError, IOError) as err:
LOG.error('Could not delete Kodi playlist file %s. Error:\n%s: %s',
playlist, err.errno, err.strerror)
raise PL.PlaylistError('Could not delete %s' % playlist.kodi_path)
update_plex_table(playlist, delete=True)
def update_plex_table(playlist, delete=False):
"""
Assumes that all sync operations are over. Takes playlist [Playlist_Object]
and creates/updates the corresponding Plex playlists table entry
Pass delete=True to delete the playlist entry
"""
with plexdb.Get_Plex_DB() as plex_db:
if delete:
plex_db.delete_playlist_entry(playlist)
else:
plex_db.insert_playlist_entry(playlist)
def _playlist_file_to_plex_ids(playlist):
"""
Takes the playlist file located at path [unicode] and parses it.
Returns a list of plex_ids (str) or raises PL.PlaylistError if a single
item cannot be parsed from Kodi to Plex.
"""
if playlist.kodi_extension == 'm3u':
plex_ids = m3u_to_plex_ids(playlist)
else:
LOG.error('Unsupported playlist extension: %s',
playlist.kodi_extension)
raise PL.PlaylistError
return plex_ids
def _m3u_iterator(text):
"""
Yields e.g. plugin://plugin.video.plexkodiconnect.movies/?plex_id=xxx
"""
lines = iter(text.split('\n'))
for line in lines:
if line.startswith('#EXTINF:'):
yield next(lines).strip()
def m3u_to_plex_ids(playlist):
"""
Adapter to process *.m3u playlist files. Encoding is not uniform!
"""
plex_ids = list()
with open(path_ops.encode_path(playlist.kodi_path), 'rb') as f:
text = f.read()
try:
text = text.decode(v.M3U_ENCODING)
except UnicodeDecodeError:
LOG.warning('Fallback to ISO-8859-1 decoding for %s', playlist)
text = text.decode('ISO-8859-1')
for entry in _m3u_iterator(text):
plex_id = utils.REGEX_PLEX_ID.search(entry)
if plex_id:
plex_id = plex_id.group(1)
plex_ids.append(plex_id)
else:
# Add-on paths not working, try direct
kodi_id, kodi_type = kodidb.kodiid_from_filename(
entry, db_type=playlist.type)
if not kodi_id:
continue
with plexdb.Get_Plex_DB() as plex_db:
plex_id = plex_db.getItem_byKodiId(kodi_id, kodi_type)
if plex_id:
plex_ids.append(plex_id[0])
return plex_ids
def _write_playlist_to_file(playlist, xml):
"""
Feed with playlist [Playlist_Object]. Will write the playlist to a m3u file
Returns None or raises PL.PlaylistError
"""
text = '#EXTCPlayListM3U::M3U\n'
for element in xml:
api = API(element)
append_season_episode = False
if api.plex_type() == v.PLEX_TYPE_EPISODE:
_, show, season_id, episode_id = api.episode_data()
try:
season_id = int(season_id)
episode_id = int(episode_id)
except ValueError:
pass
else:
append_season_episode = True
if append_season_episode:
text += ('#EXTINF:%s,%s S%.2dE%.2d - %s\n%s\n'
% (api.runtime(), show, season_id, episode_id,
api.title(), api.path()))
else:
# Only append the TV show name
text += ('#EXTINF:%s,%s - %s\n%s\n'
% (api.runtime(), show, api.title(), api.path()))
else:
text += ('#EXTINF:%s,%s\n%s\n'
% (api.runtime(), api.title(), api.path()))
text += '\n'
text = text.encode(v.M3U_ENCODING, 'strict')
try:
with open(path_ops.encode_path(playlist.kodi_path), 'wb') as f:
f.write(text)
except EnvironmentError as err:
LOG.error('Could not write Kodi playlist file: %s', playlist)
LOG.error('Error message %s: %s', err.errno, err.strerror)
raise PL.PlaylistError('Cannot write Kodi playlist to path for %s'
% playlist)
def plex_id_from_playlist_path(path):
"""
Given the Kodi playlist path [unicode], this will return the Plex id [str]
or None
"""
with plexdb.Get_Plex_DB() as plex_db:
plex_id = plex_db.plex_id_from_playlist_path(path)
if not plex_id:
LOG.error('Could not find existing entry for playlist path %s', path)
return plex_id
def playlist_object_from_db(path=None, kodi_hash=None, plex_id=None):
"""
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 process_websocket(plex_id, status):
"""
Hit by librarysync to process websocket messages concerning playlists
"""
create = False
with state.LOCK_PLAYLISTS:
playlist = playlist_object_from_db(plex_id=plex_id)
if playlist and status == 9:
# Won't be able to download metadata of the deleted playlist
if sync_plex_playlist(playlist=playlist):
LOG.debug('Plex deletion of playlist detected: %s', playlist)
try:
delete_kodi_playlist(playlist)
except PL.PlaylistError:
pass
return
xml = PL.get_pms_playlist_metadata(plex_id)
if xml is None:
LOG.debug('Could not download playlist %s, probably deleted',
plex_id)
return
if not sync_plex_playlist(xml=xml[0]):
return
api = API(xml[0])
try:
if playlist:
if api.updated_at() == playlist.plex_updatedat:
LOG.debug('Playlist with id %s already synced: %s',
plex_id, playlist)
else:
LOG.debug('Change of Plex playlist detected: %s',
playlist)
delete_kodi_playlist(playlist)
create = True
elif not playlist and not status == 9:
LOG.debug('Creation of new Plex playlist detected: %s',
plex_id)
create = True
# To the actual work
if create:
create_kodi_playlist(plex_id)
except PL.PlaylistError:
pass
def full_sync():
"""
Full sync of playlists between Kodi and Plex. Returns True is successful,
False otherwise
"""
if not state.SYNC_PLAYLISTS:
LOG.debug('Not syncing playlists')
return True
LOG.info('Starting playlist full sync')
with state.LOCK_PLAYLISTS:
return _full_sync()
def _full_sync():
"""
Need to lock because we're messing with playlists
"""
# Get all Plex playlists
xml = PL.get_all_playlists()
if xml is None:
return False
# 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)
try:
old_plex_ids.remove(api.plex_id())
except ValueError:
pass
if not sync_plex_playlist(xml=xml_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())
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())
# 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=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:
LOG.debug('Skipping deletion of playlist %s: %s',
api.plex_id(), api.title())
# Look at all supported Kodi playlists. Check whether they are in the DB.
with plexdb.Get_Plex_DB() as plex_db:
old_kodi_paths = plex_db.all_kodi_playlist_paths()
for root, _, files in path_ops.walk(v.PLAYLIST_PATH):
for f in files:
path = path_ops.path.join(root, f)
try:
old_kodi_paths.remove(path)
except ValueError:
pass
if not sync_kodi_playlist(path):
continue
kodi_hash = utils.generate_file_md5(path)
playlist = playlist_object_from_db(path=path)
if playlist and playlist.kodi_hash == kodi_hash:
continue
try:
if not playlist:
LOG.debug('New Kodi playlist detected: %s', path)
playlist = PL.Playlist_Object()
playlist.kodi_path = path
playlist.kodi_hash = kodi_hash
create_plex_playlist(playlist)
else:
LOG.debug('Changed Kodi playlist detected: %s', playlist)
delete_plex_playlist(playlist)
playlist.kodi_hash = kodi_hash
create_plex_playlist(playlist)
except PL.PlaylistError:
LOG.info('Skipping Kodi playlist %s', path)
for kodi_path in old_kodi_paths:
playlist = playlist_object_from_db(kodi_path=kodi_path)
try:
delete_plex_playlist(playlist)
except PL.PlaylistError:
LOG.debug('Skipping deletion of playlist %s: %s',
playlist.plex_id, playlist.plex_name)
LOG.info('Playlist full sync done')
return True
def sync_kodi_playlist(path):
"""
Returns True if we should sync this Kodi playlist with path [unicode] to
Plex based on the playlist file name and the user settings, False otherwise
"""
if path.startswith(v.PLAYLIST_PATH_MIXED):
return False
try:
extension = path.rsplit('.', 1)[1].lower()
except IndexError:
return False
if extension not in SUPPORTED_FILETYPES:
return False
if not state.SYNC_SPECIFIC_KODI_PLAYLISTS:
return True
playlist = PL.Playlist_Object()
playlist.kodi_path = path
prefix = utils.settings('syncSpecificKodiPlaylistsPrefix').lower()
if playlist.kodi_filename.lower().startswith(prefix):
return True
LOG.debug('User chose to not sync Kodi playlist %s', path)
return False
def sync_plex_playlist(plex_id=None, xml=None, playlist=None):
"""
Returns True if we should sync this specific Plex playlist due to the
user settings (including a disabled music library), False if not.
Pass in either the plex_id or an xml (where API(xml) will be used)
"""
if not state.SYNC_SPECIFIC_PLEX_PLAYLISTS:
return True
if playlist:
# Mainly once we DELETED a Plex playlist that we're NOT supposed
# to sync
name = playlist.plex_name
typus = playlist.type
else:
if xml is None:
xml = PL.get_pms_playlist_metadata(plex_id)
if xml is None:
LOG.info('Could not get Plex metadata for playlist %s',
plex_id)
return False
api = API(xml[0])
else:
api = API(xml)
name = api.title()
typus = api.playlist_type()
if (not state.ENABLE_MUSIC and typus == v.PLEX_PLAYLIST_TYPE_AUDIO):
LOG.debug('Not synching Plex audio playlist')
return False
prefix = utils.settings('syncSpecificPlexPlaylistsPrefix').lower()
if name and name.lower().startswith(prefix):
return True
LOG.debug('User chose to not sync Plex playlist')
return False
class PlaylistEventhandler(events.FileSystemEventHandler):
"""
PKC eventhandler to monitor Kodi playlists safed to disk
"""
def dispatch(self, event):
"""
Dispatches events to the appropriate methods.
:param event:
The event object representing the file system event.
:type event:
:class:`FileSystemEvent`
"""
if not state.SYNC_PLAYLISTS:
# Sync is deactivated
return
path = event.dest_path if event.event_type == events.EVENT_TYPE_MOVED \
else event.src_path
if not sync_kodi_playlist(path):
return
_method_map = {
events.EVENT_TYPE_MODIFIED: self.on_modified,
events.EVENT_TYPE_MOVED: self.on_moved,
events.EVENT_TYPE_CREATED: self.on_created,
events.EVENT_TYPE_DELETED: self.on_deleted,
}
with state.LOCK_PLAYLISTS:
_method_map[event.event_type](event)
def on_created(self, event):
LOG.debug('on_created: %s', event.src_path)
old_playlist = playlist_object_from_db(path=event.src_path)
kodi_hash = utils.generate_file_md5(event.src_path)
if old_playlist and old_playlist.kodi_hash == kodi_hash:
LOG.debug('Playlist already in DB - skipping')
return
elif old_playlist:
LOG.debug('Playlist already in DB but it has been changed')
self.on_modified(event)
return
playlist = PL.Playlist_Object()
playlist.kodi_path = event.src_path
playlist.kodi_hash = kodi_hash
try:
create_plex_playlist(playlist)
except PL.PlaylistError:
pass
def on_modified(self, event):
LOG.debug('on_modified: %s', event.src_path)
old_playlist = playlist_object_from_db(path=event.src_path)
kodi_hash = utils.generate_file_md5(event.src_path)
if old_playlist and old_playlist.kodi_hash == kodi_hash:
LOG.debug('Nothing modified, playlist already in DB - skipping')
return
new_playlist = PL.Playlist_Object()
if old_playlist:
# Retain the name! Might've come from Plex
# (rename should fire on_moved)
new_playlist.plex_name = old_playlist.plex_name
delete_plex_playlist(old_playlist)
new_playlist.kodi_path = event.src_path
new_playlist.kodi_hash = kodi_hash
try:
create_plex_playlist(new_playlist)
except PL.PlaylistError:
pass
def on_moved(self, event):
LOG.debug('on_moved: %s to %s', event.src_path, event.dest_path)
kodi_hash = utils.generate_file_md5(event.dest_path)
# First check whether we don't already have destination playlist in
# our DB. Just in case....
old_playlist = playlist_object_from_db(path=event.dest_path)
if old_playlist:
LOG.warning('Found target playlist already in our DB!')
new_event = events.FileModifiedEvent(event.dest_path)
self.on_modified(new_event)
return
# All good
old_playlist = playlist_object_from_db(path=event.src_path)
if not old_playlist:
LOG.debug('Did not have source path in the DB %s', event.src_path)
else:
delete_plex_playlist(old_playlist)
new_playlist = PL.Playlist_Object()
new_playlist.kodi_path = event.dest_path
new_playlist.kodi_hash = kodi_hash
try:
create_plex_playlist(new_playlist)
except PL.PlaylistError:
pass
def on_deleted(self, event):
LOG.debug('on_deleted: %s', event.src_path)
playlist = playlist_object_from_db(path=event.src_path)
if not playlist:
LOG.info('Playlist not found in DB for path %s', event.src_path)
else:
delete_plex_playlist(playlist)
class PlaylistQueue(OrderedSetQueue):
"""
OrderedSetQueue that drops all directory events immediately
"""
def _put(self, item):
if item[0].is_directory:
self.unfinished_tasks -= 1
else:
# Can't use super as OrderedSetQueue is old style class
OrderedSetQueue._put(self, item)
class PlaylistObserver(Observer):
"""
PKC implementation, overriding the dispatcher. PKC will wait for the
duration timeout (in seconds) AFTER receiving a filesystem event. A new
("non-similar") event will reset the timer.
Creating and modifying will be regarded as equal.
"""
def __init__(self, *args, **kwargs):
super(PlaylistObserver, self).__init__(*args, **kwargs)
# Drop the same events that get into the queue even if there are other
# events in between these similar events. Ignore directory events
# completely
self._event_queue = PlaylistQueue()
@staticmethod
def _pkc_similar_events(event1, event2):
if event1 == event2:
return True
elif (event1.src_path == event2.src_path and
event1.event_type in SIMILAR_EVENTS and
event2.event_type in SIMILAR_EVENTS):
# Set created and modified events to equal
return True
return False
def _dispatch_iterator(self, event_queue, timeout):
"""
This iterator will block for timeout (seconds) until an event is
received or raise Queue.Empty.
"""
event, watch = event_queue.get(block=True, timeout=timeout)
event_queue.task_done()
start = utils.unix_timestamp()
while utils.unix_timestamp() - start < timeout:
if state.STOP_PKC:
raise Queue.Empty
try:
new_event, new_watch = event_queue.get(block=False)
except Queue.Empty:
xbmc.sleep(200)
else:
event_queue.task_done()
start = utils.unix_timestamp()
if self._pkc_similar_events(new_event, event):
continue
else:
yield event, watch
event, watch = new_event, new_watch
yield event, watch
def dispatch_events(self, event_queue, timeout):
for event, watch in self._dispatch_iterator(event_queue, timeout):
# This is copy-paste of original code
with self._lock:
# To allow unschedule/stop and safe removal of event handlers
# within event handlers itself, check if the handler is still
# registered after every dispatch.
for handler in list(self._handlers.get(watch, [])):
if handler in self._handlers.get(watch, []):
handler.dispatch(event)
def kodi_playlist_monitor():
"""
Monitors the Kodi playlist folder special://profile/playlist for the user.
Will thus catch all changes on the Kodi side of things.
Returns an watchdog Observer instance. Be sure to use
observer.stop() (and maybe observer.join()) to shut down properly
"""
event_handler = PlaylistEventhandler()
observer = PlaylistObserver(timeout=FILESYSTEM_TIMEOUT)
observer.schedule(event_handler, v.PLAYLIST_PATH, recursive=True)
observer.start()
return observer

View file

@ -0,0 +1,360 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Syncs Plex playlists <=> Kodi playlists with 3 main components:
kodi_playlist_monitor()
watchdog Observer checking whether Kodi playlists are changed
websocket(plex_id, status)
Hit with websocket answers from our background sync
full_sync()
Triggers a full re-sync of playlists
PlaylistError is thrown if anything wierd happens
"""
from logging import getLogger
from .common import Playlist, PlaylistError, PlaylistObserver
from . import pms, db, kodi_pl, plex_pl
from ..watchdog import events
from ..plex_api import API
from .. import utils
from .. import path_ops
from .. import variables as v
from .. import state
###############################################################################
LOG = getLogger('PLEX.playlists')
# Safety margin for playlist filesystem operations
FILESYSTEM_TIMEOUT = 1
# Which playlist formates are supported by PKC?
SUPPORTED_FILETYPES = (
'm3u',
# 'm3u8'
# 'pls',
# 'cue',
)
###############################################################################
def kodi_playlist_monitor():
"""
Monitors the Kodi playlist folder special://profile/playlist for the user.
Will thus catch all changes on the Kodi side of things.
Returns an watchdog Observer instance. Be sure to use
observer.stop() (and maybe observer.join()) to shut down properly
"""
event_handler = PlaylistEventhandler()
observer = PlaylistObserver(timeout=FILESYSTEM_TIMEOUT)
observer.schedule(event_handler, v.PLAYLIST_PATH, recursive=True)
observer.start()
return observer
def websocket(plex_id, status):
"""
Hit by librarysync to process websocket messages concerning playlists
"""
create = False
with state.LOCK_PLAYLISTS:
playlist = db.get_playlist(plex_id=plex_id)
if playlist and status == 9:
# Won't be able to download metadata of the deleted playlist
if sync_plex_playlist(playlist=playlist):
LOG.debug('Plex deletion of playlist detected: %s', playlist)
try:
kodi_pl.delete(playlist)
except PlaylistError:
pass
return
xml = pms.metadata(plex_id)
if xml is None:
LOG.debug('Could not download playlist %s, probably deleted',
plex_id)
return
if not sync_plex_playlist(xml=xml[0]):
return
api = API(xml[0])
try:
if playlist:
if api.updated_at() == playlist.plex_updatedat:
LOG.debug('Playlist with id %s already synced: %s',
plex_id, playlist)
else:
LOG.debug('Change of Plex playlist detected: %s',
playlist)
kodi_pl.delete(playlist)
create = True
elif not playlist and not status == 9:
LOG.debug('Creation of new Plex playlist detected: %s',
plex_id)
create = True
# To the actual work
if create:
kodi_pl.create(plex_id)
except PlaylistError:
pass
def full_sync():
"""
Full sync of playlists between Kodi and Plex. Returns True is successful,
False otherwise
"""
if not state.SYNC_PLAYLISTS:
LOG.debug('Not syncing playlists')
return True
LOG.info('Starting playlist full sync')
with state.LOCK_PLAYLISTS:
return _full_sync()
def _full_sync():
"""
Need to lock because we're messing with playlists
"""
# Get all Plex playlists
xml = pms.all_playlists()
if xml is None:
return False
# 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.
old_plex_ids = db.plex_playlist_ids()
for xml_playlist in xml:
api = API(xml_playlist)
try:
old_plex_ids.remove(api.plex_id())
except ValueError:
pass
if not sync_plex_playlist(xml=xml_playlist):
continue
playlist = db.get_playlist(plex_id=api.plex_id())
try:
if not playlist:
LOG.debug('New Plex playlist %s discovered: %s',
api.plex_id(), api.title())
kodi_pl.create(api.plex_id())
elif playlist.plex_updatedat != api.updated_at():
LOG.debug('Detected changed Plex playlist %s: %s',
api.plex_id(), api.title())
kodi_pl.delete(playlist)
kodi_pl.create(api.plex_id())
except PlaylistError:
LOG.info('Skipping playlist %s: %s', api.plex_id(), api.title())
# Get rid of old Plex playlists that were deleted on the Plex side
for plex_id in old_plex_ids:
playlist = db.get_playlist(plex_id=plex_id)
if playlist:
LOG.debug('Removing outdated Plex playlist %s from %s',
playlist.plex_name, playlist.kodi_path)
try:
kodi_pl.delete(playlist)
except PlaylistError:
LOG.debug('Skipping deletion of playlist %s: %s',
api.plex_id(), api.title())
# Look at all supported Kodi playlists. Check whether they are in the DB.
old_kodi_paths = db.kodi_playlist_paths()
for root, _, files in path_ops.walk(v.PLAYLIST_PATH):
for f in files:
path = path_ops.path.join(root, f)
try:
old_kodi_paths.remove(path)
except ValueError:
pass
if not sync_kodi_playlist(path):
continue
kodi_hash = utils.generate_file_md5(path)
playlist = db.get_playlist(path=path)
if playlist and playlist.kodi_hash == kodi_hash:
continue
try:
if not playlist:
LOG.debug('New Kodi playlist detected: %s', path)
playlist = Playlist()
playlist.kodi_path = path
playlist.kodi_hash = kodi_hash
plex_pl.create(playlist)
else:
LOG.debug('Changed Kodi playlist detected: %s', playlist)
plex_pl.delete(playlist)
playlist.kodi_hash = kodi_hash
plex_pl.create(playlist)
except PlaylistError:
LOG.info('Skipping Kodi playlist %s', path)
for kodi_path in old_kodi_paths:
playlist = db.get_playlist(kodi_path=kodi_path)
try:
plex_pl.delete(playlist)
except PlaylistError:
LOG.debug('Skipping deletion of playlist %s: %s',
playlist.plex_id, playlist.plex_name)
LOG.info('Playlist full sync done')
return True
def sync_kodi_playlist(path):
"""
Returns True if we should sync this Kodi playlist with path [unicode] to
Plex based on the playlist file name and the user settings, False otherwise
"""
if path.startswith(v.PLAYLIST_PATH_MIXED):
return False
try:
extension = path.rsplit('.', 1)[1].lower()
except IndexError:
return False
if extension not in SUPPORTED_FILETYPES:
return False
if not state.SYNC_SPECIFIC_KODI_PLAYLISTS:
return True
playlist = Playlist()
playlist.kodi_path = path
prefix = utils.settings('syncSpecificKodiPlaylistsPrefix').lower()
if playlist.kodi_filename.lower().startswith(prefix):
return True
LOG.debug('User chose to not sync Kodi playlist %s', path)
return False
def sync_plex_playlist(plex_id=None, xml=None, playlist=None):
"""
Returns True if we should sync this specific Plex playlist due to the
user settings (including a disabled music library), False if not.
Pass in either the plex_id or an xml (where API(xml) will be used)
"""
if not state.SYNC_SPECIFIC_PLEX_PLAYLISTS:
return True
if playlist:
# Mainly once we DELETED a Plex playlist that we're NOT supposed
# to sync
name = playlist.plex_name
typus = playlist.kodi_type
else:
if xml is None:
xml = pms.metadata(plex_id)
if xml is None:
LOG.info('Could not get Plex metadata for playlist %s',
plex_id)
return False
api = API(xml[0])
else:
api = API(xml)
name = api.title()
typus = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()]
if (not state.ENABLE_MUSIC and typus == v.PLEX_PLAYLIST_TYPE_AUDIO):
LOG.debug('Not synching Plex audio playlist')
return False
prefix = utils.settings('syncSpecificPlexPlaylistsPrefix').lower()
if name and name.lower().startswith(prefix):
return True
LOG.debug('User chose to not sync Plex playlist')
return False
class PlaylistEventhandler(events.FileSystemEventHandler):
"""
PKC eventhandler to monitor Kodi playlists safed to disk
"""
def dispatch(self, event):
"""
Dispatches events to the appropriate methods.
:param event:
The event object representing the file system event.
:type event:
:class:`FileSystemEvent`
"""
if not state.SYNC_PLAYLISTS:
# Sync is deactivated
return
path = event.dest_path if event.event_type == events.EVENT_TYPE_MOVED \
else event.src_path
if not sync_kodi_playlist(path):
return
_method_map = {
events.EVENT_TYPE_MODIFIED: self.on_modified,
events.EVENT_TYPE_MOVED: self.on_moved,
events.EVENT_TYPE_CREATED: self.on_created,
events.EVENT_TYPE_DELETED: self.on_deleted,
}
with state.LOCK_PLAYLISTS:
_method_map[event.event_type](event)
def on_created(self, event):
LOG.debug('on_created: %s', event.src_path)
old_playlist = db.get_playlist(path=event.src_path)
kodi_hash = utils.generate_file_md5(event.src_path)
if old_playlist and old_playlist.kodi_hash == kodi_hash:
LOG.debug('Playlist already in DB - skipping')
return
elif old_playlist:
LOG.debug('Playlist already in DB but it has been changed')
self.on_modified(event)
return
playlist = Playlist()
playlist.kodi_path = event.src_path
playlist.kodi_hash = kodi_hash
try:
plex_pl.create(playlist)
except PlaylistError:
pass
def on_modified(self, event):
LOG.debug('on_modified: %s', event.src_path)
old_playlist = db.get_playlist(path=event.src_path)
kodi_hash = utils.generate_file_md5(event.src_path)
if old_playlist and old_playlist.kodi_hash == kodi_hash:
LOG.debug('Nothing modified, playlist already in DB - skipping')
return
new_playlist = Playlist()
if old_playlist:
# Retain the name! Might've come from Plex
# (rename should fire on_moved)
new_playlist.plex_name = old_playlist.plex_name
plex_pl.delete(old_playlist)
new_playlist.kodi_path = event.src_path
new_playlist.kodi_hash = kodi_hash
try:
plex_pl.create(new_playlist)
except PlaylistError:
pass
def on_moved(self, event):
LOG.debug('on_moved: %s to %s', event.src_path, event.dest_path)
kodi_hash = utils.generate_file_md5(event.dest_path)
# First check whether we don't already have destination playlist in
# our DB. Just in case....
old_playlist = db.get_playlist(path=event.dest_path)
if old_playlist:
LOG.warning('Found target playlist already in our DB!')
new_event = events.FileModifiedEvent(event.dest_path)
self.on_modified(new_event)
return
# All good
old_playlist = db.get_playlist(path=event.src_path)
if not old_playlist:
LOG.debug('Did not have source path in the DB %s', event.src_path)
else:
plex_pl.delete(old_playlist)
new_playlist = Playlist()
new_playlist.kodi_path = event.dest_path
new_playlist.kodi_hash = kodi_hash
try:
plex_pl.create(new_playlist)
except PlaylistError:
pass
def on_deleted(self, event):
LOG.debug('on_deleted: %s', event.src_path)
playlist = db.get_playlist(path=event.src_path)
if not playlist:
LOG.debug('Playlist not found in DB for path %s', event.src_path)
else:
plex_pl.delete(playlist)

View file

@ -0,0 +1,193 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from logging import getLogger
import Queue
import time
from ..watchdog import events
from ..watchdog.observers import Observer
from ..watchdog.utils.bricks import OrderedSetQueue
from .. import path_ops
from .. import variables as v
###############################################################################
LOG = getLogger('PLEX.playlists.common')
# These filesystem events are considered similar
SIMILAR_EVENTS = (events.EVENT_TYPE_CREATED, events.EVENT_TYPE_MODIFIED)
###############################################################################
class PlaylistError(Exception):
"""
The one main exception thrown if anything goes awry
"""
pass
class Playlist(object):
"""
Class representing a synced Playlist with info for both Kodi and Plex.
Attributes:
Plex:
plex_id: unicode
plex_name: unicode
plex_updatedat: unicode
Kodi:
kodi_path: unicode
kodi_filename: unicode
kodi_extension: unicode
kodi_type: unicode
kodi_hash: unicode
Testing for a Playlist() returns ONLY True if all the following attributes
are set; 2 playlists are only equal if all attributes are equal:
plex_id
plex_name
plex_updatedat
kodi_path
kodi_filename
kodi_type
kodi_hash
"""
def __init__(self):
# Plex
self.plex_id = None
self.plex_name = None
self.plex_updatedat = None
# Kodi
self._kodi_path = None
self.kodi_filename = None
self.kodi_extension = None
self.kodi_type = None
self.kodi_hash = None
def __repr__(self):
return ("{{"
"'plex_id': {self.plex_id}, "
"'plex_name': '{self.plex_name}', "
"'kodi_type': '{self.kodi_type}', "
"'kodi_filename': '{self.kodi_filename}', "
"'kodi_path': '{self._kodi_path}', "
"'plex_updatedat': {self.plex_updatedat}, "
"'kodi_hash': '{self.kodi_hash}'"
"}}").format(self=self)
def __str__(self):
return self.__repr__()
def __bool__(self):
return (self.plex_id and self.plex_updatedat and self.plex_name and
self._kodi_path and self.kodi_filename and self.kodi_type and
self.kodi_hash)
# Used for comparison of playlists
@property
def key(self):
return (self.plex_id, self.plex_updatedat, self.plex_name,
self._kodi_path, self.kodi_filename, self.kodi_type,
self.kodi_hash)
def __eq__(self, playlist):
return self.key == playlist.key
def __ne__(self, playlist):
return self.key != playlist.key
@property
def kodi_path(self):
return self._kodi_path
@kodi_path.setter
def kodi_path(self, path):
if not isinstance(path, unicode):
raise RuntimeError('Path not in unicode: %s' % path)
f = path_ops.path.basename(path)
try:
self.kodi_filename, self.kodi_extension = f.split('.', 1)
except ValueError:
LOG.error('Trying to set invalid path: %s', path)
raise PlaylistError('Invalid path: %s' % path)
if path.startswith(v.PLAYLIST_PATH_VIDEO):
self.kodi_type = v.KODI_TYPE_VIDEO_PLAYLIST
elif path.startswith(v.PLAYLIST_PATH_MUSIC):
self.kodi_type = v.KODI_TYPE_AUDIO_PLAYLIST
else:
LOG.error('Playlist type not supported for %s', path)
raise PlaylistError('Playlist type not supported: %s' % path)
if not self.plex_name:
self.plex_name = self.kodi_filename
self._kodi_path = path
class PlaylistQueue(OrderedSetQueue):
"""
OrderedSetQueue that drops all directory events immediately
"""
def _put(self, item):
if item[0].is_directory:
self.unfinished_tasks -= 1
else:
# Can't use super as OrderedSetQueue is old style class
OrderedSetQueue._put(self, item)
class PlaylistObserver(Observer):
"""
PKC implementation, overriding the dispatcher. PKC will wait for the
duration timeout (in seconds) AFTER receiving a filesystem event. A new
("non-similar") event will reset the timer.
Creating and modifying will be regarded as equal.
"""
def __init__(self, *args, **kwargs):
super(PlaylistObserver, self).__init__(*args, **kwargs)
# Drop the same events that get into the queue even if there are other
# events in between these similar events. Ignore directory events
# completely
self._event_queue = PlaylistQueue()
@staticmethod
def _pkc_similar_events(event1, event2):
if event1 == event2:
return True
elif (event1.src_path == event2.src_path and
event1.event_type in SIMILAR_EVENTS and
event2.event_type in SIMILAR_EVENTS):
# Set created and modified events to equal
return True
return False
def _dispatch_iterator(self, event_queue, timeout):
"""
This iterator will block for timeout (seconds) until an event is
received or raise Queue.Empty.
"""
event, watch = event_queue.get(block=True, timeout=timeout)
event_queue.task_done()
start = time.time()
while time.time() - start < timeout:
try:
new_event, new_watch = event_queue.get(block=False)
except Queue.Empty:
time.sleep(0.2)
else:
event_queue.task_done()
start = time.time()
if self._pkc_similar_events(new_event, event):
continue
else:
yield event, watch
event, watch = new_event, new_watch
yield event, watch
def dispatch_events(self, event_queue, timeout):
for event, watch in self._dispatch_iterator(event_queue, timeout):
# This is copy-paste of original code
with self._lock:
# To allow unschedule/stop and safe removal of event handlers
# within event handlers itself, check if the handler is still
# registered after every dispatch.
for handler in list(self._handlers.get(watch, [])):
if handler in self._handlers.get(watch, []):
handler.dispatch(event)

View file

@ -0,0 +1,116 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Synced playlists are stored in our plex.db. Interact with it through this
module
"""
from logging import getLogger
from .common import Playlist, PlaylistError
from .. import kodidb_functions as kodidb
from .. import plexdb_functions as plexdb
from .. import path_ops, utils, variables as v
###############################################################################
LOG = getLogger('PLEX.playlists.db')
###############################################################################
def plex_playlist_ids():
"""
Returns a list of all Plex ids of the playlists already in our DB
"""
with plexdb.Get_Plex_DB() as plex_db:
return plex_db.plex_ids_all_playlists()
def kodi_playlist_paths():
"""
Returns a list of all Kodi playlist paths of the playlists already synced
"""
with plexdb.Get_Plex_DB() as plex_db:
return plex_db.all_kodi_playlist_paths()
def update_playlist(playlist, delete=False):
"""
Assumes that all sync operations are over. Takes playlist [Playlist]
and creates/updates the corresponding Plex playlists table entry
Pass delete=True to delete the playlist entry
"""
with plexdb.Get_Plex_DB() as plex_db:
if delete:
plex_db.delete_playlist_entry(playlist)
else:
plex_db.insert_playlist_entry(playlist)
def get_playlist(path=None, kodi_hash=None, plex_id=None):
"""
Returns the playlist as a Playlist for either the plex_id, path or
kodi_hash. kodi_hash will be more reliable as it includes path and file
content.
"""
playlist = Playlist()
with plexdb.Get_Plex_DB() as plex_db:
playlist = plex_db.retrieve_playlist(playlist,
plex_id,
path, kodi_hash)
return playlist
def _m3u_iterator(text):
"""
Yields e.g. plugin://plugin.video.plexkodiconnect.movies/?plex_id=xxx
"""
lines = iter(text.split('\n'))
for line in lines:
if line.startswith('#EXTINF:'):
yield next(lines).strip()
def m3u_to_plex_ids(playlist):
"""
Adapter to process *.m3u playlist files. Encoding is not uniform!
"""
plex_ids = list()
with open(path_ops.encode_path(playlist.kodi_path), 'rb') as f:
text = f.read()
try:
text = text.decode(v.M3U_ENCODING)
except UnicodeDecodeError:
LOG.warning('Fallback to ISO-8859-1 decoding for %s', playlist)
text = text.decode('ISO-8859-1')
for entry in _m3u_iterator(text):
plex_id = utils.REGEX_PLEX_ID.search(entry)
if plex_id:
plex_id = plex_id.group(1)
plex_ids.append(plex_id)
else:
# Add-on paths not working, try direct
kodi_id, kodi_type = kodidb.kodiid_from_filename(
entry, db_type=playlist.kodi_type)
if not kodi_id:
continue
with plexdb.Get_Plex_DB() as plex_db:
plex_id = plex_db.getItem_byKodiId(kodi_id, kodi_type)
if plex_id:
plex_ids.append(plex_id[0])
return plex_ids
def playlist_file_to_plex_ids(playlist):
"""
Takes the playlist file located at path [unicode] and parses it.
Returns a list of plex_ids (str) or raises PL.PlaylistError if a single
item cannot be parsed from Kodi to Plex.
"""
if playlist.kodi_extension == 'm3u':
plex_ids = m3u_to_plex_ids(playlist)
else:
LOG.error('Unsupported playlist extension: %s',
playlist.kodi_extension)
raise PlaylistError
return plex_ids

View file

@ -0,0 +1,124 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Create and delete playlists on the Kodi side of things
"""
from logging import getLogger
from .common import Playlist, PlaylistError
from . import db, pms
from ..plex_api import API
from .. import utils, path_ops, variables as v
###############################################################################
LOG = getLogger('PLEX.playlists.kodi_pl')
###############################################################################
def create(plex_id):
"""
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). Thus make sure that the "same" playlist
is deleted from both disk and the Plex database.
Returns the playlist or raises PlaylistError
"""
xml_metadata = pms.metadata(plex_id)
if xml_metadata is None:
LOG.error('Could not get Plex playlist metadata %s', plex_id)
raise PlaylistError('Could not get Plex playlist %s' % plex_id)
api = API(xml_metadata[0])
playlist = Playlist()
playlist.plex_id = api.plex_id()
playlist.kodi_type = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()]
playlist.plex_name = api.title()
playlist.plex_updatedat = api.updated_at()
LOG.debug('Creating new Kodi playlist from Plex playlist: %s', playlist)
# Derive filename close to Plex playlist name
name = utils.valid_filename(playlist.plex_name)
path = path_ops.path.join(v.PLAYLIST_PATH, playlist.kodi_type,
'%s.m3u' % name)
while path_ops.exists(path) or db.get_playlist(path=path):
# In case the Plex playlist names are not unique
occurance = utils.REGEX_FILE_NUMBERING.search(path)
if not occurance:
path = path_ops.path.join(v.PLAYLIST_PATH,
playlist.kodi_type,
'%s_01.m3u' % name[:min(len(name), 248)])
else:
occurance = int(occurance.group(1)) + 1
path = path_ops.path.join(v.PLAYLIST_PATH,
playlist.kodi_type,
'%s_%02d.m3u' % (name[:min(len(name),
248)],
occurance))
LOG.debug('Kodi playlist path: %s', path)
playlist.kodi_path = path
xml_playlist = pms.get_playlist(plex_id)
if xml_playlist is None:
LOG.error('Could not get Plex playlist %s', plex_id)
raise PlaylistError('Could not get Plex playlist %s' % plex_id)
_write_playlist_to_file(playlist, xml_playlist)
playlist.kodi_hash = utils.generate_file_md5(path)
db.update_playlist(playlist)
LOG.debug('Created Kodi playlist based on Plex playlist: %s', playlist)
def delete(playlist):
"""
Removes the corresponding Kodi file for playlist Playlist from
disk. Be sure that playlist.kodi_path is set. Will also delete the entry in
the Plex playlist table.
Returns None or raises PlaylistError
"""
if path_ops.exists(playlist.kodi_path):
try:
path_ops.remove(playlist.kodi_path)
except (OSError, IOError) as err:
LOG.error('Could not delete Kodi playlist file %s. Error:\n%s: %s',
playlist, err.errno, err.strerror)
raise PlaylistError('Could not delete %s' % playlist.kodi_path)
db.update_playlist(playlist, delete=True)
def _write_playlist_to_file(playlist, xml):
"""
Feed with playlist Playlist. Will write the playlist to a m3u file
Returns None or raises PlaylistError
"""
text = '#EXTCPlayListM3U::M3U\n'
for element in xml:
api = API(element)
append_season_episode = False
if api.plex_type() == v.PLEX_TYPE_EPISODE:
_, show, season_id, episode_id = api.episode_data()
try:
season_id = int(season_id)
episode_id = int(episode_id)
except ValueError:
pass
else:
append_season_episode = True
if append_season_episode:
text += ('#EXTINF:%s,%s S%.2dE%.2d - %s\n%s\n'
% (api.runtime(), show, season_id, episode_id,
api.title(), api.path()))
else:
# Only append the TV show name
text += ('#EXTINF:%s,%s - %s\n%s\n'
% (api.runtime(), show, api.title(), api.path()))
else:
text += ('#EXTINF:%s,%s\n%s\n'
% (api.runtime(), api.title(), api.path()))
text += '\n'
text = text.encode(v.M3U_ENCODING, 'strict')
try:
with open(path_ops.encode_path(playlist.kodi_path), 'wb') as f:
f.write(text)
except EnvironmentError as err:
LOG.error('Could not write Kodi playlist file: %s', playlist)
LOG.error('Error message %s: %s', err.errno, err.strerror)
raise PlaylistError('Cannot write Kodi playlist to path for %s'
% playlist)

View file

@ -0,0 +1,50 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Create and delete playlists on the Plex side of things
"""
from logging import getLogger
from .common import PlaylistError
from . import pms, db
###############################################################################
LOG = getLogger('PLEX.playlists.plex_pl')
###############################################################################
def create(playlist):
"""
Adds the playlist Playlist to the PMS. If playlist.id is
not None the existing Plex playlist will be overwritten; otherwise a new
playlist will be generated and stored accordingly in the playlist object.
Will also add (or modify an existing) Plex playlist table entry.
Make sure that playlist.kodi_hash is set!
Returns None or raises PlaylistError
"""
LOG.debug('Creating Plex playlist from Kodi file: %s', playlist)
plex_ids = db.playlist_file_to_plex_ids(playlist)
if not plex_ids:
LOG.warning('No Plex ids found for playlist %s', playlist)
raise PlaylistError
for pos, plex_id in enumerate(plex_ids):
try:
if pos == 0 or not playlist.plex_id:
pms.initialize(playlist, plex_id)
else:
pms.add_item(playlist, plex_id=plex_id)
except PlaylistError:
continue
db.update_playlist(playlist)
LOG.debug('Done creating Plex playlist %s', playlist)
def delete(playlist):
"""
Removes the playlist Playlist from the PMS. Will also delete the
entry in the Plex playlist table.
Returns None or raises PlaylistError
"""
LOG.debug('Deleting playlist from PMS: %s', playlist)
pms.delete(playlist)
db.update_playlist(playlist, delete=True)

View file

@ -0,0 +1,116 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Functions to communicate with the currently connected PMS in order to
manipulate playlists
"""
from logging import getLogger
import urllib
from .common import PlaylistError
from ..plex_api import API
from ..downloadutils import DownloadUtils as DU
from .. import variables as v
###############################################################################
LOG = getLogger('PLEX.playlists.pms')
###############################################################################
def all_playlists():
"""
Returns an XML with all Plex playlists or None
"""
xml = DU().downloadUrl('{server}/playlists')
try:
xml.attrib
except (AttributeError, TypeError):
LOG.error('Could not download a list of all playlists')
xml = None
return xml
def get_playlist(plex_id):
"""
Fetches the PMS playlist/playqueue as an XML. Pass in playlist id
Returns None if something went wrong
"""
xml = DU().downloadUrl("{server}/playlists/%s/items" % plex_id)
try:
xml.attrib
except AttributeError:
xml = None
return xml
def initialize(playlist, plex_id):
"""
Initializes a new playlist on the PMS side. Will set playlist.plex_id and
playlist.plex_updatedat. Will raise PlaylistError if something went wrong.
"""
LOG.debug('Initializing the playlist with Plex id %s on the Plex side: %s',
plex_id, playlist)
params = {
'type': v.PLEX_PLAYLIST_TYPE_FROM_KODI[playlist.kodi_type],
'title': playlist.plex_name,
'smart': 0,
'uri': ('library://None/item/%s' % (urllib.quote('/library/metadata/%s'
% plex_id, safe='')))
}
xml = DU().downloadUrl(url='{server}/playlists',
action_type='POST',
parameters=params)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not initialize playlist on Plex side with plex id %s',
plex_id)
raise PlaylistError('Could not initialize Plex playlist %s', plex_id)
api = API(xml[0])
playlist.plex_id = api.plex_id()
playlist.plex_updatedat = api.updated_at()
def add_item(playlist, plex_id):
"""
Adds the item with plex_id to the existing Plex playlist (at the end).
Will set playlist.plex_updatedat
Raises PlaylistError if that did not work out.
"""
params = {
'uri': ('library://None/item/%s' % (urllib.quote('/library/metadata/%s'
% plex_id, safe='')))
}
xml = DU().downloadUrl(url='{server}/playlists/%s/items' % playlist.plex_id,
action_type='PUT',
parameters=params)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not initialize playlist on Plex side with plex id %s',
plex_id)
raise PlaylistError('Could not item %s to Plex playlist %s',
plex_id, playlist)
api = API(xml[0])
playlist.plex_updatedat = api.updated_at()
def metadata(plex_id):
"""
Returns an xml with the entire metadata like updatedAt.
"""
xml = DU().downloadUrl('{server}/playlists/%s' % plex_id)
try:
xml.attrib
except AttributeError:
xml = None
return xml
def delete(playlist):
"""
Deletes the playlist from the PMS
"""
DU().downloadUrl('{server}/playlists/%s' % playlist.plex_id,
action_type="DELETE")

View file

@ -73,7 +73,7 @@ def init_playqueue_from_plex_children(plex_id, transient_token=None):
""" """
Init a new playqueue e.g. from an album. Alexa does this Init a new playqueue e.g. from an album. Alexa does this
Returns the Playlist_Object Returns the playqueue
""" """
xml = PF.GetAllPlexChildren(plex_id) xml = PF.GetAllPlexChildren(plex_id)
try: try:

View file

@ -427,8 +427,9 @@ class Plex_DB_Functions():
def retrieve_playlist(self, playlist, plex_id=None, path=None, def retrieve_playlist(self, playlist, plex_id=None, path=None,
kodi_hash=None): kodi_hash=None):
""" """
Returns a complete Playlist_Object (empty one passed in via playlist) Returns a complete Playlist (empty one passed in via playlist)
for the entry with plex_id. Or None if not found for the entry with plex_id OR kodi_hash OR kodi_path.
Returns None if not found
""" """
query = ''' query = '''
SELECT plex_id, plex_name, plex_updatedat, kodi_path, kodi_type, SELECT plex_id, plex_name, plex_updatedat, kodi_path, kodi_type,
@ -450,11 +451,11 @@ class Plex_DB_Functions():
answ = self.plexcursor.fetchone() answ = self.plexcursor.fetchone()
if not answ: if not answ:
return return
playlist.id = answ[0] playlist.plex_id = answ[0]
playlist.plex_name = answ[1] playlist.plex_name = answ[1]
playlist.plex_updatedat = answ[2] playlist.plex_updatedat = answ[2]
playlist.kodi_path = answ[3] playlist.kodi_path = answ[3]
playlist.type = answ[4] playlist.kodi_type = answ[4]
playlist.kodi_hash = answ[5] playlist.kodi_hash = answ[5]
return playlist return playlist
@ -469,9 +470,9 @@ class Plex_DB_Functions():
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
''' '''
self.plexcursor.execute(query, self.plexcursor.execute(query,
(playlist.id, playlist.plex_name, (playlist.plex_id, playlist.plex_name,
playlist.plex_updatedat, playlist.kodi_path, playlist.plex_updatedat, playlist.kodi_path,
playlist.type, playlist.kodi_hash)) playlist.kodi_type, playlist.kodi_hash))
def delete_playlist_entry(self, playlist): def delete_playlist_entry(self, playlist):
""" """
@ -479,12 +480,12 @@ class Plex_DB_Functions():
playlists table. playlists table.
Be sure to either set playlist.id or playlist.kodi_path Be sure to either set playlist.id or playlist.kodi_path
""" """
if playlist.id: if playlist.plex_id:
query = 'DELETE FROM playlists WHERE plex_id = ?' query = 'DELETE FROM playlists WHERE plex_id = ?'
var = playlist.id var = playlist.plex_id
elif playlist.kodi_path: elif playlist.kodi_path:
query = 'DELETE FROM playlists WHERE kodi_path = ?' query = 'DELETE FROM playlists WHERE kodi_path = ?'
var = playlist.kodi_path var = playlist.kodi_path
else: else:
raise RuntimeError('Cannot delete playlist: %s', playlist) raise RuntimeError('Cannot delete playlist: %s' % playlist)
self.plexcursor.execute(query, (var, )) self.plexcursor.execute(query, (var, ))