diff --git a/killstream/kill_stream.py b/killstream/kill_stream.py index 904d82f..9a26e3f 100644 --- a/killstream/kill_stream.py +++ b/killstream/kill_stream.py @@ -1,23 +1,25 @@ """ Description: Use conditions to kill a stream -Author: Blacktwin +Author: Blacktwin, Arcanemagus, samwiseg00 Requires: requests -Enabling Scripts in Tautulli: -Taultulli > Settings > Notification Agents > Add a Notification Agent > Script +Adding the script to Tautulli: +Taultulli > Settings > Notification Agents > Add a new notification agent > + Script Configuration: Taultulli > Settings > Notification Agents > New Script > Configuration: - Script Name: kill_stream - Set Script Timeout: {timeout} + Script Folder: /path/to/your/scripts + Script File: ./kill_stream.py (Should be selectable in a dropdown list) + Script Timeout: {timeout} Description: Kill stream(s) Save Triggers: Taultulli > Settings > Notification Agents > New Script > Triggers: - Check: {trigger} + Check: Playback Start and/or Playback Pause Save Conditions: @@ -31,17 +33,20 @@ Taultulli > Settings > Notification Agents > New Script > Script Arguments: Select: Playback Start, Playback Pause Arguments: --jbop SELECTOR --userId {user_id} --username {username} - --sessionId {session_id} --killMessage Your message here. No quotes. --notify notifierID + --sessionId {session_id} --notify notifierID + --interval 30 --limit 1200 + --killMessage Your message here. No quotes. Save Close - """ import requests import argparse import sys import os +from time import sleep +from datetime import datetime TAUTULLI_URL = '' TAUTULLI_APIKEY = '' @@ -49,19 +54,35 @@ TAUTULLI_URL = os.getenv('TAUTULLI_URL', TAUTULLI_URL) TAUTULLI_APIKEY = os.getenv('TAUTULLI_APIKEY', TAUTULLI_APIKEY) SUBJECT_TEXT = "Tautulli has killed a stream." -BODY_TEXT = "Killed {user}'s stream. Reason: {message}." +BODY_TEXT = "Killed session ID '{id}'. Reason: {message}" +BODY_TEXT_USER = "Killed {user}'s stream. Reason: {message}." sess = requests.Session() # Ignore verifying the SSL certificate -sess.verify = False # '/path/to/certfile' +sess.verify = False # '/path/to/certfile' # If verify is set to a path to a directory, -# the directory must have been processed using the c_rehash utility supplied with OpenSSL. +# the directory must have been processed using the c_rehash utility supplied +# with OpenSSL. +if sess.verify is False: + # Disable the warning that the request is insecure, we know that... + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -SELECTOR = ['stream', 'allStreams'] +SELECTOR = ['stream', 'allStreams', 'paused'] def send_notification(subject_text, body_text, notifier_id): - # Send the notification through Tautulli + """Send a notification through Tautulli + + Parameters + ---------- + subject_text : str + The text to use for the subject line of the message. + body_text : str + The text to use for the body of the notification. + notifier_id : int + Tautulli Notification Agent ID to send the notification to. + """ payload = {'apikey': TAUTULLI_APIKEY, 'cmd': 'notify', 'notifier_id': notifier_id, @@ -69,7 +90,7 @@ def send_notification(subject_text, body_text, notifier_id): 'body': body_text} try: - r = requests.post(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) + r = sess.post(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) response = r.json() if response['response']['result'] == 'success': @@ -77,30 +98,69 @@ def send_notification(subject_text, body_text, notifier_id): else: raise Exception(response['response']['message']) except Exception as e: - sys.stderr.write("Tautulli API 'notify' request failed: {0}.".format(e)) + sys.stderr.write( + "Tautulli API 'notify' request failed: {0}.".format(e)) return None -def get_activity(user_id): - # Get the current activity on the PMS. +def get_activity(): + """Get the current activity on the PMS. + + Returns + ------- + list + The current active sessions on the Plex server. + """ payload = {'apikey': TAUTULLI_APIKEY, 'cmd': 'get_activity'} try: - req = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) + req = sess.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) response = req.json() res_data = response['response']['data']['sessions'] - user_streams = [d['session_id'] for d in res_data if d['user_id'] == user_id] - return user_streams + return res_data except Exception as e: - sys.stderr.write("Tautulli API 'get_activity' request failed: {0}.".format(e)) + sys.stderr.write( + "Tautulli API 'get_activity' request failed: {0}.".format(e)) pass -def terminate_session(session_id, message): - # Stop a streaming session. +def get_user_session_ids(user_id): + """Get current session IDs for a specific user. + + Parameters + ---------- + user_id : int + The ID of the user to grab sessions for. + + Returns + ------- + list + The active session IDs for the specific user ID. + + """ + sessions = get_activity() + user_streams = [s['session_id'] + for s in sessions if s['user_id'] == user_id] + return user_streams + + +def terminate_session(session_id, message, notifier=None, username=None): + """Stop a streaming session. + + Parameters + ---------- + session_id : str + The session ID of the stream to terminate. + message : str + The message to display to the user when terminating a stream. + notifier : int + Notification agent ID to send a message to (the default is None). + username : str + The username for the terminated session (the default is None). + """ payload = {'apikey': TAUTULLI_APIKEY, 'cmd': 'terminate_session', 'session_id': session_id, @@ -111,16 +171,79 @@ def terminate_session(session_id, message): response = req.json() if response['response']['result'] == 'success': - sys.stdout.write("Successfully killed Plex session: {0}.".format(session_id)) + sys.stdout.write( + "Successfully killed Plex session: {0}.".format(session_id)) + if notifier: + if username: + body = BODY_TEXT_USER.format(user=username, + message=message) + else: + body = BODY_TEXT.format(id=session_id, message=message) + send_notification(SUBJECT_TEXT, body, notifier) else: raise Exception(response['response']['message']) except Exception as e: - sys.stderr.write("Tautulli API 'terminate_session' request failed: {0}.".format(e)) + sys.stderr.write( + "Tautulli API 'terminate_session' request failed: {0}.".format(e)) return None +def terminate_long_pause(session_id, message, limit, interval, notify=None): + """Kills the session if it is paused for longer than seconds. + + Parameters + ---------- + session_id : str + The session id of the session to monitor. + message : str + The message to use if the stream is terminated. + limit : int + The number of seconds the session is allowed to remain paused before it + is terminated. + interval : int + The amount of time to wait between checks of the session state. + notify : int + Tautulli Notification Agent ID to send a notification to on killing a + stream. + """ + start = datetime.now() + checked_time = 0 + # Continue checking 2 intervals past the allowed limit in order to + # account for system variances. + check_limit = limit + (interval * 2) + + while checked_time < check_limit: + sessions = get_activity() + found_session = False + + for session in sessions: + if session['session_id'] == session_id: + found_session = True + state = session['state'] + now = datetime.now() + checked_time = (now - start).total_seconds() + + if state == 'paused': + if checked_time >= limit: + terminate_session(session_id, message, notify) + return + else: + sleep(interval) + elif state == 'playing' or state == 'buffering': + sys.stdout.write( + "Session '{}' has resumed, ".format(session_id) + + "stopping monitoring.") + return + if not found_session: + sys.stdout.write( + "Session '{}' is no longer active ".format(session_id) + + "on the server, stopping monitoring.") + return + + if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Killing Plex streams from Tautulli.") + parser = argparse.ArgumentParser( + description="Killing Plex streams from Tautulli.") parser.add_argument('--jbop', required=True, choices=SELECTOR, help='Kill selector.\nChoices: (%(choices)s)') parser.add_argument('--userId', type=int, @@ -129,10 +252,15 @@ if __name__ == "__main__": help='The username of the person streaming.') parser.add_argument('--sessionId', required=True, help='The unique identifier for the stream.') + parser.add_argument('--notify', type=int, + help='Notification Agent ID number to Agent to send ' + + 'notification.') + parser.add_argument('--limit', type=int, default=(20 * 60), # 20 minutes + help='The time session is allowed to remain paused.') + parser.add_argument('--interval', type=int, default=30, + help='The seconds between paused session checks.') parser.add_argument('--killMessage', nargs='+', help='Message to send to user whose stream is killed.') - parser.add_argument('--notify', type=int, - help='Notification Agent ID number to Agent to send notification.') opts = parser.parse_args() @@ -142,12 +270,11 @@ if __name__ == "__main__": message = '' if opts.jbop == 'stream': - terminate_session(opts.sessionId, message) + terminate_session(opts.sessionId, message, opts.notify, opts.username) elif opts.jbop == 'allStreams': - streams = get_activity(opts.userId) + streams = get_user_session_ids(opts.userId) for session_id in streams: - terminate_session(session_id, message) - - if opts.notify: - BODY_TEXT = BODY_TEXT.format(user=opts.username, message=message) - send_notification(SUBJECT_TEXT, BODY_TEXT, opts.notify) + terminate_session(session_id, message, opts.notify, opts.username) + elif opts.jbop == 'paused': + terminate_long_pause(opts.sessionId, message, opts.limit, + opts.interval, opts.notify) diff --git a/killstream/readme.md b/killstream/readme.md index 93067b9..5bf0465 100644 --- a/killstream/readme.md +++ b/killstream/readme.md @@ -1,81 +1,166 @@ -## README +# README -Killing streams is a Plex Pass only feature. So these scripts will only work for Plex Pass users. +Killing streams is a Plex Pass only feature. So these scripts will **only** work for Plex Pass users. +## `kill_stream.py` examples: +### Kill transcodes -### Kill_stream.py examples: +Triggers: Playback Start +Conditions: \[ `Transcode Decision` | `is` | `transcode` \] -#### Arguments examples: +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage Transcoding streams are not allowed. +``` -Kill the one offending stream with a custom message and send notification to notfication agent ID 1 - - --jbop stream --userId {user_id} --username {username} --sessionId {session_id} --killMessage You did something wrong. --notify 1 +### Kill non-local streams paused for a long time -Kill all the offending users streams with a custom message and send notification to notfication agent ID 1 - - --jbop allStreams --userId {user_id} --username {username} --sessionId {session_id} --killMessage You did something wrong. --notify 1 +_The default values will kill anything paused for over 20 minutes, checking every 30 seconds._ -Kill the one offending stream with default message +Script Timeout: 0 _**Important!**_ +Triggers: Playback Paused +Conditions: \[ `Stream Local` | `is not` | `1` \] - --jbop stream --userId {user_id} --username {username} --sessionId {session_id} +Arguments: +``` +--jbop paused --sessionId {session_id} --killMessage Your stream was paused for over 20 minutes and has been automatically stopped for you. +``` +### Kill streams paused for a custom time -#### Condition Examples: +_This is an example of customizing the paused stream monitoring to check every 15 seconds, and kill any stream paused for over 5 minutes._ -Kill transcodes: +Script Timeout: 0 _**Important!**_ +Triggers: Playback Paused - Set Trigger: Playback Start - Set Conditions: [ {Transcode Decision} | {is} | {transcode} ] +Arguments: +``` +--jbop paused --interval 15 --limit 300 --sessionId {session_id} --killMessage Your stream was paused for over 5 minutes and has been automatically stopped for you. +``` -Kill paused transcodes: - - Set Trigger: Playback Paused - Set Conditions: [ {Transcode Decision} | {is} | {transcode} ] +### Kill paused transcodes -Limit User stream count, kill last stream: - - Set Trigger: Playback Start - Set Conditions: [ {User Streams} | {is greater than} | {3} ] +Triggers: Playback Paused +Conditions: \[ `Transcode Decision` | `is` | `transcode` \] -IP Whitelist: - - Set Trigger: Playback Start - Set Conditions: [ {IP Address} | {is not} | {192.168.0.100 or 192.168.0.101} ] +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage Paused streams are automatically stopped. +``` -Kill by platform: - - Set Trigger: Playback Start - Set Conditions: [ {Platform} | {is} | {Roku or Android} ] +### Limit User stream count, kill last stream -Kill transcode by library: - - Set Trigger: Playback Start - Set Conditions: [ {Transcode Decision} | {is} | {transcode} ] - [ {Library Name} | {is} | {4K Movies} ] +Triggers: Playback Start +Conditions: \[ `User Streams` | `is greater than` | `3` \] -Kill transcode by original resolution: - - Set Trigger: Playback Start - Set Conditions: [ {Transcode Decision} | {is} | {transcode} ] - [ {Video Resolution} | {is} | {1080 or 720}] +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage You are only allowed 3 streams. +``` -Kill transcode by bitrate: - - Set Trigger: Playback Start - Set Conditions: [ {Transcode Decision} | {is} | {transcode} ] - [ {Bitrate} | {is greater than} | {4000} ] +### IP Whitelist -Kill by hours of the day: - - Set Trigger: Playback Start - Set Conditions: [ {Timestamp} | {begins with} | {09 or 10} ] - # Killing any streams from 9am to 11am +Triggers: Playback Start +Conditions: \[ `IP Address` | `is not` | `192.168.0.100 or 192.168.0.101` \] -Kill non local streams: - - Set Trigger: Playback Start - Set Conditions: [ {Stream location} | {is} | {wan} ] - or - Set Conditions: [ {Stream location} | {is not} | {lan} ] +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage {ip_address} is not allowed to access {server_name}. +``` +### Kill by platform + +Triggers: Playback Start +Conditions: \[ `Platform` | `is` | `Roku or Android` \] + +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage {platform} is not allowed on {server_name}. +``` + +### Kill transcode by library + +Triggers: Playback Start +Conditions: +* \[ `Transcode Decision` | `is` | `transcode` \] +* \[ `Library Name` | `is` | `4K Movies` \] + +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage Transcoding streams are not allowed from the 4K Movies library. +``` + +### Kill transcode by original resolution + +Triggers: Playback Start +Conditions: +* \[ `Transcode Decision` | `is` | `transcode` \] +* \[ `Video Resolution` | `is` | `1080 or 720`\] + +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage Transcoding streams are not allowed for {stream_video_resolution}p streams. +``` + +### Kill transcode by bitrate + +Triggers: Playback Start +Conditions: +* \[ `Transcode Decision` | `is` | `transcode` \] +* \[ `Bitrate` | `is greater than` | `4000` \] + +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage Transcoding streams are not allowed from over 4 Mbps (Yours: {stream_bitrate}). +``` + +### Kill by hours of the day + +_Kills any streams during 9 AM to 10 AM._ + +Triggers: Playback Start +Conditions: \[ `Timestamp` | `begins with` | `09 or 10` \] +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage {server_name} is unavailable between 9 and 10 AM. +``` + +### Kill non local streams + +Triggers: Playback Start +Conditions: \[ `Stream Local` | `is not` | `1` \] +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --killMessage {server_name} only allows local streams. +``` + +### Kill transcodes and send a notification to agent 1 + +Triggers: Playback Start +Conditions: \[ `Transcode Decision` | `is` | `transcode` \] + +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} --notify 1 --killMessage Transcoding streams are not allowed. +``` + +### Kill transcodes using the default message + +Triggers: Playback Start +Conditions: \[ `Transcode Decision` | `is` | `transcode` \] + +Arguments: +``` +--jbop stream --username {username} --sessionId {session_id} +``` + +### Kill all of a user's streams with notification + +Triggers: Playback Start +Conditions: \[ `Username` | `is` | `Bob` \] + +Arguments: +``` +--jbop allStreams --userId {user_id} --notify 1 --killMessage Hey Bob, we need to talk! +``` diff --git a/killstream/wait_kill_paused_notify.py b/killstream/wait_kill_paused_notify.py deleted file mode 100644 index c0f6996..0000000 --- a/killstream/wait_kill_paused_notify.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Description: Kill paused sessions if paused for X amount of time. -Author: samwiseg00 -Requires: requests, plexapi - -Enabling Scripts in Tautulli: -Taultulli > Settings > Notification Agents > Add a Notification Agent > Script - -Configuration: -Taultulli > Settings > Notification Agents > New Script > Configuration: - - Script Name: wait_kill_notify.py - Set Script Timeout: 0 - Description: Killing long pauses - Save - -Triggers: -Taultulli > Settings > Notification Agents > New Script > Triggers: - - Check: Playback Pause - Save - -Conditions: -Taultulli > Settings > Notification Agents > New Script > Conditions: - - Set Conditions: Condition {1} | Username | is not | UsernameToExclude - Save - -Script Arguments: -Taultulli > Settings > Notification Agents > New Script > Script Arguments: - - Select: Playback Pause - Arguments: {session_key} {user} {title} TIMEOUT INTERVAL - - Save - Close - -Example: - {session_key} {user} {title} 1200 20 - This will tell the script to kill the stream after 20 minutes and check every 20 seconds - -""" - -import os -import sys -from time import sleep -from datetime import datetime -from plexapi.server import PlexServer -import requests - -PLEX_FALLBACK_URL = 'http://127.0.0.1:32400' -PLEX_FALLBACK_TOKEN = '' -PLEX_URL = os.getenv('PLEX_URL', PLEX_FALLBACK_URL) -PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_FALLBACK_TOKEN) - -PLEX_OVERRIDE_URL = '' -PLEX_OVERRIDE_TOKEN = '' - -if PLEX_OVERRIDE_URL: - PLEX_URL = PLEX_OVERRIDE_URL -if PLEX_OVERRIDE_TOKEN: - PLEX_TOKEN = PLEX_OVERRIDE_TOKEN - - -sess = requests.Session() -sess.verify = False -plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) - -sessionKey = sys.argv[1] -username = sys.argv[2] -streamTitle = sys.argv[3] -timeout = int(sys.argv[4]) -interval = int(sys.argv[5]) - -seconds = int(timeout) - -minutes, seconds = divmod(seconds, 60) -hours, minutes = divmod(minutes, 60) - -periods = [('hours', hours), ('minutes', minutes), ('seconds', seconds)] -time_string = ', '.join('{} {}'.format(value, name) - for name, value in periods - if value) -start = datetime.now() - -countdown = 0 -counter = timeout + interval + 100 - -while countdown < counter and countdown is not None: - - foundSession = False - - for session in plex.sessions(): - - if session.sessionKey == int(sessionKey): - foundSession = True - state = session.players[0].state - - if state == 'paused': - now = datetime.now() - diff = now - start - - if diff.total_seconds() >= timeout: - session.stop(reason="This stream has ended due to being paused for over {}.".format(time_string)) - print ("Killed {}'s {} paused stream of {}.".format(username, time_string, streamTitle)) - sys.exit(0) - - else: - sleep(interval) - counter = counter - interval - - elif state == 'playing' or state == 'buffering': - print ("{} resumed the stream of {} so we killed the script.".format(username, streamTitle)) - sys.exit(0) - - if not foundSession: - print ("Session key ({}) for user {} not found while playing {}. " - "The player may have gone to a paused then stopped state.".format(sessionKey, username, streamTitle)) - sys.exit(0)