· 5 years ago · Oct 12, 2020, 09:54 AM
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4"""
5Description: Use conditions to kill a stream
6Author: Blacktwin, Arcanemagus, Samwiseg0, JonnyWong16, DirtyCajunRice
7
8Adding the script to Tautulli:
9Tautulli > Settings > Notification Agents > Add a new notification agent >
10 Script
11
12Configuration:
13Tautulli > Settings > Notification Agents > New Script > Configuration:
14
15 Script Folder: /path/to/your/scripts
16 Script File: ./kill_stream.py (Should be selectable in a dropdown list)
17 Script Timeout: {timeout}
18 Description: Kill stream(s)
19 Save
20
21Triggers:
22Tautulli > Settings > Notification Agents > New Script > Triggers:
23
24 Check: Playback Start and/or Playback Pause
25 Save
26
27Conditions:
28Tautulli > Settings > Notification Agents > New Script > Conditions:
29
30 Set Conditions: [{condition} | {operator} | {value} ]
31 Save
32
33Script Arguments:
34Tautulli > Settings > Notification Agents > New Script > Script Arguments:
35
36 Select: Playback Start, Playback Pause
37 Arguments: --jbop SELECTOR --userId {user_id} --username {username}
38 --sessionId {session_id} --notify notifierID
39 --interval 30 --limit 1200
40 --richMessage RICH_TYPE --serverName {server_name}
41 --plexUrl {plex_url} --posterUrl {poster_url}
42 --richColor '#E5A00D'
43 --killMessage 'Your message here.'
44
45 Save
46 Close
47"""
48from __future__ import print_function
49from __future__ import unicode_literals
50
51
52from builtins import object
53from builtins import str
54import os
55import sys
56import json
57import time
58import argparse
59from datetime import datetime
60from requests import Session
61from requests.adapters import HTTPAdapter
62from requests.exceptions import RequestException
63
64
65TAUTULLI_URL = ''
66TAUTULLI_APIKEY = ''
67TAUTULLI_PUBLIC_URL = ''
68TAUTULLI_URL = os.getenv('TAUTULLI_URL', TAUTULLI_URL)
69TAUTULLI_PUBLIC_URL = os.getenv('TAUTULLI_PUBLIC_URL', TAUTULLI_PUBLIC_URL)
70TAUTULLI_APIKEY = os.getenv('TAUTULLI_APIKEY', TAUTULLI_APIKEY)
71TAUTULLI_ENCODING = os.getenv('TAUTULLI_ENCODING', 'UTF-8')
72VERIFY_SSL = False
73
74if TAUTULLI_PUBLIC_URL != '/':
75 # Check to see if there is a public URL set in Tautulli
76 TAUTULLI_LINK = TAUTULLI_PUBLIC_URL
77else:
78 TAUTULLI_LINK = TAUTULLI_URL
79
80SUBJECT_TEXT = "Tautulli has killed a stream."
81BODY_TEXT = "Killed session ID '{id}'. Reason: {message}"
82BODY_TEXT_USER = "Killed {user}'s stream. Reason: {message}."
83
84
85SELECTOR = ['stream', 'allStreams', 'paused']
86
87RICH_TYPE = ['discord', 'slack']
88
89TAUTULLI_ICON = 'https://github.com/Tautulli/Tautulli/raw/master/data/interfaces/default/images/logo-circle.png'
90
91
92def utc_now_iso():
93 """Get current time in ISO format"""
94 utcnow = datetime.utcnow()
95
96 return utcnow.isoformat()
97
98
99def hex_to_int(value):
100 """Convert hex value to integer"""
101 try:
102 return int(value, 16)
103 except (ValueError, TypeError):
104 return 0
105
106
107def arg_decoding(arg):
108 """Decode args, encode UTF-8"""
109 if sys.version_info[0] < 3:
110 return arg.decode(TAUTULLI_ENCODING).encode('UTF-8')
111 else:
112 return arg
113
114
115def debug_dump_vars():
116 """Dump parameters for debug"""
117 print('Tautulli URL - ' + TAUTULLI_URL)
118 print('Tautulli Public URL - ' + TAUTULLI_PUBLIC_URL)
119 print('Verify SSL - ' + str(VERIFY_SSL))
120 print('Tautulli API key - ' + TAUTULLI_APIKEY[-4:]
121 .rjust(len(TAUTULLI_APIKEY), "x"))
122
123
124def get_all_streams(tautulli, user_id=None):
125 """Get a list of all current streams.
126
127 Parameters
128 ----------
129 user_id : int
130 The ID of the user to grab sessions for.
131 tautulli : obj
132 Tautulli object.
133 Returns
134 -------
135 objects
136 The of stream objects.
137 """
138 sessions = tautulli.get_activity()['sessions']
139
140 if user_id:
141 streams = [Stream(session=s) for s in sessions if s['user_id'] == user_id]
142 else:
143 streams = [Stream(session=s) for s in sessions]
144
145 return streams
146
147
148def notify(all_opts, message, kill_type=None, stream=None, tautulli=None):
149 """Decides which notifier type to use"""
150 if all_opts.notify and all_opts.richMessage:
151 rich_notify(all_opts.notify, all_opts.richMessage, all_opts.richColor, kill_type,
152 all_opts.serverName, all_opts.plexUrl, all_opts.posterUrl, message, stream, tautulli)
153 elif all_opts.notify:
154 basic_notify(all_opts.notify, all_opts.sessionId, all_opts.username, message, stream, tautulli)
155
156
157def rich_notify(notifier_id, rich_type, color=None, kill_type=None, server_name=None,
158 plex_url=None, poster_url=None, message=None, stream=None, tautulli=None):
159 """Decides which rich notifier type to use. Set default values for empty variables
160
161 Parameters
162 ----------
163 notifier_id : int
164 The ID of the user to grab sessions for.
165 rich_type : str
166 Contains 'discord' or 'slack'.
167 color : Union[int, str]
168 Hex string or integer representation of color.
169 kill_type : str
170 The kill type used.
171 server_name : str
172 The name of the plex server.
173 plex_url : str
174 Plex media URL.
175 poster_url : str
176 The media poster URL.
177 message : str
178 Message sent to the client.
179 stream : obj
180 Stream object.
181 tautulli : obj
182 Tautulli object.
183 """
184 notification = Notification(notifier_id, None, None, tautulli, stream)
185 # Initialize Variables
186 title = ''
187 footer = ''
188 # Set a default server_name if none is provided
189 if server_name is None:
190 server_name = 'Plex Server'
191
192 # Set a default color if none is provided
193 if color is None:
194 color = '#E5A00D'
195
196 # Set a default plexUrl if none is provided
197 if plex_url is None:
198 plex_url = 'https://app.plex.tv'
199
200 # Set a default posterUrl if none is provided
201 if poster_url is None:
202 poster_url = TAUTULLI_ICON
203
204 # Set a default message if none is provided
205 if message is None:
206 message = 'The server owner has ended the stream.'
207
208 if kill_type == 'Stream':
209 title = "Killed {}'s stream.".format(stream.friendly_name)
210 footer = '{} | Kill {}'.format(server_name, kill_type)
211
212 elif kill_type == 'Paused':
213 title = "Killed {}'s paused stream.".format(stream.friendly_name)
214 footer = '{} | Kill {}'.format(server_name, kill_type)
215
216 elif kill_type == 'All Streams':
217 title = "Killed {}'s stream.".format(stream.friendly_name)
218 footer = '{} | Kill {}'.format(server_name, kill_type)
219 poster_url = TAUTULLI_ICON
220 plex_url = 'https://app.plex.tv'
221
222 if rich_type == 'discord':
223 color = hex_to_int(color.lstrip('#'))
224 notification.send_discord(title, color, poster_url, plex_url, message, footer)
225
226 elif rich_type == 'slack':
227 notification.send_slack(title, color, poster_url, plex_url, message, footer)
228
229
230def basic_notify(notifier_id, session_id, username=None, message=None, stream=None, tautulli=None):
231 """Basic notifier"""
232 notification = Notification(notifier_id, SUBJECT_TEXT, BODY_TEXT, tautulli, stream)
233
234 if username:
235 body = BODY_TEXT_USER.format(user=username,
236 message=message)
237 else:
238 body = BODY_TEXT.format(id=session_id, message=message)
239 notification.send(SUBJECT_TEXT, body)
240
241
242class Tautulli(object):
243 def __init__(self, url, apikey, verify_ssl=False, debug=None):
244 self.url = url
245 self.apikey = apikey
246 self.debug = debug
247
248 self.session = Session()
249 self.adapters = HTTPAdapter(max_retries=3,
250 pool_connections=1,
251 pool_maxsize=1,
252 pool_block=True)
253 self.session.mount('http://', self.adapters)
254 self.session.mount('https://', self.adapters)
255
256 # Ignore verifying the SSL certificate
257 if verify_ssl is False:
258 self.session.verify = False
259 # Disable the warning that the request is insecure, we know that...
260 import urllib3
261 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
262
263 def _call_api(self, cmd, payload, method='GET'):
264 payload['cmd'] = cmd
265 payload['apikey'] = self.apikey
266
267 try:
268 response = self.session.request(method, self.url + '/api/v2', params=payload)
269 except RequestException as e:
270 print("Tautulli request failed for cmd '{}'. Invalid Tautulli URL? Error: {}".format(cmd, e))
271 if self.debug:
272 traceback.print_exc()
273 return
274
275 try:
276 response_json = response.json()
277 except ValueError:
278 print(
279 "Failed to parse json response for Tautulli API cmd '{}': {}"
280 .format(cmd, response.content))
281 return
282
283 if response_json['response']['result'] == 'success':
284 if self.debug:
285 print("Successfully called Tautulli API cmd '{}'".format(cmd))
286 return response_json['response']['data']
287 else:
288 error_msg = response_json['response']['message']
289 print("Tautulli API cmd '{}' failed: {}".format(cmd, error_msg))
290 return
291
292 def get_activity(self, session_key=None, session_id=None):
293 """Call Tautulli's get_activity api endpoint"""
294 payload = {}
295
296 if session_key:
297 payload['session_key'] = session_key
298 elif session_id:
299 payload['session_id'] = session_id
300
301 return self._call_api('get_activity', payload)
302
303 def notify(self, notifier_id, subject, body):
304 """Call Tautulli's notify api endpoint"""
305 payload = {'notifier_id': notifier_id,
306 'subject': subject,
307 'body': body}
308
309 return self._call_api('notify', payload)
310
311 def terminate_session(self, session_key=None, session_id=None, message=''):
312 """Call Tautulli's terminate_session api endpoint"""
313 payload = {}
314
315 if session_key:
316 payload['session_key'] = session_key
317 elif session_id:
318 payload['session_id'] = session_id
319
320 if message:
321 payload['message'] = message
322
323 return self._call_api('terminate_session', payload)
324
325
326class Stream(object):
327 def __init__(self, session_id=None, user_id=None, username=None, tautulli=None, session=None):
328 self.state = None
329 self.ip_address = None
330 self.session_id = session_id
331 self.user_id = user_id
332 self.username = username
333 self.session_exists = False
334 self.tautulli = tautulli
335
336 if session is not None:
337 self._set_stream_attributes(session)
338
339 def _set_stream_attributes(self, session):
340 for k, v in session.items():
341 setattr(self, k, v)
342
343 def get_all_stream_info(self):
344 """Get all stream info from Tautulli."""
345 session = self.tautulli.get_activity(session_id=self.session_id)
346 if session:
347 self._set_stream_attributes(session)
348 self.session_exists = True
349 else:
350 self.session_exists = False
351
352 def terminate(self, message=''):
353 """Calls Tautulli to terminate the session.
354
355 Parameters
356 ----------
357 message : str
358 The message to use if the stream is terminated.
359 """
360 self.tautulli.terminate_session(session_id=self.session_id, message=message)
361
362 def terminate_long_pause(self, message, limit, interval):
363 """Kills the session if it is paused for longer than <limit> seconds.
364
365 Parameters
366 ----------
367 message : str
368 The message to use if the stream is terminated.
369 limit : int
370 The number of seconds the session is allowed to remain paused before it
371 is terminated.
372 interval : int
373 The amount of time to wait between checks of the session state.
374 """
375 start = datetime.now()
376 checked_time = 0
377 # Continue checking 2 intervals past the allowed limit in order to
378 # account for system variances.
379 check_limit = limit + (interval * 2)
380
381 while checked_time < check_limit:
382 self.get_all_stream_info()
383
384 if self.session_exists is False:
385 sys.stdout.write(
386 "Session '{}' from user '{}' is no longer active "
387 .format(self.session_id, self.username) +
388 "on the server, stopping monitoring.\n")
389 return False
390
391 now = datetime.now()
392 checked_time = (now - start).total_seconds()
393
394 if self.state == 'paused':
395 if checked_time >= limit:
396 self.terminate(message)
397 sys.stdout.write(
398 "Session '{}' from user '{}' has been killed.\n"
399 .format(self.session_id, self.username))
400 return True
401 else:
402 time.sleep(interval)
403
404 elif self.state == 'playing' or self.state == 'buffering':
405 sys.stdout.write(
406 "Session '{}' from user '{}' has been resumed, "
407 .format(self.session_id, self.username) +
408 "stopping monitoring.\n")
409 return False
410
411
412class Notification(object):
413 def __init__(self, notifier_id, subject, body, tautulli, stream):
414 self.notifier_id = notifier_id
415 self.subject = subject
416 self.body = body
417
418 self.tautulli = tautulli
419 self.stream = stream
420
421 def send(self, subject='', body=''):
422 """Send to Tautulli notifier.
423
424 Parameters
425 ----------
426 subject : str
427 Subject of the message.
428 body : str
429 Body of the message.
430 """
431 subject = subject or self.subject
432 body = body or self.body
433 self.tautulli.notify(notifier_id=self.notifier_id, subject=subject, body=body)
434
435 def send_discord(self, title, color, poster_url, plex_url, message, footer):
436 """Build the Discord message.
437
438 Parameters
439 ----------
440 title : str
441 The title of the message.
442 color : int
443 The color of the message
444 poster_url : str
445 The media poster URL.
446 plex_url : str
447 Plex media URL.
448 message : str
449 Message sent to the player.
450 footer : str
451 Footer of the message.
452 """
453 discord_message = {
454 "embeds": [
455 {
456 "author": {
457 "icon_url": TAUTULLI_ICON,
458 "name": "Tautulli",
459 "url": TAUTULLI_LINK.rstrip('/')
460 },
461 "color": color,
462 "fields": [
463 {
464 "inline": True,
465 "name": "User",
466 "value": self.stream.friendly_name
467 },
468 {
469 "inline": True,
470 "name": "Session Key",
471 "value": self.stream.session_key
472 },
473 {
474 "inline": True,
475 "name": "Watching",
476 "value": self.stream.full_title
477 },
478 {
479 "inline": False,
480 "name": "Message Sent",
481 "value": message
482 }
483 ],
484 "thumbnail": {
485 "url": poster_url
486 },
487 "title": title,
488 "timestamp": utc_now_iso(),
489 "url": plex_url,
490 "footer": {
491 "text": footer
492 }
493
494 }
495
496 ],
497 }
498
499 discord_message = json.dumps(discord_message, sort_keys=True,
500 separators=(',', ': '))
501 self.send(body=discord_message)
502
503 def send_slack(self, title, color, poster_url, plex_url, message, footer):
504 """Build the Slack message.
505
506 Parameters
507 ----------
508 title : str
509 The title of the message.
510 color : int
511 The color of the message
512 poster_url : str
513 The media poster URL.
514 plex_url : str
515 Plex media URL.
516 message : str
517 Message sent to the player.
518 footer : str
519 Footer of the message.
520 """
521 slack_message = {
522 "attachments": [
523 {
524 "title": title,
525 "title_link": plex_url,
526 "author_icon": TAUTULLI_ICON,
527 "author_name": "Tautulli",
528 "author_link": TAUTULLI_LINK.rstrip('/'),
529 "color": color,
530 "fields": [
531 {
532 "title": "User",
533 "value": self.stream.friendly_name,
534 "short": True
535 },
536 {
537 "title": "Session Key",
538 "value": self.stream.session_key,
539 "short": True
540 },
541 {
542 "title": "Watching",
543 "value": self.stream.full_title,
544 "short": True
545 },
546 {
547 "title": "Message Sent",
548 "value": message,
549 "short": False
550 }
551 ],
552 "thumb_url": poster_url,
553 "footer": footer,
554 "ts": time.time()
555 }
556
557 ],
558 }
559
560 slack_message = json.dumps(slack_message, sort_keys=True,
561 separators=(',', ': '))
562 self.send(body=slack_message)
563
564
565if __name__ == "__main__":
566 parser = argparse.ArgumentParser(
567 description="Killing Plex streams from Tautulli.")
568 parser.add_argument('--jbop', required=True, choices=SELECTOR,
569 help='Kill selector.\nChoices: (%(choices)s)')
570 parser.add_argument('--userId', type=int,
571 help='The unique identifier for the user.')
572 parser.add_argument('--username', type=arg_decoding,
573 help='The username of the person streaming.')
574 parser.add_argument('--sessionId',
575 help='The unique identifier for the stream.')
576 parser.add_argument('--notify', type=int,
577 help='Notification Agent ID number to Agent to ' +
578 'send notification.')
579 parser.add_argument('--limit', type=int, default=(20 * 60), # 20 minutes
580 help='The time session is allowed to remain paused.')
581 parser.add_argument('--interval', type=int, default=30,
582 help='The seconds between paused session checks.')
583 parser.add_argument('--killMessage', nargs='+', type=arg_decoding,
584 help='Message to send to user whose stream is killed.')
585 parser.add_argument('--richMessage', type=arg_decoding, choices=RICH_TYPE,
586 help='Rich message type selector.\nChoices: (%(choices)s)')
587 parser.add_argument('--serverName', type=arg_decoding,
588 help='Plex Server Name')
589 parser.add_argument('--plexUrl', type=arg_decoding,
590 help='URL to plex media')
591 parser.add_argument('--posterUrl', type=arg_decoding,
592 help='Poster URL of the media')
593 parser.add_argument('--richColor', type=arg_decoding,
594 help='Color of the rich message')
595 parser.add_argument("--debug", action='store_true',
596 help='Enable debug messages.')
597
598 opts = parser.parse_args()
599
600 if not opts.sessionId and opts.jbop != 'allStreams':
601 sys.stderr.write("No sessionId provided! Is this synced content?\n")
602 sys.exit(1)
603
604 if opts.debug:
605 # Import traceback to get more detailed information
606 import traceback
607 # Dump the ENVs passed from tautulli
608 debug_dump_vars()
609
610 # Create a Tautulli instance
611 tautulli_server = Tautulli(TAUTULLI_URL.rstrip('/'), TAUTULLI_APIKEY, VERIFY_SSL, opts.debug)
612
613 # Create initial Stream object with basic info
614 tautulli_stream = Stream(opts.sessionId, opts.userId, opts.username, tautulli_server)
615
616 # Only pull all stream info if using richMessage
617 if opts.notify and opts.richMessage:
618 tautulli_stream.get_all_stream_info()
619
620 # Set a default message if none is provided
621 if opts.killMessage:
622 kill_message = ' '.join(opts.killMessage)
623 else:
624 kill_message = 'The server owner has ended the stream.'
625
626 if opts.jbop == 'stream':
627 tautulli_stream.terminate(kill_message)
628 notify(opts, kill_message, 'Stream', tautulli_stream, tautulli_server)
629
630 elif opts.jbop == 'allStreams':
631 all_streams = get_all_streams(tautulli_server, opts.userId)
632 for a_stream in all_streams:
633 tautulli_server.terminate_session(session_id=a_stream.session_id, message=kill_message)
634 notify(opts, kill_message, 'All Streams', a_stream, tautulli_server)
635
636 elif opts.jbop == 'paused':
637 killed_stream = tautulli_stream.terminate_long_pause(kill_message, opts.limit, opts.interval)
638 if killed_stream:
639 notify(opts, kill_message, 'Paused', tautulli_stream, tautulli_server)
640