Merge branch 'beta-version'

This commit is contained in:
croneter 2018-07-12 11:04:58 +02:00
commit 8275c943b8
17 changed files with 992 additions and 826 deletions

View file

@ -1,5 +1,5 @@
[![stable version](https://img.shields.io/badge/stable_version-2.1.5-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) [![stable version](https://img.shields.io/badge/stable_version-2.1.6-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip)
[![beta version](https://img.shields.io/badge/beta_version-2.2.11-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) [![beta version](https://img.shields.io/badge/beta_version-2.2.12-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip)
[![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation) [![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation)
[![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq) [![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq)

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.2.11" provider-name="croneter"> <addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.2.12" provider-name="croneter">
<requires> <requires>
<import addon="xbmc.python" version="2.1.0"/> <import addon="xbmc.python" version="2.1.0"/>
<import addon="script.module.requests" version="2.9.1" /> <import addon="script.module.requests" version="2.9.1" />
@ -73,7 +73,12 @@
<summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary> <summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary>
<description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description> <description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description>
<disclaimer lang="uk_UA">Використовуйте на свій ризик</disclaimer> <disclaimer lang="uk_UA">Використовуйте на свій ризик</disclaimer>
<news>version 2.2.11 (beta only): <news>version 2.2.12 (beta only):
- Fix slow sync. Fix endless sync of corrupted PMS elements
- Refactor playlist code
- Fix FutureWarning
version 2.2.11 (beta only):
- Fix OnDeck widget for Direct Paths - Fix OnDeck widget for Direct Paths
- Fix Plex Companion crashing when connected to Plex Web - Fix Plex Companion crashing when connected to Plex Web
- Fix Plex Companion crash when connected to Plex Web playing playlist music - Fix Plex Companion crash when connected to Plex Web playing playlist music

View file

@ -1,3 +1,8 @@
version 2.2.12 (beta only):
- Fix slow sync. Fix endless sync of corrupted PMS elements
- Refactor playlist code
- Fix FutureWarning
version 2.2.11 (beta only): version 2.2.11 (beta only):
- Fix OnDeck widget for Direct Paths - Fix OnDeck widget for Direct Paths
- Fix Plex Companion crashing when connected to Plex Web - Fix Plex Companion crashing when connected to Plex Web

View file

@ -69,7 +69,7 @@ class ThreadedProcessMetadata(Thread):
continue continue
# Do the work # Do the work
item_method = getattr(item_class, item['method']) item_method = getattr(item_class, item['method'])
if item.get('children'): if item.get('children') is not None:
item_method(item['xml'][0], item_method(item['xml'][0],
viewtag=item['view_name'], viewtag=item['view_name'],
viewid=item['view_id'], viewid=item['view_id'],

View file

@ -1189,7 +1189,7 @@ 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

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,674 +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
"""
if (state.SYNC_SPECIFIC_KODI_PLAYLISTS and
not sync_kodi_playlist(playlist.kodi_path)):
# We might have already synced this playlist BEFORE user chose to only
# sync specific playlists. Let's NOT delete all those playlists.
# However, delete it from our database of synced playlists.
LOG.debug('Not deleting playlist since user chose not to sync: %s',
playlist)
else:
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
Be aware that user settings will be checked whether this Plex playlist
should actually indeed be synced
"""
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])
if state.SYNC_SPECIFIC_PLEX_PLAYLISTS:
prefix = utils.settings('syncSpecificPlexPlaylistsPrefix').lower()
if api.title() and not api.title().lower().startswith(prefix):
LOG.debug('User chose to not sync Plex playlist %s', api.title())
return
playlist = PL.Playlist_Object()
playlist.id = api.plex_id()
playlist.type = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()]
if not state.ENABLE_MUSIC and playlist.type == v.KODI_PLAYLIST_TYPE_AUDIO:
return
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 not sync_kodi_playlist(playlist.kodi_path):
LOG.debug('Do not delete since we should not sync playlist %s',
playlist)
else:
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
"""
if delete:
with plexdb.Get_Plex_DB() as plex_db:
plex_db.delete_playlist_entry(playlist)
return
with plexdb.Get_Plex_DB() as plex_db:
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('Unknown 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 (OSError, IOError) 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 change_plex_playlist_name(playlist, new_name):
"""
TODO - Renames the existing playlist with new_name [unicode]
"""
pass
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 _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 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)
try:
if playlist and status == 9:
LOG.debug('Plex deletion of playlist detected: %s', playlist)
delete_kodi_playlist(playlist)
elif playlist:
xml = PL.get_pms_playlist_metadata(plex_id)
if xml is None:
LOG.error('Could not download playlist %s', plex_id)
return
api = API(xml[0])
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)
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())
if path_ops.exists(playlist.kodi_path):
delete_kodi_playlist(playlist)
else:
update_plex_table(playlist, delete=True)
create_kodi_playlist(api.plex_id())
except PL.PlaylistError:
LOG.info('Skipping playlist %s: %s', api.plex_id(), api.title())
try:
old_plex_ids.remove(api.plex_id())
except ValueError:
pass
# 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:
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 path_ops.walk(master_path):
for file in files:
try:
extension = file.rsplit('.', 1)[1]
except IndexError:
continue
if extension not in SUPPORTED_FILETYPES:
continue
path = path_ops.path.join(root, file)
if not sync_kodi_playlist(path):
continue
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:
playlist = PL.Playlist_Object()
playlist.kodi_path = path
playlist.kodi_hash = kodi_hash
if playlist_2:
LOG.debug('Changed Kodi playlist %s detected: %s',
playlist_2.plex_name, path)
playlist.id = playlist_2.id
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(playlist)
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
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 not state.SYNC_SPECIFIC_KODI_PLAYLISTS:
return True
playlist = PL.Playlist_Object()
try:
playlist.kodi_path = path
except PL.PlaylistError:
pass
else:
prefix = utils.settings('syncSpecificKodiPlaylistsPrefix').lower()
if playlist.kodi_filename.lower().startswith(prefix):
return True
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
try:
_, extension = event.src_path.rsplit('.', 1)
except ValueError:
return
if extension.lower() not in SUPPORTED_FILETYPES:
return
if event.src_path.startswith(v.PLAYLIST_PATH_MIXED):
return
if (not state.ENABLE_MUSIC and
event.src_path.startswith(v.PLAYLIST_PATH_MUSIC)):
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)
if (old_playlist and old_playlist.kodi_hash ==
utils.generate_file_md5(event.src_path)):
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 = utils.generate_file_md5(event.src_path)
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)
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
new_playlist.kodi_path = event.src_path
new_playlist.kodi_hash = utils.generate_file_md5(event.src_path)
try:
if not old_playlist:
LOG.debug('Old playlist not found, creating a new one')
try:
create_plex_playlist(new_playlist)
except PL.PlaylistError:
pass
elif old_playlist.kodi_hash == new_playlist.kodi_hash:
LOG.debug('Old and new playlist are identical - nothing to do')
else:
delete_plex_playlist(old_playlist)
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)
old_playlist = playlist_object_from_db(path=event.src_path)
if not old_playlist:
LOG.error('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 = utils.generate_file_md5(event.dest_path)
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.error('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,356 @@
#!/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)
LOG.debug('Removing outdated Plex playlist: %s', playlist)
try:
kodi_pl.delete(playlist)
except PlaylistError:
LOG.debug('Skipping deletion of playlist: %s', playlist)
# 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', path)
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(path=kodi_path)
try:
plex_pl.delete(playlist)
except PlaylistError:
LOG.debug('Skipping deletion of playlist: %s', playlist)
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 %s', name)
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

@ -473,6 +473,7 @@ def GetPlexMetadata(key):
'includeExtras': 1, # Trailers and Extras => Extras 'includeExtras': 1, # Trailers and Extras => Extras
'includeReviews': 1, 'includeReviews': 1,
'includeRelated': 0, # Similar movies => Video -> Related 'includeRelated': 0, # Similar movies => Video -> Related
'skipRefresh': 1,
# 'includeRelatedCount': 0, # 'includeRelatedCount': 0,
# 'includeOnDeck': 1, # 'includeOnDeck': 1,
# 'includeChapters': 1, # 'includeChapters': 1,

View file

@ -414,12 +414,12 @@ class Plex_DB_Functions():
answ.append(entry[0]) answ.append(entry[0])
return answ return answ
def kodi_hashes_all_playlists(self): def all_kodi_playlist_paths(self):
""" """
Returns a list of all Kodi hashes of playlists. Returns a list of all Kodi playlist paths.
""" """
answ = [] answ = []
self.plexcursor.execute('SELECT kodi_hash FROM playlists') self.plexcursor.execute('SELECT kodi_path FROM playlists')
for entry in self.plexcursor.fetchall(): for entry in self.plexcursor.fetchall():
answ.append(entry[0]) answ.append(entry[0])
return answ return answ
@ -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, ))

View file

@ -1011,19 +1011,18 @@ def delete_nodes():
def generate_file_md5(path): def generate_file_md5(path):
""" """
Generates the md5 hash value for the file located at path [unicode]. Generates the md5 hash value for the file located at path [unicode].
The hash includes the path and is thus different for the same file for The hash does not include the path and filename and is thus identical for
different filenames. a file that was moved/changed name.
Returns a unique string containing only hexadecimal digits Returns a unique unicode containing only hexadecimal digits
""" """
m = hashlib.md5() m = hashlib.md5()
m.update(path.encode('utf-8'))
with open(path_ops.encode_path(path), 'rb') as f: with open(path_ops.encode_path(path), 'rb') as f:
while True: while True:
piece = f.read(32768) piece = f.read(32768)
if not piece: if not piece:
break break
m.update(piece) m.update(piece)
return m.hexdigest() return m.hexdigest().decode('utf-8')
############################################################################### ###############################################################################