514151e69b
Issue reported in Plex forums.
563 lines
20 KiB
Python
563 lines
20 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""Sync the watch status from one Plex or Tautulli user to other users across any owned server.
|
|
|
|
Author: Blacktwin
|
|
Requires: requests, plexapi, argparse
|
|
|
|
Enabling Scripts in Tautulli:
|
|
Taultulli > Settings > Notification Agents > Add a Notification Agent > Script
|
|
|
|
Configuration:
|
|
Taultulli > Settings > Notification Agents > New Script > Configuration:
|
|
|
|
Script Name: sync_watch_status.py
|
|
Set Script Timeout: default
|
|
Description: Sync watch status
|
|
Save
|
|
|
|
Triggers:
|
|
Taultulli > Settings > Notification Agents > New Script > Triggers:
|
|
|
|
Check: Notify on Watched
|
|
Save
|
|
|
|
Conditions:
|
|
Taultulli > Settings > Notification Agents > New Script > Conditions:
|
|
|
|
Set Conditions: [{username} | {is} | {user_to_sync_from} ]
|
|
Save
|
|
|
|
Script Arguments:
|
|
Taultulli > Settings > Notification Agents > New Script > Script Arguments:
|
|
|
|
Select: Notify on Watched
|
|
Arguments: --ratingKey {rating_key} --userFrom Tautulli=Tautulli --userTo "Username2=Server1" "Username3=Server1"
|
|
|
|
Save
|
|
Close
|
|
|
|
Example:
|
|
Set in Tautulli in script notification agent (above) or run manually (below)
|
|
|
|
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=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.
|
|
|
|
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.
|
|
|
|
sync_watch_status.py --userFrom USER1=Tautulli --userTo USER2=Server1 USER3=Server2 --ratingKey 1234
|
|
- Synced watch statuse of rating key 1234 from USER1's Tautulli history to {USER2 or USER3}'s account
|
|
on selected servers.
|
|
**Rating key must be a movie or episode. Shows and Seasons not support.... yet.
|
|
"""
|
|
import argparse
|
|
from plexapi.myplex import MyPlexAccount
|
|
from plexapi.server import PlexServer
|
|
from plexapi.server import CONFIG
|
|
from requests import Session
|
|
from requests.adapters import HTTPAdapter
|
|
from requests.exceptions import RequestException
|
|
|
|
# Using CONFIG file
|
|
PLEX_TOKEN = ''
|
|
TAUTULLI_URL = ''
|
|
TAUTULLI_APIKEY = ''
|
|
|
|
if not PLEX_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')
|
|
|
|
VERIFY_SSL = False
|
|
|
|
|
|
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=None, section_id=None, rating_key=None, start=None, length=None):
|
|
"""Call Tautulli's get_history api endpoint."""
|
|
payload = {"order_column": "full_title",
|
|
"order_dir": "asc"}
|
|
if user:
|
|
payload["user"] = user
|
|
if section_id:
|
|
payload["section_id"] = section_id
|
|
if rating_key:
|
|
payload["rating_key"] = rating_key
|
|
if start:
|
|
payload["start"] = start
|
|
if length:
|
|
payload["lengh"] = length
|
|
|
|
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):
|
|
"""Get all owned servers.
|
|
|
|
Returns
|
|
-------
|
|
data: dict
|
|
|
|
"""
|
|
resources = {}
|
|
for resource in self.account.resources():
|
|
if 'server' in [resource.provides] and resource.owned is True:
|
|
resources[resource.name] = resource
|
|
|
|
return resources
|
|
|
|
def all_users(self):
|
|
"""Get all users.
|
|
|
|
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):
|
|
"""Get all sections from all owned servers.
|
|
|
|
Returns
|
|
-------
|
|
data: dict
|
|
|
|
"""
|
|
data = {}
|
|
servers = self.admin_servers()
|
|
print("Connecting to admin server(s) for access 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):
|
|
"""Get users access across all owned servers.
|
|
|
|
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 is 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):
|
|
"""Find server url and connect using user token.
|
|
|
|
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(access, user, server_name, libraries=None):
|
|
"""Check user's access to server. If allowed connect.
|
|
|
|
Parameters
|
|
----------
|
|
access: dict
|
|
user: dict
|
|
server_name: str
|
|
libraries: list
|
|
|
|
Returns
|
|
-------
|
|
server_connection: class
|
|
|
|
"""
|
|
try:
|
|
_user = 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 sync_watch_status(watched, section, accountTo, userTo, same_server=False):
|
|
"""Sync watched status between two users.
|
|
|
|
Parameters
|
|
----------
|
|
watched: list
|
|
List of watched items either from Tautulli or Plex
|
|
section: str
|
|
Section title of sync from server
|
|
accountTo: class
|
|
User's account that will be synced to
|
|
userTo: str
|
|
User's server class of sync to user
|
|
same_server: bool
|
|
Are serverFrom and serverTo the same
|
|
|
|
"""
|
|
print('Marking watched...')
|
|
sectionTo = accountTo.library.section(section)
|
|
for item in watched:
|
|
try:
|
|
if same_server:
|
|
fetch_check = sectionTo.fetchItem(item.ratingKey)
|
|
else:
|
|
if item.type == 'episode':
|
|
show_name = item.grandparentTitle
|
|
show = sectionTo.get(show_name)
|
|
watch_check = show.episode(season=int(item.parentIndex), episode=int(item.index))
|
|
else:
|
|
title = item.title
|
|
watch_check = sectionTo.get(title)
|
|
# .get retrieves a partial object
|
|
# .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()
|
|
title = fetch_check._prettyfilename()
|
|
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)
|
|
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("="), 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 = ''
|
|
same_server = False
|
|
count = 25
|
|
start = 0
|
|
plex_admin = Plex(PLEX_TOKEN)
|
|
plex_access = plex_admin.users_access()
|
|
|
|
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:
|
|
# Pull all libraries from Tautulli
|
|
_sections = {}
|
|
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:
|
|
# Pull all libraries from admin access dict
|
|
admin_access = plex_access.get(plex_admin.account.title).get("access")
|
|
for server in admin_access:
|
|
if server.get("server").get(serverFrom):
|
|
all_sections[serverFrom] = server.get("sections")
|
|
|
|
# Defining libraries
|
|
if opts.libraries:
|
|
for library in opts.libraries:
|
|
if all_sections.get(serverFrom).get(library):
|
|
libraries.append(all_sections.get(serverFrom).get(library))
|
|
else:
|
|
print("No matching library name '{}'".format(library))
|
|
exit()
|
|
|
|
# If server is Plex and synciing libraries, check access
|
|
if serverFrom != "Tautulli" and libraries:
|
|
print("Checking {}'s access to {}".format(userFrom, serverFrom))
|
|
watchedFrom = check_users_access(plex_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(plex_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(user=userFrom, section_id=_library.key,
|
|
start=start, length=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':
|
|
watched_lst = sectionFrom.search(libtype='episode', unwatched=False)
|
|
else:
|
|
watched_lst = sectionFrom.search(unwatched=False)
|
|
|
|
for user in plexTo:
|
|
username, server = user
|
|
if server == serverFrom:
|
|
same_server = True
|
|
sync_watch_status(watched_lst, _library.title, server, username, same_server)
|
|
|
|
elif opts.ratingKey and serverFrom == "Tautulli":
|
|
plexTo = []
|
|
watched_item = []
|
|
|
|
if userFrom != "Tautulli":
|
|
print("Request manually triggered to update watch status")
|
|
tt_watched = tautulli_server.get_watched_history(user=userFrom, rating_key=opts.ratingKey)
|
|
if tt_watched:
|
|
watched_item = Metadata(tautulli_server.get_metadata(opts.ratingKey))
|
|
else:
|
|
print("Rating Key {} was not reported as watched in Tautulli for user {}".format(opts.ratingKey, userFrom))
|
|
exit()
|
|
|
|
elif userFrom == "Tautulli":
|
|
print("Request from Tautulli notification agent to update watch status")
|
|
watched_item = Metadata(tautulli_server.get_metadata(opts.ratingKey))
|
|
|
|
for user, server_name in opts.userTo:
|
|
# Check access and connect
|
|
plexTo.append([user, check_users_access(plex_access, user, server_name, libraries)])
|
|
|
|
for user in plexTo:
|
|
username, server = user
|
|
sync_watch_status([watched_item], watched_item.libraryName, server, username)
|
|
|
|
elif opts.ratingKey and serverFrom != "Tautulli":
|
|
plexTo = []
|
|
watched_item = []
|
|
|
|
if userFrom != "Tautulli":
|
|
print("Request manually triggered to update watch status")
|
|
watchedFrom = check_users_access(plex_access, userFrom, serverFrom)
|
|
watched_item = watchedFrom.fetchItem(int(opts.ratingKey))
|
|
if not watched_item.isWatched:
|
|
print("Rating Key {} was not reported as watched in Plex for user {}".format(opts.ratingKey,
|
|
userFrom))
|
|
exit()
|
|
else:
|
|
print("Use an actual user.")
|
|
exit()
|
|
|
|
for user, server_name in opts.userTo:
|
|
# Check access and connect
|
|
plexTo.append([user, check_users_access(plex_access, user, server_name, libraries)])
|
|
|
|
for user in plexTo:
|
|
username, server = user
|
|
library = server.library.sectionByID(watched_item.librarySectionID)
|
|
sync_watch_status([watched_item], library.title, server, username)
|
|
|
|
else:
|
|
print("You aren't using this script correctly... bye!")
|