From e605979a16c10c115d599684c60308ce0f2345e4 Mon Sep 17 00:00:00 2001 From: Blacktwin Date: Thu, 14 Mar 2019 13:07:41 -0400 Subject: [PATCH] total rewrite while resolving #146. Now able to read watched status from Tautulli and apply to any user on any owned server. --- utility/sync_watch_status.py | 586 ++++++++++++++++++++++++----------- 1 file changed, 412 insertions(+), 174 deletions(-) diff --git a/utility/sync_watch_status.py b/utility/sync_watch_status.py index c46693d..cff0d30 100644 --- a/utility/sync_watch_status.py +++ b/utility/sync_watch_status.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """ -Description: Sync the watch status from one user to another. Either by user or user/libraries +Description: Sync the watch status from one user to another across multiple servers including from Tautulli Author: Blacktwin Requires: requests, plexapi, argparse @@ -31,32 +31,35 @@ Script Arguments: Taultulli > Settings > Notification Agents > New Script > Script Arguments: Select: Notify on Watched - Arguments: --ratingKey {rating_key} --userTo "Username2=Server1" "Username3=Server1" --userFrom {username}={server_name} + Arguments: --ratingKey {rating_key} --userFrom Tautulli=Tautulli --userTo "Username2=Server1" "Username3=Server1" Save Close Example: - Set in Tautulli in script notification agent or run manually + Set in Tautulli in script notification agent (above) or run manually (below) - sync_watch_status.py --userFrom USER1=Server --userTo USER2=Server --libraries Movies - - Synced watch status of {title from library} to {USER2}'s account. + sync_watch_status.py --userFrom USER1=Server1 --userTo USER2=Server1 --libraries Movies + - Synced watch status from Server1 {title from library} to {USER2}'s account on Server1. - sync_watch_status.py --userFrom USER1=Server --userTo USER2=Server USER3=Server --allLibraries - - Synced watch status of {title from library} to {USER2 or USER3}'s account. + sync_watch_status.py --userFrom USER1=Server2 --userTo USER2=Server1 USER3=Server1 --libraries Movies "TV Shows" + - Synced watch status from Server2 {title from library} to {USER2 or USER3}'s account on Server1. - Excluding; - --libraries becomes excluded if --allLibraries is set - sync_watch_status.py --userFrom USER1=Server --userTo USER2=Server --allLibraries --libraries Movies - - Shared [all libraries but Movies] with USER. + sync_watch_status.py --userFrom USER1=Tautulli --userTo USER2=Server1 USER3=Server2 --libraries Movies "TV Shows" + - Synced watch statuses from Tautulli {title from library} to {USER2 or USER3}'s account on selected servers. """ -import sys -import requests import argparse from plexapi.myplex import MyPlexAccount -from plexapi.server import PlexServer, CONFIG +from plexapi.server import PlexServer +from plexapi.server import CONFIG +from requests import Session +from requests.adapters import HTTPAdapter +from requests.exceptions import RequestException +import pprint + +pp = pprint.PrettyPrinter(indent=4) # Using CONFIG file PLEX_TOKEN = '' @@ -64,206 +67,441 @@ TAUTULLI_URL = '' TAUTULLI_APIKEY = '' if not PLEX_TOKEN: - PLEX_TOKEN = CONFIG.data['auth'].get('server_token', '') + PLEX_TOKEN = CONFIG.data['auth'].get('server_token') if not TAUTULLI_URL: TAUTULLI_URL = CONFIG.data['auth'].get('tautulli_baseurl') if not TAUTULLI_APIKEY: TAUTULLI_APIKEY = CONFIG.data['auth'].get('tautulli_apikey') - -sess = requests.Session() -# Ignore verifying the SSL certificate -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. -if sess.verify is False: - # Disable the warning that the request is insecure, we know that... - import urllib3 - - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - -account = MyPlexAccount(PLEX_TOKEN) - -# todo-me This is cleaned up. Should only connect to servers that are selected -sections_lst = [] -user_servers = {} -admin_servers = {} -server_users = account.users() -user_server_dict = {'data': {}} -user_data = user_server_dict['data'] - -# Finding and connecting to owned servers. -print('Connecting to admin owned servers.') -for resource in account.resources(): - if 'server' in [resource.provides] and resource.ownerid == 0: - server_connect = resource.connect() - admin_servers[resource.name] = server_connect - # Pull section names to check against - server_sections = [section.title for section in server_connect.library.sections()] - sections_lst += server_sections - -sections_lst = list(set(sections_lst)) - -# Add admin account -user_data[account.title] = {'account': account, - 'servers': admin_servers} - -# Finding what user has access to which admin owned servers -for user in server_users: - for server in user.servers: - if admin_servers.get(server.name): - user_servers[server.name] = admin_servers.get(server.name) - if not user_data.get(user.title): - user_data[user.title] = {'account': user, - 'servers': user_servers} - -# todo-me Add Tautulli history for syncing watch statuses from Tautulli to Plex -def get_history(user_id, media_type): - # Get the user history from Tautulli. - payload = {'apikey': TAUTULLI_APIKEY, - 'cmd': 'get_history', - 'user_id': user_id, - 'media_type': media_type} - - try: - r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) - response = r.json() - res_data = response['response']['data']['data'] - - except Exception as e: - sys.stderr.write("Tautulli API 'get_history' request failed: {0}.".format(e)) +VERIFY_SSL = False -def get_account(user, server): +class Connection: + def __init__(self, url=None, apikey=None, verify_ssl=False): + self.url = url + self.apikey = apikey + self.session = Session() + self.adapters = HTTPAdapter(max_retries=3, + pool_connections=1, + pool_maxsize=1, + pool_block=True) + self.session.mount('http://', self.adapters) + self.session.mount('https://', self.adapters) + + # Ignore verifying the SSL certificate + if verify_ssl is False: + self.session.verify = False + # Disable the warning that the request is insecure, we know that... + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +class Library(object): + def __init__(self, data=None): + d = data or {} + self.title = d['section_name'] + self.key = d['section_id'] + + +class Metadata(object): + def __init__(self, data=None): + d = data or {} + self.type = d['media_type'] + self.grandparentTitle = d['grandparent_title'] + self.parentIndex = d['parent_media_index'] + self.index = d['media_index'] + if self.type == 'episode': + ep_name = d['full_title'].partition('-')[-1] + self.title = ep_name.lstrip() + else: + self.title = d['full_title'] + + # For History + try: + if d['watched_status']: + self.watched_status = d['watched_status'] + except KeyError: + pass + # For Metadata + try: + if d["library_name"]: + self.libraryName = d['library_name'] + except KeyError: + pass + + +class Tautulli: + def __init__(self, connection): + self.connection = connection + + def _call_api(self, cmd, payload, method='GET'): + payload['cmd'] = cmd + payload['apikey'] = self.connection.apikey + + try: + response = self.connection.session.request(method, self.connection.url + '/api/v2', params=payload) + except RequestException as e: + print("Tautulli request failed for cmd '{}'. Invalid Tautulli URL? Error: {}".format(cmd, e)) + return + + try: + response_json = response.json() + except ValueError: + print("Failed to parse json response for Tautulli API cmd '{}'".format(cmd)) + return + + if response_json['response']['result'] == 'success': + return response_json['response']['data'] + else: + error_msg = response_json['response']['message'] + print("Tautulli API cmd '{}' failed: {}".format(cmd, error_msg)) + return + + def get_watched_history(self, user, section_id, start, length): + """Call Tautulli's get_history api endpoint""" + payload = {"user": user, + "section_id": section_id, + 'start': start, + 'length': length, + 'order_column': 'full_title', + 'order_dir': 'asc'} + + history = self._call_api('get_history', payload) + + return [d for d in history['data'] if d['watched_status'] == 1] + + def get_metadata(self, rating_key): + """Call Tautulli's get_metadata api endpoint""" + + payload = {"rating_key": rating_key} + return self._call_api('get_metadata', payload) + + def get_libraries(self): + """Call Tautulli's get_libraries api endpoint""" + + payload = {} + return self._call_api('get_libraries', payload) + + +class Plex: + def __init__(self, token, url=None): + if token and not url: + self.account = MyPlexAccount(token) + if token and url: + session = Connection().session + self.server = PlexServer(baseurl=url, token=token, session=session) + + def admin_servers(self): + """ + Returns + ------- + data: dict + """ + resources = {} + for resource in self.account.resources(): + if 'server' in [resource.provides] and resource.owned == True: + resources[resource.name] = resource + + return resources + + def all_users(self): + """ + Returns + ------- + data: dict + """ + users = {self.account.title: self.account} + for user in self.account.users(): + users[user.title] = user + + return users + + def all_sections(self): + """ + Returns + ------- + data: dict + """ + data = {} + servers = self.admin_servers() + print('Connecting to admin server(s) for section info...') + for name, server in servers.items(): + connect = server.connect() + sections = {section.title: section for section in connect.library.sections()} + data[name] = sections + + return data + + def users_access(self): + """ + Returns + ------- + data: dict + """ + all_users = self.all_users().values() + admin_servers = self.admin_servers() + all_sections = self.all_sections() + + data = {self.account.title: {"account": self.account}} + + for user in all_users: + if not data.get(user.title): + servers = [] + for server in user.servers: + if admin_servers.get(server.name): + access = {} + sections = {section.title: section for section in server.sections() + if section.shared == True} + access['server'] = {server.name: admin_servers.get(server.name)} + access['sections'] = sections + servers += [access] + data[user.title] = {'account': user, + 'access': servers} + else: + # Admin account + servers = [] + for name, server in admin_servers.items(): + access = {} + sections = all_sections.get(name) + access['server'] = {name: server} + access['sections'] = sections + servers += [access] + data[user.title] = {'account': user, + 'access': servers} + return data + + +def connect_to_server(server_obj, user_account): + """ + Parameters + ---------- + server_obj: class + user_account: class + + Returns + ------- + user_connection.server: class + """ + server_name = server_obj.name + user = user_account.title + + print('Connecting {} to {}...'.format(user, server_name)) + server_connection = server_obj.connect() + baseurl = server_connection._baseurl.split('.') + url = ''.join([baseurl[0].replace('-', '.'), + baseurl[-1].replace('direct', '')]) + if user_account.title == Plex(PLEX_TOKEN).account.title: + token = PLEX_TOKEN + else: + token = user_account.get_token(server_connection.machineIdentifier) + + user_connection = Plex(url=url, token=token) + + return user_connection.server + + +def check_users_access(user, server_name, libraries=None): """ Parameters ---------- user: str - User's name - server: str - Server's name + server_name: str + libraries: list Returns ------- - User server class - + server_connection: class """ - print('Checking {} on {}'.format(user, server)) - if user_server_dict['data'][user]['servers'].get(server): - user_server = user_server_dict['data'][user]['servers'].get(server) - baseurl = user_server._baseurl.split('.') - url = ''.join([baseurl[0].replace('-', '.'), - baseurl[-1].replace('direct', '')]) - if user == MyPlexAccount(PLEX_TOKEN).title: - token = PLEX_TOKEN - else: - userAccount = user_server.myPlexAccount().user(user) - token = userAccount.get_token(user_server.machineIdentifier) - account = PlexServer(baseurl=url, token=token, session=sess) - return account - else: - print('{} is not shared to {}'.format(user, server)) + try: + _user = plex_admin.users_access().get(user) + for access in _user['access']: + server = access.get("server") + # Check user access to server + if server.get(server_name): + server_obj = server.get(server_name) + # If syncing by libraries, check library access + if libraries: + library_check = any(lib.title in access.get("sections").keys() for lib in libraries) + # Check user access to library + if library_check: + server_connection = connect_to_server(server_obj, _user['account']) + return server_connection + + elif not library_check: + print("User does not have access to this library.") + # Not syncing by libraries + else: + server_connection = connect_to_server(server_obj, _user['account']) + return server_connection + # else: + # print("User does not have access to this server: {}.".format(server_name)) + except KeyError: + print('User name is incorrect.') + print(", ".join(plex_admin.all_users().keys())) exit() -def mark_watached(sectionFrom, accountTo, userTo): +def sync_watch_status(watched, section, accountTo, userTo): """ Parameters ---------- - sectionFrom: class + watched: list + section: class Section class of sync from server - accountTo: class + userTo: str User's server class of sync to user """ # Check sections for watched items print('Marking watched...') - sectionTo = accountTo.library.section(sectionFrom.title) - for item in sectionFrom.search(unwatched=False): - title = item.title.encode('utf-8') + sectionTo = accountTo.library.section(section) + for item in watched: try: - # Check movie media type - if item.type == 'movie': - watch_check = sectionTo.get(item.title) - fetch_check = sectionTo.fetchItem(watch_check.key) - if not fetch_check.isWatched: - fetch_check.markWatched() - print('Synced watch status of {} to {}\'s account.'.format(title, userTo)) - # Check show media type - elif item.type == 'show': - for episode in sectionFrom.searchEpisodes(unwatched=False, title=title): - ep_title = episode.title.encode('utf-8') - watch_check = sectionTo.get(item.title) - fetch_check = sectionTo.fetchItem(watch_check.key) - if not fetch_check.isWatched: - fetch_check.markWatched() - print('Synced watch status of {} - {} to {}\'s account.'.format(title, ep_title, userTo)) - except Exception: + # .get retrieves a partial object + if item.type == 'episode': + show_name = item.grandparentTitle + ep_name = item.title + title = "{} {}".format(show_name, ep_name) + show = sectionTo.get(show_name) + watch_check = show.episode(season=item.parentIndex, episode=item.index) + else: + title = item.title + watch_check = sectionTo.get(title) + # .fetchItem retrieves a full object + fetch_check = sectionTo.fetchItem(watch_check.key) + # If item is already watched ignore + if not fetch_check.isWatched: + # todo-me should watched count be synced? + fetch_check.markWatched() + print("Synced watched status of {} to account {}...".format(title, userTo)) + + except Exception as e: + print(e) pass if __name__ == '__main__': - parser = argparse.ArgumentParser(description="Sync watch status from one user to others.", formatter_class=argparse.RawTextHelpFormatter) - requiredNamed = parser.add_argument_group('required named arguments') - parser.add_argument('--libraries', nargs='*', choices=sections_lst, metavar='library', - help='Space separated list of case sensitive names to process. Allowed names are: \n' - '(choices: %(choices)s)') - parser.add_argument('--allLibraries', action='store_true', - help='Select all libraries.') - parser.add_argument('--ratingKey', nargs=1, + parser.add_argument('--libraries', nargs='*', metavar='library', + help='Libraries to scan for watched content.') + parser.add_argument('--ratingKey', nargs="?", type=str, help='Rating key of item whose watch status is to be synced.') + requiredNamed = parser.add_argument_group('required named arguments') requiredNamed.add_argument('--userFrom', metavar='user=server', required=True, - type=lambda kv: kv.split("="), + type=lambda kv: kv.split("="), default=["", ""], help='Select user and server to sync from') requiredNamed.add_argument('--userTo', nargs='*', metavar='user=server', required=True, type=lambda kv: kv.split("="), help='Select user and server to sync to.') - + opts = parser.parse_args() - + # print(opts) + tautulli_server = '' + + libraries = [] + all_sections = {} + watchedFrom = '' + count = 25 + start = 0 + plex_admin = Plex(PLEX_TOKEN) + + userFrom, serverFrom = opts.userFrom + + if serverFrom == "Tautulli": + # Create a Tautulli instance + tautulli_server = Tautulli(Connection(url=TAUTULLI_URL.rstrip('/'), + apikey=TAUTULLI_APIKEY, + verify_ssl=VERIFY_SSL)) + + if serverFrom == "Tautulli" and opts.libraries: + _sections = {} + # Pull all libraries from Tautulli + tautulli_sections = tautulli_server.get_libraries() + for section in tautulli_sections: + section_obj = Library(section) + _sections[section_obj.title] = section_obj + all_sections[serverFrom] = _sections + elif serverFrom != "Tautulli" and opts.libraries: + all_sections = plex_admin.all_sections() + # Defining libraries - libraries = '' - if opts.allLibraries and not opts.libraries: - libraries = sections_lst - elif not opts.allLibraries and opts.libraries: - libraries = opts.libraries - elif opts.allLibraries and opts.libraries: - # If allLibraries is used then any libraries listed will be excluded + if opts.libraries: for library in opts.libraries: - sections_lst.remove(library) - libraries = sections_lst - - # Create Sync-From user account - plexFrom = get_account(opts.userFrom[0], opts.userFrom[1]) - - # Go through list of users - for user in opts.userTo: - plexTo = get_account(user[0], user[1]) - if libraries: - # Go through Libraries - for library in libraries: - try: - print('Checking library: {}'.format(library)) - # Check library for watched items - section = plexFrom.library.section(library) - mark_watached(section, plexTo, user[0]) - except Exception as e: - if str(e).startswith('Unknown'): - print('Library ({}) does not have a watch status.'.format(library)) - elif str(e).startswith('(404)'): - print('Library ({}) not shared to user: {}.'.format(library, user)) - else: - print(e) - pass - # Check rating key from Tautulli - elif opts.ratingKey: - for key in opts.ratingKey: - item = plexTo.fetchItem(int(key)) - title = item.title.encode('utf-8') - print('Syncing watch status of {} to {}\'s account.'.format(title, user[0])) - item.markWatched() - else: - print('No libraries or rating key provided.') + if all_sections.get(serverFrom): + if all_sections.get(serverFrom).get(library): + libraries.append(all_sections.get(serverFrom).get(library)) + else: + print("No matching library name '{}'".format(library)) + exit() + + else: + print("No matching server name '{}'".format(serverFrom)) + exit() + + # If userFrom is Plex Admin + # if userFrom == plex_admin.account.title and serverFrom != "Tautulli" and opts.libraries: + # resource = plex_admin.admin_servers().get(serverFrom) + # print('Connecting {} to {}...'.format(userFrom, serverFrom)) + # server_connection = resource.connect() + # baseurl = server_connection._baseurl.split('.') + # url = ''.join([baseurl[0].replace('-', '.'), + # baseurl[-1].replace('direct', '')]) + # + # token = PLEX_TOKEN + # admin_connection = Plex(url=url, token=token) + # watchedFrom = admin_connection.server + if serverFrom != "Tautulli" and opts.libraries: + watchedFrom = check_users_access(userFrom, serverFrom, libraries) + + if libraries: + print("Finding watched items in libraries...") + plexTo = [] + + for user, server_name in opts.userTo: + plexTo.append([user, check_users_access(user, server_name, libraries)]) + + for _library in libraries: + watched_lst = [] + print("Checking {}'s library: '{}' watch statuses...".format(userFrom, _library.title)) + if tautulli_server: + while True: + # Getting all watched history for userFrom + tt_watched = tautulli_server.get_watched_history(userFrom, _library.key, start, count) + if all([tt_watched]): + start += count + for item in tt_watched: + watched_lst.append(Metadata(item)) + continue + elif not all([tt_watched]): + break + start += count + else: + # Check library for watched items + sectionFrom = watchedFrom.library.section(_library.title) + if _library.type == 'show': + for episode in sectionFrom.searchEpisodes(unwatched=False): + watched_lst.append(episode) + else: + for item in sectionFrom.search(unwatched=False): + watched_lst.append(item) + + for user in plexTo: + username, server = user + sync_watch_status(watched_lst, _library.title, server, username) + + elif opts.ratingKey and userFrom == "Tautulli" and serverFrom == "Tautulli": + print('Request from Tautulli notification agent to update watch status') + plexTo = [] + + for user, server_name in opts.userTo: + # Check access and connect + plexTo.append([user, check_users_access(user, server_name, libraries)]) + + for user in plexTo: + username, server = user + item = Metadata(tautulli_server.get_metadata(opts.ratingKey)) + sync_watch_status([item], item.libraryName, server, username) + + else: + print("You aren't using this script correctly... bye!") \ No newline at end of file