Refactor code
This commit is contained in:
parent
e0108eeb89
commit
c0d78bd273
11 changed files with 973 additions and 805 deletions
|
@ -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
|
||||||
|
|
|
@ -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")
|
|
||||||
|
|
|
@ -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
|
|
360
resources/lib/playlists/__init__.py
Normal file
360
resources/lib/playlists/__init__.py
Normal 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)
|
193
resources/lib/playlists/common.py
Normal file
193
resources/lib/playlists/common.py
Normal 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)
|
116
resources/lib/playlists/db.py
Normal file
116
resources/lib/playlists/db.py
Normal 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
|
124
resources/lib/playlists/kodi_pl.py
Normal file
124
resources/lib/playlists/kodi_pl.py
Normal 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)
|
50
resources/lib/playlists/plex_pl.py
Normal file
50
resources/lib/playlists/plex_pl.py
Normal 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)
|
116
resources/lib/playlists/pms.py
Normal file
116
resources/lib/playlists/pms.py
Normal 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")
|
|
@ -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:
|
||||||
|
|
|
@ -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, ))
|
||||||
|
|
Loading…
Reference in a new issue