Files
JellyPy/plexpy/activity_handler.py
Landon Abney 67f70fab90 Don't double notify on fast buffer triggers
If two buffer notifications come in at the same second right at the cusp 
of the notification trigger the difference between the current and last 
trigger would be 0, causing it to send two notifications.

Change the initial value to `None` to prevent this from happening.
2018-10-11 13:29:21 -07:00

587 lines
26 KiB
Python

# This file is part of Tautulli.
#
# Tautulli is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Tautulli is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
import datetime
import os
import time
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.date import DateTrigger
import plexpy
import activity_processor
import datafactory
import helpers
import logger
import notification_handler
import pmsconnect
ACTIVITY_SCHED = BackgroundScheduler()
RECENTLY_ADDED_QUEUE = {}
class ActivityHandler(object):
def __init__(self, timeline):
self.timeline = timeline
def is_valid_session(self):
if 'sessionKey' in self.timeline:
if str(self.timeline['sessionKey']).isdigit():
return True
return False
def get_session_key(self):
if self.is_valid_session():
return int(self.timeline['sessionKey'])
return None
def get_rating_key(self):
if self.is_valid_session():
return self.timeline['ratingKey']
return None
def get_live_session(self):
pms_connect = pmsconnect.PmsConnect()
session_list = pms_connect.get_current_activity()
if session_list:
for session in session_list['sessions']:
if int(session['session_key']) == self.get_session_key():
# Live sessions don't have rating keys in sessions
# Get it from the websocket data
if not session['rating_key']:
session['rating_key'] = self.get_rating_key()
return session
return None
def update_db_session(self, session=None):
if session is None:
session = self.get_live_session()
if session:
# Update our session temp table values
ap = activity_processor.ActivityProcessor()
ap.write_session(session=session, notify=False)
self.set_session_state()
def set_session_state(self):
ap = activity_processor.ActivityProcessor()
ap.set_session_state(session_key=self.get_session_key(),
state=self.timeline['state'],
view_offset=self.timeline['viewOffset'],
stopped=int(time.time()))
def on_start(self):
if self.is_valid_session():
session = self.get_live_session()
if not session:
return
# Some DLNA clients create a new session temporarily when browsing the library
# Wait and get session again to make sure it is an actual session
if session['platform'] == 'DLNA':
time.sleep(1)
session = self.get_live_session()
if not session:
return
logger.debug(u"Tautulli ActivityHandler :: Session %s started by user %s (%s) with ratingKey %s (%s)."
% (str(session['session_key']), str(session['user_id']), session['username'],
str(session['rating_key']), session['full_title']))
plexpy.NOTIFY_QUEUE.put({'stream_data': session.copy(), 'notify_action': 'on_play'})
# Write the new session to our temp session table
self.update_db_session(session=session)
def on_stop(self, force_stop=False):
if self.is_valid_session():
logger.debug(u"Tautulli ActivityHandler :: Session %s %sstopped."
% (str(self.get_session_key()), 'force ' if force_stop else ''))
# Set the session last_paused timestamp
ap = activity_processor.ActivityProcessor()
ap.set_session_last_paused(session_key=self.get_session_key(), timestamp=None)
# Update the session state and viewOffset
# Set force_stop to true to disable the state set
if not force_stop:
self.set_session_state()
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_stop'})
# Write it to the history table
monitor_proc = activity_processor.ActivityProcessor()
row_id = monitor_proc.write_session_history(session=db_session)
if row_id:
schedule_callback('session_key-{}'.format(self.get_session_key()), remove_job=True)
# Remove the session from our temp session table
logger.debug(u"Tautulli ActivityHandler :: Removing sessionKey %s ratingKey %s from session queue"
% (str(self.get_session_key()), str(self.get_rating_key())))
ap.delete_session(row_id=row_id)
delete_metadata_cache(self.get_session_key())
else:
schedule_callback('session_key-{}'.format(self.get_session_key()), func=force_stop_stream,
args=[self.get_session_key()], seconds=30)
def on_pause(self, still_paused=False):
if self.is_valid_session():
if not still_paused:
logger.debug(u"Tautulli ActivityHandler :: Session %s paused." % str(self.get_session_key()))
# Set the session last_paused timestamp
ap = activity_processor.ActivityProcessor()
ap.set_session_last_paused(session_key=self.get_session_key(), timestamp=int(time.time()))
# Update the session state and viewOffset
self.update_db_session()
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
if not still_paused:
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_pause'})
def on_resume(self):
if self.is_valid_session():
logger.debug(u"Tautulli ActivityHandler :: Session %s resumed." % str(self.get_session_key()))
# Set the session last_paused timestamp
ap = activity_processor.ActivityProcessor()
ap.set_session_last_paused(session_key=self.get_session_key(), timestamp=None)
# Update the session state and viewOffset
self.update_db_session()
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_resume'})
def on_change(self):
if self.is_valid_session():
logger.debug(u"Tautulli ActivityHandler :: Session %s has changed transcode decision." % str(self.get_session_key()))
# Update the session state and viewOffset
self.update_db_session()
# Retrieve the session data from our temp table
ap = activity_processor.ActivityProcessor()
db_session = ap.get_session_by_key(session_key=self.get_session_key())
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_change'})
def on_buffer(self):
if self.is_valid_session():
logger.debug(u"Tautulli ActivityHandler :: Session %s is buffering." % self.get_session_key())
ap = activity_processor.ActivityProcessor()
db_stream = ap.get_session_by_key(session_key=self.get_session_key())
# Increment our buffer count
ap.increment_session_buffer_count(session_key=self.get_session_key())
# Get our current buffer count
current_buffer_count = ap.get_session_buffer_count(self.get_session_key())
logger.debug(u"Tautulli ActivityHandler :: Session %s buffer count is %s." %
(self.get_session_key(), current_buffer_count))
# Get our last triggered time
buffer_last_triggered = ap.get_session_buffer_trigger_time(self.get_session_key())
# Update the session state and viewOffset
self.update_db_session()
time_since_last_trigger = None
if buffer_last_triggered:
logger.debug(u"Tautulli ActivityHandler :: Session %s buffer last triggered at %s." %
(self.get_session_key(), buffer_last_triggered))
time_since_last_trigger = int(time.time()) - int(buffer_last_triggered)
if plexpy.CONFIG.BUFFER_THRESHOLD > 0 and (current_buffer_count >= plexpy.CONFIG.BUFFER_THRESHOLD and \
time_since_last_trigger is None or time_since_last_trigger >= plexpy.CONFIG.BUFFER_WAIT):
ap.set_session_buffer_trigger_time(session_key=self.get_session_key())
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_buffer'})
# This function receives events from our websocket connection
def process(self):
if self.is_valid_session():
ap = activity_processor.ActivityProcessor()
db_session = ap.get_session_by_key(session_key=self.get_session_key())
this_state = self.timeline['state']
this_rating_key = str(self.timeline['ratingKey'])
this_key = self.timeline['key']
this_transcode_key = self.timeline.get('transcodeSession', '')
# Get the live tv session uuid
this_live_uuid = this_key.split('/')[-1] if this_key.startswith('/livetv/sessions') else None
# If we already have this session in the temp table, check for state changes
if db_session:
# Re-schedule the callback to reset the 5 minutes timer
schedule_callback('session_key-{}'.format(self.get_session_key()),
func=force_stop_stream, args=[self.get_session_key()], minutes=5)
last_state = db_session['state']
last_rating_key = str(db_session['rating_key'])
last_live_uuid = db_session['live_uuid']
last_transcode_key = db_session['transcode_key'].split('/')[-1]
# Make sure the same item is being played
if this_rating_key == last_rating_key or this_live_uuid == last_live_uuid:
# Update the session state and viewOffset
if this_state == 'playing':
# Update the session in our temp session table
# if the last set temporary stopped time exceeds 60 seconds
if int(time.time()) - db_session['stopped'] > 60:
self.update_db_session()
# Start our state checks
if this_state != last_state:
if this_state == 'paused':
self.on_pause()
elif last_state == 'paused' and this_state == 'playing':
self.on_resume()
elif this_state == 'stopped':
self.on_stop()
elif this_state == 'paused':
# Update the session last_paused timestamp
self.on_pause(still_paused=True)
if this_state == 'buffering':
self.on_buffer()
if this_transcode_key != last_transcode_key:
self.on_change()
# If a client doesn't register stop events (I'm looking at you PHT!) check if the ratingKey has changed
else:
# Manually stop and start
# Set force_stop so that we don't overwrite our last viewOffset
self.on_stop(force_stop=True)
self.on_start()
# Monitor if the stream has reached the watch percentage for notifications
# The only purpose of this is for notifications
if not db_session['watched'] and this_state != 'buffering':
progress_percent = helpers.get_percent(self.timeline['viewOffset'], db_session['duration'])
watched_percent = {'movie': plexpy.CONFIG.MOVIE_WATCHED_PERCENT,
'episode': plexpy.CONFIG.TV_WATCHED_PERCENT,
'track': plexpy.CONFIG.MUSIC_WATCHED_PERCENT,
'clip': plexpy.CONFIG.TV_WATCHED_PERCENT
}
if progress_percent >= watched_percent.get(db_session['media_type'], 101):
logger.debug(u"Tautulli ActivityHandler :: Session %s watched."
% str(self.get_session_key()))
ap.set_watched(session_key=self.get_session_key())
watched_notifiers = notification_handler.get_notify_state_enabled(
session=db_session, notify_action='on_watched', notified=False)
for d in watched_notifiers:
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(),
'notifier_id': d['notifier_id'],
'notify_action': 'on_watched'})
else:
# We don't have this session in our table yet, start a new one.
if this_state != 'buffering':
self.on_start()
# Schedule a callback to force stop a stale stream 5 minutes later
schedule_callback('session_key-{}'.format(self.get_session_key()),
func=force_stop_stream, args=[self.get_session_key()], minutes=5)
class TimelineHandler(object):
def __init__(self, timeline):
self.timeline = timeline
def is_item(self):
if 'itemID' in self.timeline:
return True
return False
def get_rating_key(self):
if self.is_item():
return int(self.timeline['itemID'])
return None
def get_metadata(self):
pms_connect = pmsconnect.PmsConnect()
metadata = pms_connect.get_metadata_details(self.get_rating_key())
if metadata:
return metadata
return None
# This function receives events from our websocket connection
def process(self):
if self.is_item():
global RECENTLY_ADDED_QUEUE
rating_key = self.get_rating_key()
media_types = {1: 'movie',
2: 'show',
3: 'season',
4: 'episode',
8: 'artist',
9: 'album',
10: 'track'}
identifier = self.timeline.get('identifier')
state_type = self.timeline.get('state')
media_type = media_types.get(self.timeline.get('type'))
section_id = self.timeline.get('sectionID', 0)
title = self.timeline.get('title', 'Unknown')
metadata_state = self.timeline.get('metadataState')
media_state = self.timeline.get('mediaState')
queue_size = self.timeline.get('queueSize')
# Return if it is not a library event (i.e. DVR EPG event)
if identifier != 'com.plexapp.plugins.library':
return
# Add a new media item to the recently added queue
if media_type and section_id > 0 and \
((state_type == 0 and metadata_state == 'created')): # or \
#(plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_UPGRADE and state_type in (1, 5) and \
#media_state == 'analyzing' and queue_size is None)):
if media_type in ('episode', 'track'):
metadata = self.get_metadata()
if metadata:
grandparent_rating_key = int(metadata['grandparent_rating_key'])
parent_rating_key = int(metadata['parent_rating_key'])
grandparent_set = RECENTLY_ADDED_QUEUE.get(grandparent_rating_key, set())
grandparent_set.add(parent_rating_key)
RECENTLY_ADDED_QUEUE[grandparent_rating_key] = grandparent_set
parent_set = RECENTLY_ADDED_QUEUE.get(parent_rating_key, set())
parent_set.add(rating_key)
RECENTLY_ADDED_QUEUE[parent_rating_key] = parent_set
RECENTLY_ADDED_QUEUE[rating_key] = set([grandparent_rating_key])
logger.debug(u"Tautulli TimelineHandler :: Library item '%s' (%s, grandparent %s) added to recently added queue."
% (title, str(rating_key), str(grandparent_rating_key)))
# Schedule a callback to clear the recently added queue
schedule_callback('rating_key-{}'.format(grandparent_rating_key), func=clear_recently_added_queue,
args=[grandparent_rating_key], seconds=plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_DELAY)
elif media_type in ('season', 'album'):
metadata = self.get_metadata()
if metadata:
parent_rating_key = int(metadata['parent_rating_key'])
parent_set = RECENTLY_ADDED_QUEUE.get(parent_rating_key, set())
parent_set.add(rating_key)
RECENTLY_ADDED_QUEUE[parent_rating_key] = parent_set
logger.debug(u"Tautulli TimelineHandler :: Library item '%s' (%s , parent %s) added to recently added queue."
% (title, str(rating_key), str(parent_rating_key)))
# Schedule a callback to clear the recently added queue
schedule_callback('rating_key-{}'.format(parent_rating_key), func=clear_recently_added_queue,
args=[parent_rating_key], seconds=plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_DELAY)
else:
queue_set = RECENTLY_ADDED_QUEUE.get(rating_key, set())
RECENTLY_ADDED_QUEUE[rating_key] = queue_set
logger.debug(u"Tautulli TimelineHandler :: Library item '%s' (%s) added to recently added queue."
% (title, str(rating_key)))
# Schedule a callback to clear the recently added queue
schedule_callback('rating_key-{}'.format(rating_key), func=clear_recently_added_queue,
args=[rating_key], seconds=plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_DELAY)
# A movie, show, or artist is done processing
elif media_type in ('movie', 'show', 'artist') and section_id > 0 and \
state_type == 5 and metadata_state is None and queue_size is None and \
rating_key in RECENTLY_ADDED_QUEUE:
logger.debug(u"Tautulli TimelineHandler :: Library item '%s' (%s) done processing metadata."
% (title, str(rating_key)))
# An item was deleted, make sure it is removed from the queue
elif state_type == 9 and metadata_state == 'deleted':
if rating_key in RECENTLY_ADDED_QUEUE and not RECENTLY_ADDED_QUEUE[rating_key]:
logger.debug(u"Tautulli TimelineHandler :: Library item %s removed from recently added queue."
% str(rating_key))
del_keys(rating_key)
# Remove the callback if the item is removed
schedule_callback('rating_key-{}'.format(rating_key), remove_job=True)
def del_keys(key):
if isinstance(key, set):
for child_key in key:
del_keys(child_key)
elif key in RECENTLY_ADDED_QUEUE:
del_keys(RECENTLY_ADDED_QUEUE.pop(key))
def schedule_callback(id, func=None, remove_job=False, args=None, **kwargs):
if ACTIVITY_SCHED.get_job(id):
if remove_job:
ACTIVITY_SCHED.remove_job(id)
else:
ACTIVITY_SCHED.reschedule_job(
id, args=args, trigger=DateTrigger(
run_date=datetime.datetime.now() + datetime.timedelta(**kwargs)))
elif not remove_job:
ACTIVITY_SCHED.add_job(
func, args=args, id=id, trigger=DateTrigger(
run_date=datetime.datetime.now() + datetime.timedelta(**kwargs)))
def force_stop_stream(session_key):
ap = activity_processor.ActivityProcessor()
session = ap.get_session_by_key(session_key=session_key)
row_id = ap.write_session_history(session=session)
if row_id:
# If session is written to the database successfully, remove the session from the session table
logger.info(u"Tautulli ActivityHandler :: Removing stale stream with sessionKey %s ratingKey %s from session queue"
% (session['session_key'], session['rating_key']))
ap.delete_session(row_id=row_id)
delete_metadata_cache(session_key)
else:
session['write_attempts'] += 1
if session['write_attempts'] < plexpy.CONFIG.SESSION_DB_WRITE_ATTEMPTS:
logger.warn(u"Tautulli ActivityHandler :: Failed to write stream with sessionKey %s ratingKey %s to the database. " \
"Will try again in 30 seconds. Write attempt %s."
% (session['session_key'], session['rating_key'], str(session['write_attempts'])))
ap.increment_write_attempts(session_key=session_key)
# Reschedule for 30 seconds later
schedule_callback('session_key-{}'.format(session_key), func=force_stop_stream,
args=[session_key], seconds=30)
else:
logger.warn(u"Tautulli ActivityHandler :: Failed to write stream with sessionKey %s ratingKey %s to the database. " \
"Removing session from the database. Write attempt %s."
% (session['session_key'], session['rating_key'], str(session['write_attempts'])))
logger.info(u"Tautulli ActivityHandler :: Removing stale stream with sessionKey %s ratingKey %s from session queue"
% (session['session_key'], session['rating_key']))
ap.delete_session(session_key=session_key)
delete_metadata_cache(session_key)
def clear_recently_added_queue(rating_key):
child_keys = RECENTLY_ADDED_QUEUE[rating_key]
if plexpy.CONFIG.NOTIFY_GROUP_RECENTLY_ADDED_GRANDPARENT and len(child_keys) > 1:
on_created(rating_key, child_keys=child_keys)
elif child_keys:
for child_key in child_keys:
grandchild_keys = RECENTLY_ADDED_QUEUE.get(child_key, [])
if plexpy.CONFIG.NOTIFY_GROUP_RECENTLY_ADDED_PARENT and len(grandchild_keys) > 1:
on_created(child_key, child_keys=grandchild_keys)
elif grandchild_keys:
for grandchild_key in grandchild_keys:
on_created(grandchild_key)
else:
on_created(child_key)
else:
on_created(rating_key)
# Remove all keys
del_keys(rating_key)
def on_created(rating_key, **kwargs):
logger.debug(u"Tautulli TimelineHandler :: Library item %s added to Plex." % str(rating_key))
pms_connect = pmsconnect.PmsConnect()
metadata = pms_connect.get_metadata_details(rating_key)
if metadata:
notify = True
# now = int(time.time())
#
# if helpers.cast_to_int(metadata['added_at']) < now - 86400: # Updated more than 24 hours ago
# logger.debug(u"Tautulli TimelineHandler :: Library item %s added more than 24 hours ago. Not notifying."
# % str(rating_key))
# notify = False
data_factory = datafactory.DataFactory()
if 'child_keys' not in kwargs:
if data_factory.get_recently_added_item(rating_key):
logger.debug(u"Tautulli TimelineHandler :: Library item %s added already. Not notifying again."
% str(rating_key))
notify = False
if notify:
data = {'timeline_data': metadata, 'notify_action': 'on_created'}
data.update(kwargs)
plexpy.NOTIFY_QUEUE.put(data)
all_keys = [rating_key]
if 'child_keys' in kwargs:
all_keys.extend(kwargs['child_keys'])
for key in all_keys:
data_factory.set_recently_added_item(key)
logger.debug(u"Added %s items to the recently_added database table." % str(len(all_keys)))
else:
logger.error(u"Tautulli TimelineHandler :: Unable to retrieve metadata for rating_key %s" % str(rating_key))
def delete_metadata_cache(session_key):
try:
os.remove(os.path.join(plexpy.CONFIG.CACHE_DIR, 'session_metadata/metadata-sessionKey-%s.json' % session_key))
except IOError as e:
logger.error(u"Tautulli ActivityHandler :: Failed to remove metadata cache file (sessionKey %s): %s"
% (session_key, e))