PlexKodiConnect/resources/lib/playlists/common.py

215 lines
7.1 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from future import standard_library
standard_library.install_aliases()
from builtins import object
from logging import getLogger
import queue
import time
import os
import hashlib
from ..watchdog import events
from ..watchdog.observers import Observer
from ..watchdog.utils.bricks import OrderedSetQueue
from .. import path_ops, variables as v, app
###############################################################################
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 __unicode__(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 __repr__(self):
return self.__unicode__().encode('utf-8')
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):
f = path_ops.path.basename(path)
try:
self.kodi_filename, self.kodi_extension = f.rsplit('.', 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
def kodi_playlist_hash(path):
"""
Returns a md5 hash [unicode] using os.stat() st_size and st_mtime for the
playlist located at path [unicode]
(size of file in bytes and time of most recent content modification)
There are probably way more efficient ways out there to do this
"""
stat = os.stat(path_ops.encode_path(path))
# stat.st_size is of type long; stat.st_mtime is of type float - hash both
m = hashlib.md5()
m.update(repr(stat.st_size))
m.update(repr(stat.st_mtime))
return m.hexdigest().decode('utf-8')
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:
app.APP.monitor.waitForAbort(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)