Set the percentage for a music track to be considered as listened. Minimum 50, Maximum 95.
+
+
+
+
+
+
+
+
Decide whether to use end credits markers to determine the 'watched' state of video items. When markers are not available the selected threshold percentage will be used.
+
diff --git a/plexpy/config.py b/plexpy/config.py
index 544cf79a..f094e88e 100644
--- a/plexpy/config.py
+++ b/plexpy/config.py
@@ -199,6 +199,7 @@ _CONFIG_DEFINITIONS = {
'UPGRADE_FLAG': (int, 'Advanced', 0),
'VERBOSE_LOGS': (int, 'Advanced', 1),
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
+ 'WATCHED_MARKER': (int, 'Monitoring', 3),
'WEBSOCKET_MONITOR_PING_PONG': (int, 'Advanced', 0),
'WEBSOCKET_CONNECTION_ATTEMPTS': (int, 'Advanced', 5),
'WEBSOCKET_CONNECTION_TIMEOUT': (int, 'Advanced', 5),
@@ -298,7 +299,8 @@ SETTINGS = [
'REFRESH_USERS_INTERVAL',
'SHOW_ADVANCED_SETTINGS',
'TIME_FORMAT',
- 'TV_WATCHED_PERCENT'
+ 'TV_WATCHED_PERCENT',
+ 'WATCHED_MARKER'
]
CHECKED_SETTINGS = [
From b2b12044e3342ccc77dbbaa9c90771e8e13a38dd Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 20 Feb 2023 17:14:35 -0800
Subject: [PATCH 03/19] Trigger on_watched based on credits markers
---
plexpy/activity_handler.py | 92 +++++++++++++++++++++++++-------------
plexpy/helpers.py | 40 +++++++++++++++++
2 files changed, 101 insertions(+), 31 deletions(-)
diff --git a/plexpy/activity_handler.py b/plexpy/activity_handler.py
index 8933c1ac..851372d5 100644
--- a/plexpy/activity_handler.py
+++ b/plexpy/activity_handler.py
@@ -110,11 +110,13 @@ class ActivityHandler(object):
self.set_session_state()
self.get_db_session()
- def set_session_state(self):
- self.ap.set_session_state(session_key=self.session_key,
- state=self.state,
- view_offset=self.view_offset,
- stopped=helpers.timestamp())
+ def set_session_state(self, view_offset=None):
+ self.ap.set_session_state(
+ session_key=self.session_key,
+ state=self.state,
+ view_offset=view_offset or self.view_offset,
+ stopped=helpers.timestamp()
+ )
def put_notification(self, notify_action, **kwargs):
notification = {'stream_data': self.db_session.copy(), 'notify_action': notify_action}
@@ -246,26 +248,34 @@ class ActivityHandler(object):
self.put_notification('on_change')
def on_intro(self, marker):
- if self.get_live_session():
- logger.debug("Tautulli ActivityHandler :: Session %s reached intro marker." % str(self.session_key))
+ logger.debug("Tautulli ActivityHandler :: Session %s reached intro marker." % str(self.session_key))
- self.put_notification('on_intro', marker=marker)
+ self.set_session_state(view_offset=marker['start_time_offset'])
+
+ self.put_notification('on_intro', marker=marker)
def on_commercial(self, marker):
- if self.get_live_session():
- logger.debug("Tautulli ActivityHandler :: Session %s reached commercial marker." % str(self.session_key))
+ logger.debug("Tautulli ActivityHandler :: Session %s reached commercial marker." % str(self.session_key))
- self.put_notification('on_commercial', marker=marker)
+ self.set_session_state(view_offset=marker['start_time_offset'])
+
+ self.put_notification('on_commercial', marker=marker)
def on_credits(self, marker):
- if self.get_live_session():
- logger.debug("Tautulli ActivityHandler :: Session %s reached credits marker." % str(self.session_key))
+ logger.debug("Tautulli ActivityHandler :: Session %s reached credits marker." % str(self.session_key))
- self.put_notification('on_credits', marker=marker)
+ self.set_session_state(view_offset=marker['start_time_offset'])
- def on_watched(self):
+ self.put_notification('on_credits', marker=marker)
+
+ def on_watched(self, marker=None):
logger.debug("Tautulli ActivityHandler :: Session %s watched." % str(self.session_key))
+ if marker:
+ self.set_session_state(view_offset=marker['start_time_offset'])
+ else:
+ self.update_db_session()
+
watched_notifiers = notification_handler.get_notify_state_enabled(
session=self.db_session, notify_action='on_watched', notified=False)
@@ -368,38 +378,58 @@ class ActivityHandler(object):
if self.db_session['marker'] != marker_idx:
self.ap.set_marker(session_key=self.session_key, marker_idx=marker_idx, marker_type=marker['type'])
- callback_func = getattr(self, 'on_{}'.format(marker['type']))
if self.view_offset < marker['start_time_offset']:
# Schedule a callback for the exact offset of the marker
schedule_callback(
'session_key-{}-marker-{}'.format(self.session_key, marker_idx),
- func=callback_func,
+ func=self._marker_callback,
args=[marker],
milliseconds=marker['start_time_offset'] - self.view_offset
)
else:
- callback_func(marker)
+ self._marker_callback(marker)
break
if not marker_flag:
self.ap.set_marker(session_key=self.session_key, marker_idx=0)
- def check_watched(self):
- # Monitor if the stream has reached the watch percentage for notifications
- if not self.db_session['watched'] and self.timeline['state'] != 'buffering':
- progress_percent = helpers.get_percent(self.timeline['viewOffset'], self.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
- }
+ def _marker_callback(self, marker):
+ if self.get_live_session():
+ # Reset ActivityProcessor object for new database thread
+ self.ap = activity_processor.ActivityProcessor()
- if progress_percent >= watched_percent.get(self.db_session['media_type'], 101):
- self.ap.set_watched(session_key=self.session_key)
- self.on_watched()
+ if marker['type'] == 'intro':
+ self.on_intro(marker)
+ elif marker['type'] == 'commercial':
+ self.on_commercial(marker)
+ elif marker['type'] == 'credits':
+ self.on_credits(marker)
+
+ if not self.db_session['watched']:
+ if marker['final'] and plexpy.CONFIG.WATCHED_MARKER == 1:
+ self._marker_watched(marker)
+ elif marker['first'] and (plexpy.CONFIG.WATCHED_MARKER in (2, 3)):
+ self._marker_watched(marker)
+
+ def _marker_watched(self, marker):
+ if not self.db_session['watched']:
+ self._watched_callback(marker)
+
+ def check_watched(self):
+ if plexpy.CONFIG.WATCHED_MARKER == 1 or plexpy.CONFIG.WATCHED_MARKER == 2:
+ return
+
+ # Monitor if the stream has reached the watch percentage for notifications
+ if not self.db_session['watched'] and self.state != 'buffering' and helpers.check_watched(
+ self.db_session['media_type'], self.view_offset, self.db_session['duration']
+ ):
+ self._watched_callback()
+
+ def _watched_callback(self, marker=None):
+ self.ap.set_watched(session_key=self.session_key)
+ self.on_watched(marker)
class TimelineHandler(object):
diff --git a/plexpy/helpers.py b/plexpy/helpers.py
index b0995849..89b047fd 100644
--- a/plexpy/helpers.py
+++ b/plexpy/helpers.py
@@ -1733,3 +1733,43 @@ def short_season(title):
if title.startswith('Season ') and title[7:].isdigit():
return 'S%s' % title[7:]
return title
+
+
+def get_first_final_marker(markers):
+ first = None
+ final = None
+ for marker in markers:
+ if marker['first']:
+ first = marker
+ if marker['final']:
+ final = marker
+ return first, final
+
+
+def check_watched(media_type, view_offset, duration, marker_credits_first=None, marker_credits_final=None):
+ if isinstance(marker_credits_first, dict):
+ marker_credits_first = marker_credits_first['start_time_offset']
+ if isinstance(marker_credits_final, dict):
+ marker_credits_final = marker_credits_final['start_time_offset']
+
+ view_offset = cast_to_int(view_offset)
+ duration = cast_to_int(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
+ }
+ threshold = watched_percent.get(media_type, 0) / 100 * duration
+ if not threshold:
+ return False
+
+ if plexpy.CONFIG.WATCHED_MARKER == 1 and marker_credits_final:
+ return view_offset >= marker_credits_final
+ elif plexpy.CONFIG.WATCHED_MARKER == 2 and marker_credits_first:
+ return view_offset >= marker_credits_first
+ elif plexpy.CONFIG.WATCHED_MARKER == 3 and marker_credits_first:
+ return view_offset >= min(threshold, marker_credits_first)
+ else:
+ return view_offset >= threshold
From c5005c1ea9ec1575abd36fa51dfc9cb1d4dd8159 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 20 Feb 2023 16:36:31 -0800
Subject: [PATCH 04/19] Group watched history sessions based on credits markers
---
plexpy/activity_processor.py | 16 +++++++---------
1 file changed, 7 insertions(+), 9 deletions(-)
diff --git a/plexpy/activity_processor.py b/plexpy/activity_processor.py
index d55c6738..71b6e3e0 100644
--- a/plexpy/activity_processor.py
+++ b/plexpy/activity_processor.py
@@ -327,7 +327,7 @@ class ActivityProcessor(object):
# Get the last insert row id
last_id = self.db.last_insert_id()
new_session = prev_session = None
- prev_progress_percent = media_watched_percent = 0
+ watched = False
if session['live']:
# Check if we should group the session, select the last guid from the user
@@ -369,12 +369,11 @@ class ActivityProcessor(object):
'view_offset': result[1]['view_offset'],
'reference_id': result[1]['reference_id']}
- watched_percent = {'movie': plexpy.CONFIG.MOVIE_WATCHED_PERCENT,
- 'episode': plexpy.CONFIG.TV_WATCHED_PERCENT,
- 'track': plexpy.CONFIG.MUSIC_WATCHED_PERCENT
- }
- prev_progress_percent = helpers.get_percent(prev_session['view_offset'], session['duration'])
- media_watched_percent = watched_percent.get(session['media_type'], 0)
+ marker_first, marker_final = helpers.get_first_final_marker(metadata['markers'])
+ watched = helpers.check_watched(
+ session['media_type'], session['view_offset'], session['duration'],
+ marker_first, marker_final
+ )
query = 'UPDATE session_history SET reference_id = ? WHERE id = ? '
@@ -384,8 +383,7 @@ class ActivityProcessor(object):
# else set the reference_id to the new id
if prev_session is None and new_session is None:
args = [last_id, last_id]
- elif prev_progress_percent < media_watched_percent and \
- prev_session['view_offset'] <= new_session['view_offset'] or \
+ elif watched and prev_session['view_offset'] <= new_session['view_offset'] or \
session['live'] and prev_session['guid'] == new_session['guid']:
args = [prev_session['reference_id'], new_session['id']]
else:
From 928e1d4b5edb2adec3049e38cccf3717463f65c2 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 20 Feb 2023 16:37:37 -0800
Subject: [PATCH 05/19] History table watched status based on credits markers
---
plexpy/datafactory.py | 21 ++++++++++++++++-----
1 file changed, 16 insertions(+), 5 deletions(-)
diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py
index cf55a2c0..e0e9fdee 100644
--- a/plexpy/datafactory.py
+++ b/plexpy/datafactory.py
@@ -99,8 +99,9 @@ class DataFactory(object):
'MIN(started) AS started',
'MAX(stopped) AS stopped',
'SUM(CASE WHEN stopped > 0 THEN (stopped - started) ELSE 0 END) - \
- SUM(CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) AS duration',
+ SUM(CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) AS play_duration',
'SUM(CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) AS paused_counter',
+ 'session_history.view_offset',
'session_history.user_id',
'session_history.user',
'(CASE WHEN users.friendly_name IS NULL OR TRIM(users.friendly_name) = "" \
@@ -139,6 +140,9 @@ class DataFactory(object):
'MAX((CASE WHEN (view_offset IS NULL OR view_offset = "") THEN 0.1 ELSE view_offset * 1.0 END) / \
(CASE WHEN (session_history_metadata.duration IS NULL OR session_history_metadata.duration = "") \
THEN 1.0 ELSE session_history_metadata.duration * 1.0 END) * 100) AS percent_complete',
+ 'session_history_metadata.duration',
+ 'session_history_metadata.marker_credits_first',
+ 'session_history_metadata.marker_credits_final',
'session_history_media_info.transcode_decision',
'COUNT(*) AS group_count',
'GROUP_CONCAT(session_history.id) AS group_ids',
@@ -159,8 +163,9 @@ class DataFactory(object):
'started',
'stopped',
'SUM(CASE WHEN stopped > 0 THEN (stopped - started) ELSE (strftime("%s", "now") - started) END) - \
- SUM(CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) AS duration',
+ SUM(CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) AS play_duration',
'SUM(CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) AS paused_counter',
+ 'view_offset',
'user_id',
'user',
'(CASE WHEN friendly_name IS NULL OR TRIM(friendly_name) = "" \
@@ -198,6 +203,9 @@ class DataFactory(object):
'MAX((CASE WHEN (view_offset IS NULL OR view_offset = "") THEN 0.1 ELSE view_offset * 1.0 END) / \
(CASE WHEN (duration IS NULL OR duration = "") \
THEN 1.0 ELSE duration * 1.0 END) * 100) AS percent_complete',
+ 'duration',
+ 'NULL AS marker_credits_first',
+ 'NULL AS marker_credits_final',
'transcode_decision',
'NULL AS group_count',
'NULL AS group_ids',
@@ -262,7 +270,7 @@ class DataFactory(object):
item['user_thumb'] = users_lookup.get(item['user_id'])
- filter_duration += int(item['duration'])
+ filter_duration += int(item['play_duration'])
if item['media_type'] == 'episode' and item['parent_thumb']:
thumb = item['parent_thumb']
@@ -274,7 +282,10 @@ class DataFactory(object):
if item['live']:
item['percent_complete'] = 100
- if item['percent_complete'] >= watched_percent[item['media_type']]:
+ if helpers.check_watched(
+ item['media_type'], item['view_offset'], item['duration'],
+ item['marker_credits_first'], item['marker_credits_final']
+ ):
watched_status = 1
elif item['percent_complete'] >= watched_percent[item['media_type']] / 2.0:
watched_status = 0.5
@@ -297,7 +308,7 @@ class DataFactory(object):
'date': item['date'],
'started': item['started'],
'stopped': item['stopped'],
- 'duration': item['duration'],
+ 'duration': item['play_duration'],
'paused_counter': item['paused_counter'],
'user_id': item['user_id'],
'user': item['user'],
From 2a1bf7847b32f01059d990f564ce89777ed7751e Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 20 Feb 2023 18:35:55 -0800
Subject: [PATCH 06/19] Last watched statistics card based on credits markers
---
plexpy/datafactory.py | 60 ++++++++++++++++++++++++++++++++++---------
1 file changed, 48 insertions(+), 12 deletions(-)
diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py
index e0e9fdee..e51b8a46 100644
--- a/plexpy/datafactory.py
+++ b/plexpy/datafactory.py
@@ -382,10 +382,6 @@ class DataFactory(object):
if user_id:
where_id += 'AND session_history.user_id = %s ' % user_id
- movie_watched_percent = plexpy.CONFIG.MOVIE_WATCHED_PERCENT
- tv_watched_percent = plexpy.CONFIG.TV_WATCHED_PERCENT
- music_watched_percent = plexpy.CONFIG.MUSIC_WATCHED_PERCENT
-
group_by = 'session_history.reference_id' if grouping else 'session_history.id'
sort_type = 'total_duration' if stats_type == 'duration' else 'total_plays'
@@ -919,6 +915,43 @@ class DataFactory(object):
'rows': session.mask_session_info(top_platform, mask_metadata=False)})
elif stat == 'last_watched':
+
+ movie_watched_percent = plexpy.CONFIG.MOVIE_WATCHED_PERCENT
+ tv_watched_percent = plexpy.CONFIG.TV_WATCHED_PERCENT
+
+ if plexpy.CONFIG.WATCHED_MARKER == 1:
+ watched_threshold = (
+ '(CASE WHEN shm.marker_credits_final IS NULL '
+ 'THEN sh._duration * (CASE WHEN sh.media_type = "movie" THEN %d ELSE %d END) / 100.0 '
+ 'ELSE shm.marker_credits_final END) '
+ 'AS watched_threshold'
+ ) % (movie_watched_percent, tv_watched_percent)
+ watched_where = '_view_offset >= watched_threshold'
+ elif plexpy.CONFIG.WATCHED_MARKER == 2:
+ watched_threshold = (
+ '(CASE WHEN shm.marker_credits_first IS NULL '
+ 'THEN sh._duration * (CASE WHEN sh.media_type = "movie" THEN %d ELSE %d END) / 100.0 '
+ 'ELSE shm.marker_credits_first END) '
+ 'AS watched_threshold'
+ ) % (movie_watched_percent, tv_watched_percent)
+ watched_where = '_view_offset >= watched_threshold'
+ elif plexpy.CONFIG.WATCHED_MARKER == 3:
+ watched_threshold = (
+ 'MIN('
+ '(CASE WHEN shm.marker_credits_first IS NULL '
+ 'THEN sh._duration * (CASE WHEN sh.media_type = "movie" THEN %d ELSE %d END) / 100.0 '
+ 'ELSE shm.marker_credits_first END), '
+ 'sh._duration * (CASE WHEN sh.media_type = "movie" THEN %d ELSE %d END) / 100.0) '
+ 'AS watched_threshold'
+ ) % (movie_watched_percent, tv_watched_percent, movie_watched_percent, tv_watched_percent)
+ watched_where = '_view_offset >= watched_threshold'
+ else:
+ watched_threshold = 'NULL AS watched_threshold'
+ watched_where = (
+ 'sh.media_type == "movie" AND percent_complete >= %d '
+ 'OR sh.media_type == "episode" AND percent_complete >= %d'
+ ) % (movie_watched_percent, tv_watched_percent)
+
last_watched = []
try:
query = 'SELECT sh.id, shm.title, shm.grandparent_title, shm.full_title, shm.year, ' \
@@ -929,22 +962,25 @@ class DataFactory(object):
'(CASE WHEN u.friendly_name IS NULL OR TRIM(u.friendly_name) = ""' \
' THEN u.username ELSE u.friendly_name END) ' \
' AS friendly_name, ' \
- 'MAX(sh.started) AS last_watch, ' \
- '((CASE WHEN sh.view_offset IS NULL THEN 0.1 ELSE sh.view_offset * 1.0 END) / ' \
- ' (CASE WHEN shm.duration IS NULL THEN 1.0 ELSE shm.duration * 1.0 END) * 100) ' \
- ' AS percent_complete ' \
- 'FROM (SELECT *, MAX(id) FROM session_history ' \
+ 'MAX(sh.started) AS last_watch, sh._view_offset, sh._duration, ' \
+ '(sh._view_offset / sh._duration * 100) AS percent_complete, ' \
+ '%s ' \
+ 'FROM (SELECT *, MAX(session_history.id), ' \
+ ' (CASE WHEN view_offset IS NULL THEN 0.1 ELSE view_offset * 1.0 END) AS _view_offset, ' \
+ ' (CASE WHEN duration IS NULL THEN 1.0 ELSE duration * 1.0 END) AS _duration ' \
+ ' FROM session_history ' \
+ ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \
' WHERE session_history.stopped >= %s ' \
' AND (session_history.media_type = "movie" ' \
' OR session_history.media_type = "episode") %s ' \
' GROUP BY %s) AS sh ' \
'JOIN session_history_metadata AS shm ON shm.id = sh.id ' \
'LEFT OUTER JOIN users AS u ON sh.user_id = u.user_id ' \
- 'WHERE sh.media_type == "movie" AND percent_complete >= %s ' \
- ' OR sh.media_type == "episode" AND percent_complete >= %s ' \
+ 'WHERE %s ' \
'GROUP BY sh.id ' \
'ORDER BY last_watch DESC ' \
- 'LIMIT %s OFFSET %s' % (timestamp, where_id, group_by, movie_watched_percent, tv_watched_percent,
+ 'LIMIT %s OFFSET %s' % (watched_threshold,
+ timestamp, where_id, group_by, watched_where,
stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
From ebe570d42f9a2e70a7bdbb7db2cdcf8577fc70f5 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Tue, 21 Feb 2023 11:12:56 -0800
Subject: [PATCH 07/19] Allow setting a custom Pushover sound
* Closes #2005
---
data/interfaces/default/css/tautulli.css | 2 -
.../interfaces/default/newsletter_config.html | 4 ++
data/interfaces/default/notifier_config.html | 10 +++
plexpy/newsletters.py | 3 +-
plexpy/notifiers.py | 69 ++++++++++---------
5 files changed, 54 insertions(+), 34 deletions(-)
diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css
index ac99ae76..5f1d90a0 100644
--- a/data/interfaces/default/css/tautulli.css
+++ b/data/interfaces/default/css/tautulli.css
@@ -79,7 +79,6 @@ select.form-control {
color: #eee !important;
border: 0px solid #444 !important;
background: #555 !important;
- padding: 1px 2px;
transition: background-color .3s;
}
.selectize-control.form-control .selectize-input {
@@ -87,7 +86,6 @@ select.form-control {
align-items: center;
flex-wrap: wrap;
margin-bottom: 4px;
- padding-left: 5px;
}
.selectize-control.form-control.selectize-pms-ip .selectize-input {
padding-left: 12px !important;
diff --git a/data/interfaces/default/newsletter_config.html b/data/interfaces/default/newsletter_config.html
index dc6de294..10583707 100644
--- a/data/interfaces/default/newsletter_config.html
+++ b/data/interfaces/default/newsletter_config.html
@@ -142,8 +142,10 @@