Merge branch 'beta-version'
This commit is contained in:
commit
8275c943b8
17 changed files with 992 additions and 826 deletions
|
@ -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)
|
||||
[![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)
|
||||
[![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.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)
|
||||
[![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?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>
|
||||
<import addon="xbmc.python" version="2.1.0"/>
|
||||
<import addon="script.module.requests" version="2.9.1" />
|
||||
|
@ -73,7 +73,12 @@
|
|||
<summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary>
|
||||
<description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description>
|
||||
<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 Plex Companion crashing when connected to Plex Web
|
||||
- Fix Plex Companion crash when connected to Plex Web playing playlist music
|
||||
|
|
|
@ -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):
|
||||
- Fix OnDeck widget for Direct Paths
|
||||
- Fix Plex Companion crashing when connected to Plex Web
|
||||
|
|
|
@ -69,7 +69,7 @@ class ThreadedProcessMetadata(Thread):
|
|||
continue
|
||||
# Do the work
|
||||
item_method = getattr(item_class, item['method'])
|
||||
if item.get('children'):
|
||||
if item.get('children') is not None:
|
||||
item_method(item['xml'][0],
|
||||
viewtag=item['view_name'],
|
||||
viewid=item['view_id'],
|
||||
|
|
|
@ -1189,8 +1189,8 @@ class LibrarySync(Thread):
|
|||
if typus == 'playlist':
|
||||
if not state.SYNC_PLAYLISTS:
|
||||
continue
|
||||
playlists.process_websocket(plex_id=unicode(item['itemID']),
|
||||
status=status)
|
||||
playlists.websocket(plex_id=unicode(item['itemID']),
|
||||
status=status)
|
||||
elif status == 9:
|
||||
# Immediately and always process deletions (as the PMS will
|
||||
# send additional message with other codes)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Collection of functions associated with Kodi and Plex playlists and playqueues
|
||||
|
@ -60,47 +61,6 @@ class PlaylistObjectBaseclase(object):
|
|||
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):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
return
|
||||
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,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
|
356
resources/lib/playlists/__init__.py
Normal file
356
resources/lib/playlists/__init__.py
Normal 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)
|
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
|
||||
|
||||
Returns the Playlist_Object
|
||||
Returns the playqueue
|
||||
"""
|
||||
xml = PF.GetAllPlexChildren(plex_id)
|
||||
try:
|
||||
|
|
|
@ -473,6 +473,7 @@ def GetPlexMetadata(key):
|
|||
'includeExtras': 1, # Trailers and Extras => Extras
|
||||
'includeReviews': 1,
|
||||
'includeRelated': 0, # Similar movies => Video -> Related
|
||||
'skipRefresh': 1,
|
||||
# 'includeRelatedCount': 0,
|
||||
# 'includeOnDeck': 1,
|
||||
# 'includeChapters': 1,
|
||||
|
|
|
@ -414,12 +414,12 @@ class Plex_DB_Functions():
|
|||
answ.append(entry[0])
|
||||
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 = []
|
||||
self.plexcursor.execute('SELECT kodi_hash FROM playlists')
|
||||
self.plexcursor.execute('SELECT kodi_path FROM playlists')
|
||||
for entry in self.plexcursor.fetchall():
|
||||
answ.append(entry[0])
|
||||
return answ
|
||||
|
@ -427,8 +427,9 @@ class Plex_DB_Functions():
|
|||
def retrieve_playlist(self, playlist, plex_id=None, path=None,
|
||||
kodi_hash=None):
|
||||
"""
|
||||
Returns a complete Playlist_Object (empty one passed in via playlist)
|
||||
for the entry with plex_id. Or None if not found
|
||||
Returns a complete Playlist (empty one passed in via playlist)
|
||||
for the entry with plex_id OR kodi_hash OR kodi_path.
|
||||
Returns None if not found
|
||||
"""
|
||||
query = '''
|
||||
SELECT plex_id, plex_name, plex_updatedat, kodi_path, kodi_type,
|
||||
|
@ -450,11 +451,11 @@ class Plex_DB_Functions():
|
|||
answ = self.plexcursor.fetchone()
|
||||
if not answ:
|
||||
return
|
||||
playlist.id = answ[0]
|
||||
playlist.plex_id = answ[0]
|
||||
playlist.plex_name = answ[1]
|
||||
playlist.plex_updatedat = answ[2]
|
||||
playlist.kodi_path = answ[3]
|
||||
playlist.type = answ[4]
|
||||
playlist.kodi_type = answ[4]
|
||||
playlist.kodi_hash = answ[5]
|
||||
return playlist
|
||||
|
||||
|
@ -469,9 +470,9 @@ class Plex_DB_Functions():
|
|||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
'''
|
||||
self.plexcursor.execute(query,
|
||||
(playlist.id, playlist.plex_name,
|
||||
(playlist.plex_id, playlist.plex_name,
|
||||
playlist.plex_updatedat, playlist.kodi_path,
|
||||
playlist.type, playlist.kodi_hash))
|
||||
playlist.kodi_type, playlist.kodi_hash))
|
||||
|
||||
def delete_playlist_entry(self, playlist):
|
||||
"""
|
||||
|
@ -479,12 +480,12 @@ class Plex_DB_Functions():
|
|||
playlists table.
|
||||
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 = ?'
|
||||
var = playlist.id
|
||||
var = playlist.plex_id
|
||||
elif playlist.kodi_path:
|
||||
query = 'DELETE FROM playlists WHERE kodi_path = ?'
|
||||
var = playlist.kodi_path
|
||||
else:
|
||||
raise RuntimeError('Cannot delete playlist: %s', playlist)
|
||||
raise RuntimeError('Cannot delete playlist: %s' % playlist)
|
||||
self.plexcursor.execute(query, (var, ))
|
||||
|
|
|
@ -1011,19 +1011,18 @@ def delete_nodes():
|
|||
def generate_file_md5(path):
|
||||
"""
|
||||
Generates the md5 hash value for the file located at path [unicode].
|
||||
The hash includes the path and is thus different for the same file for
|
||||
different filenames.
|
||||
Returns a unique string containing only hexadecimal digits
|
||||
The hash does not include the path and filename and is thus identical for
|
||||
a file that was moved/changed name.
|
||||
Returns a unique unicode containing only hexadecimal digits
|
||||
"""
|
||||
m = hashlib.md5()
|
||||
m.update(path.encode('utf-8'))
|
||||
with open(path_ops.encode_path(path), 'rb') as f:
|
||||
while True:
|
||||
piece = f.read(32768)
|
||||
if not piece:
|
||||
break
|
||||
m.update(piece)
|
||||
return m.hexdigest()
|
||||
return m.hexdigest().decode('utf-8')
|
||||
|
||||
|
||||
###############################################################################
|
||||
|
|
Loading…
Reference in a new issue