total rewrite while resolving #146.

Now able to read watched status from Tautulli and apply to any user
on any owned server.
This commit is contained in:
Blacktwin 2019-03-14 13:07:41 -04:00
parent 80557f4a61
commit e605979a16

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python #!/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 Author: Blacktwin
Requires: requests, plexapi, argparse Requires: requests, plexapi, argparse
@ -31,32 +31,35 @@ Script Arguments:
Taultulli > Settings > Notification Agents > New Script > Script Arguments: Taultulli > Settings > Notification Agents > New Script > Script Arguments:
Select: Notify on Watched 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 Save
Close Close
Example: 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 sync_watch_status.py --userFrom USER1=Server1 --userTo USER2=Server1 --libraries Movies
- Synced watch status of {title from library} to {USER2}'s account. - 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 sync_watch_status.py --userFrom USER1=Server2 --userTo USER2=Server1 USER3=Server1 --libraries Movies "TV Shows"
- Synced watch status of {title from library} to {USER2 or USER3}'s account. - Synced watch status from Server2 {title from library} to {USER2 or USER3}'s account on Server1.
Excluding; sync_watch_status.py --userFrom USER1=Tautulli --userTo USER2=Server1 USER3=Server2 --libraries Movies "TV Shows"
--libraries becomes excluded if --allLibraries is set - Synced watch statuses from Tautulli {title from library} to {USER2 or USER3}'s account on selected servers.
sync_watch_status.py --userFrom USER1=Server --userTo USER2=Server --allLibraries --libraries Movies
- Shared [all libraries but Movies] with USER.
""" """
import sys
import requests
import argparse import argparse
from plexapi.myplex import MyPlexAccount 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 # Using CONFIG file
PLEX_TOKEN = '' PLEX_TOKEN = ''
@ -64,206 +67,441 @@ TAUTULLI_URL = ''
TAUTULLI_APIKEY = '' TAUTULLI_APIKEY = ''
if not PLEX_TOKEN: if not PLEX_TOKEN:
PLEX_TOKEN = CONFIG.data['auth'].get('server_token', '') PLEX_TOKEN = CONFIG.data['auth'].get('server_token')
if not TAUTULLI_URL: if not TAUTULLI_URL:
TAUTULLI_URL = CONFIG.data['auth'].get('tautulli_baseurl') TAUTULLI_URL = CONFIG.data['auth'].get('tautulli_baseurl')
if not TAUTULLI_APIKEY: if not TAUTULLI_APIKEY:
TAUTULLI_APIKEY = CONFIG.data['auth'].get('tautulli_apikey') TAUTULLI_APIKEY = CONFIG.data['auth'].get('tautulli_apikey')
VERIFY_SSL = False
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))
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 Parameters
---------- ----------
user: str user: str
User's name server_name: str
server: str libraries: list
Server's name
Returns Returns
------- -------
User server class server_connection: class
""" """
print('Checking {} on {}'.format(user, server)) try:
if user_server_dict['data'][user]['servers'].get(server): _user = plex_admin.users_access().get(user)
user_server = user_server_dict['data'][user]['servers'].get(server) for access in _user['access']:
baseurl = user_server._baseurl.split('.') server = access.get("server")
url = ''.join([baseurl[0].replace('-', '.'), # Check user access to server
baseurl[-1].replace('direct', '')]) if server.get(server_name):
if user == MyPlexAccount(PLEX_TOKEN).title: server_obj = server.get(server_name)
token = PLEX_TOKEN # If syncing by libraries, check library access
else: if libraries:
userAccount = user_server.myPlexAccount().user(user) library_check = any(lib.title in access.get("sections").keys() for lib in libraries)
token = userAccount.get_token(user_server.machineIdentifier) # Check user access to library
account = PlexServer(baseurl=url, token=token, session=sess) if library_check:
return account server_connection = connect_to_server(server_obj, _user['account'])
else: return server_connection
print('{} is not shared to {}'.format(user, server))
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() exit()
def mark_watached(sectionFrom, accountTo, userTo): def sync_watch_status(watched, section, accountTo, userTo):
""" """
Parameters Parameters
---------- ----------
sectionFrom: class watched: list
section: class
Section class of sync from server Section class of sync from server
accountTo: class userTo: str
User's server class of sync to user User's server class of sync to user
""" """
# Check sections for watched items # Check sections for watched items
print('Marking watched...') print('Marking watched...')
sectionTo = accountTo.library.section(sectionFrom.title) sectionTo = accountTo.library.section(section)
for item in sectionFrom.search(unwatched=False): for item in watched:
title = item.title.encode('utf-8')
try: try:
# Check movie media type # .get retrieves a partial object
if item.type == 'movie': if item.type == 'episode':
watch_check = sectionTo.get(item.title) show_name = item.grandparentTitle
fetch_check = sectionTo.fetchItem(watch_check.key) ep_name = item.title
if not fetch_check.isWatched: title = "{} {}".format(show_name, ep_name)
fetch_check.markWatched() show = sectionTo.get(show_name)
print('Synced watch status of {} to {}\'s account.'.format(title, userTo)) watch_check = show.episode(season=item.parentIndex, episode=item.index)
# Check show media type else:
elif item.type == 'show': title = item.title
for episode in sectionFrom.searchEpisodes(unwatched=False, title=title): watch_check = sectionTo.get(title)
ep_title = episode.title.encode('utf-8') # .fetchItem retrieves a full object
watch_check = sectionTo.get(item.title) fetch_check = sectionTo.fetchItem(watch_check.key)
fetch_check = sectionTo.fetchItem(watch_check.key) # If item is already watched ignore
if not fetch_check.isWatched: if not fetch_check.isWatched:
fetch_check.markWatched() # todo-me should watched count be synced?
print('Synced watch status of {} - {} to {}\'s account.'.format(title, ep_title, userTo)) fetch_check.markWatched()
except Exception: print("Synced watched status of {} to account {}...".format(title, userTo))
except Exception as e:
print(e)
pass pass
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Sync watch status from one user to others.", parser = argparse.ArgumentParser(description="Sync watch status from one user to others.",
formatter_class=argparse.RawTextHelpFormatter) formatter_class=argparse.RawTextHelpFormatter)
requiredNamed = parser.add_argument_group('required named arguments') parser.add_argument('--libraries', nargs='*', metavar='library',
parser.add_argument('--libraries', nargs='*', choices=sections_lst, metavar='library', help='Libraries to scan for watched content.')
help='Space separated list of case sensitive names to process. Allowed names are: \n' parser.add_argument('--ratingKey', nargs="?", type=str,
'(choices: %(choices)s)')
parser.add_argument('--allLibraries', action='store_true',
help='Select all libraries.')
parser.add_argument('--ratingKey', nargs=1,
help='Rating key of item whose watch status is to be synced.') 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, 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') help='Select user and server to sync from')
requiredNamed.add_argument('--userTo', nargs='*', metavar='user=server', required=True, requiredNamed.add_argument('--userTo', nargs='*', metavar='user=server', required=True,
type=lambda kv: kv.split("="), type=lambda kv: kv.split("="),
help='Select user and server to sync to.') help='Select user and server to sync to.')
opts = parser.parse_args() 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 # Defining libraries
libraries = '' if opts.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
for library in opts.libraries: for library in opts.libraries:
sections_lst.remove(library) if all_sections.get(serverFrom):
libraries = sections_lst if all_sections.get(serverFrom).get(library):
libraries.append(all_sections.get(serverFrom).get(library))
# Create Sync-From user account else:
plexFrom = get_account(opts.userFrom[0], opts.userFrom[1]) print("No matching library name '{}'".format(library))
exit()
# Go through list of users
for user in opts.userTo: else:
plexTo = get_account(user[0], user[1]) print("No matching server name '{}'".format(serverFrom))
if libraries: exit()
# Go through Libraries
for library in libraries: # If userFrom is Plex Admin
try: # if userFrom == plex_admin.account.title and serverFrom != "Tautulli" and opts.libraries:
print('Checking library: {}'.format(library)) # resource = plex_admin.admin_servers().get(serverFrom)
# Check library for watched items # print('Connecting {} to {}...'.format(userFrom, serverFrom))
section = plexFrom.library.section(library) # server_connection = resource.connect()
mark_watached(section, plexTo, user[0]) # baseurl = server_connection._baseurl.split('.')
except Exception as e: # url = ''.join([baseurl[0].replace('-', '.'),
if str(e).startswith('Unknown'): # baseurl[-1].replace('direct', '')])
print('Library ({}) does not have a watch status.'.format(library)) #
elif str(e).startswith('(404)'): # token = PLEX_TOKEN
print('Library ({}) not shared to user: {}.'.format(library, user)) # admin_connection = Plex(url=url, token=token)
else: # watchedFrom = admin_connection.server
print(e) if serverFrom != "Tautulli" and opts.libraries:
pass watchedFrom = check_users_access(userFrom, serverFrom, libraries)
# Check rating key from Tautulli
elif opts.ratingKey: if libraries:
for key in opts.ratingKey: print("Finding watched items in libraries...")
item = plexTo.fetchItem(int(key)) plexTo = []
title = item.title.encode('utf-8')
print('Syncing watch status of {} to {}\'s account.'.format(title, user[0])) for user, server_name in opts.userTo:
item.markWatched() plexTo.append([user, check_users_access(user, server_name, libraries)])
else:
print('No libraries or rating key provided.') 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!")