About 8 months ago I set up a Plex server to fuel my TV addiction. One of my friends did the same and we share access to our servers. We have pretty similar tastes in TV and I’m not too careful about which server I connect to when I need to get my fix.

This brings up a problem though, the watched status does not sync between servers so I have to remember where I was in a show when I swap back and forth between servers. Fortunately, Plex has an API, so let’s write a script to sync them automatically.

Even better, someone has written a python wrapper for the API: https://github.com/pkkid/python-plexapi

Before I begin, I’ve made a few assumptions:

Watched status trumps unwatched status. If I’ve watched an episode on either, it should appear as watched on both

It’s possible that we may not have all the same episodes as each other.

This is a relatively simple problem. It seems that there should only be a few steps:

Connect to both servers Find all the shows that the two servers have in common Loop through that list and get the episodes for each show For every episode, if it’s been watched on either server, mark it watched on the other

Now that we’ve got an idea of what we want to do, let’s give it a shot in the REPL. The documentation in the GitHub readme shows us how to do pretty much all of the things we want so we’ll follow it.

$ mkvirtualenv -p /usr/bin/python3 plex_sync

$ pip install plexapi

$ python Python 3.5.2 (default, Nov 17 2016, 17:05:23)

[GCC 5.4.0 20160609] on linux

Type “help”, “copyright”, “credits” or “license” for more information.

>>> from plexapi.myplex import MyPlexAccount

>>> username = ‘username’

>>> password = ‘password’

>>> account = MyPlexAccount(username, password)

Traceback (most recent call last):

File “<stdin>”, line 1, in <module>

File “/home/nolan/.virtualenvs/plex/lib/python3.5/site-packages/plexapi/myplex.py”, line 20, in __init__

self.authenticationToken = data.attrib.get(‘authenticationToken’)

AttributeError: ‘str’ object has no attribute ‘attrib’

>>>

Hmmm. That probably wasn’t supposed to happen. Looks like the problem is coming from plexapi.myplex Let’s dive in and see what’s going on here.

According to the exception, we’re looking for line 20 in myplex.py

$ find -name “myplex.py” | xargs cat — | head -25 | tail -10

“”” MyPlex account and profile information. The easiest way to build

this object is by calling the staticmethod :func:`~plexapi.myplex.MyPlexAccount.signin`

with your username and password. This object represents the data found Account on

the myplex.tv servers at the url lass MyPlexAccount(PlexObject):“”” MyPlex account and profile information. The easiest way to buildthis object is by calling the staticmethod :func:`~plexapi.myplex.MyPlexAccount.signin`with your username and password. This object represents the data found Account onthe myplex.tv servers at the url https://plex.tv/users/account Parameters:

username (str): Your MyPlex username.

password (str): Your MyPlex password.

session (requests.Session, optional): Use your own session object if you want to

Well that’s a problem. The constructor for MyPlexAccount isn’t at line 20 like it says in the exception. The code on PyPi must be different. What does it look like?



$ tar -xvzf PlexAPI-2.0.2.tar.gz

$ find -name “myplex.py” | xargs cat — | head -25 | tail -10

BASEURL = ‘

SIGNIN = ‘



def __init__(self, data, initpath=None):

self.authenticationToken = data.attrib.get(‘authenticationToken’)

self.certificateVersion = data.attrib.get(‘certificateVersion’)

self.cloudSyncDevice = data.attrib.get(‘cloudSyncDevice’)

self.email = data.attrib.get(‘email’)

self.guest = utils.cast(bool, data.attrib.get(‘guest’))

self.home = utils.cast(bool, data.attrib.get(‘home’)) $ pip download plexapi$ tar -xvzf PlexAPI-2.0.2.tar.gz$ find -name “myplex.py” | xargs cat — | head -25 | tail -10BASEURL = ‘ https://plex.tv/users/account' SIGNIN = ‘ https://my.plexapp.com/users/sign_in.xml' def __init__(self, data, initpath=None):self.authenticationToken = data.attrib.get(‘authenticationToken’)self.certificateVersion = data.attrib.get(‘certificateVersion’)self.cloudSyncDevice = data.attrib.get(‘cloudSyncDevice’)self.email = data.attrib.get(‘email’)self.guest = utils.cast(bool, data.attrib.get(‘guest’))self.home = utils.cast(bool, data.attrib.get(‘home’))

Well that’s a little better. The constructor is where it’s supposed to be but it doesn’t take a username and password. What’s going on here?

It took longer than I’d like to admit but eventually I realized that the documentation on PyPi was different than it was on GitHub. I needed to use MyPlexAccount.signin() instead of the MyPlexAccount constructor. Attempt number two:

$ python Python 3.5.2 (default, Nov 17 2016, 17:05:23)

[GCC 5.4.0 20160609] on linux

Type “help”, “copyright”, “credits” or “license” for more information.

>>> from plexapi.myplex import MyPlexAccount

>>> username = ‘username’

>>> password = ‘password’

>>> account = MyPlexAccount.signin(username, password)

>>>

Progress! Next, we need to create connections to both servers.

>>> server_1_name = ‘server_1_name’

>>> server_2_name = ‘server_2_name’

>>> server_1 = account.resource(server_1_name)

>>> server_2 = account.resource(server_2_name)

>>> conn_1 = server_1.connect()

>>> conn_2 = server_2.connect()

Traceback (most recent call last):

File “<stdin>”, line 1, in <module>

File “/home/nolan/.virtualenvs/plex/lib/python3.5/site-packages/plexapi/myplex.py”, line 157, in connect

raise NotFound(‘Unable to connect to resource: %s’ % self.name)

plexapi.exceptions.NotFound: Unable to connect to resource: server_2_name

Uh-oh. Could this mean that I can’t connect to servers I don’t own through the API? That doesn’t make sense. I can see a bunch of information that isn’t available in the web interface about my friend’s server.

>>> dir(server_2)

[‘BASEURL’, ‘__class__’, ‘__delattr__’, ‘__dict__’, ‘__dir__’, ‘__doc__’, ‘__eq__’, ‘__format__’, ‘__ge__’, ‘__getattribute__’, ‘__gt__’, ‘__hash__’, ‘__init__’, ‘__le__’, ‘__lt__’, ‘__module__’, ‘__ne__’, ‘__new__’, ‘__reduce__’, ‘__reduce_ex__’, ‘__repr__’, ‘__setattr__’, ‘__sizeof__’, ‘__str__’, ‘__subclasshook__’, ‘__weakref__’, ‘_connect’, ‘accessToken’, ‘clientIdentifier’, ‘connect’, ‘connections’, ‘createdAt’, ‘device’, ‘home’, ‘lastSeenAt’, ‘name’, ‘owned’, ‘platform’, ‘platformVersion’, ‘presence’, ‘product’, ‘productVersion’, ‘provides’, ‘synced’]

I probably wouldn’t get things like accessToken or platform if I wasn’t allowed to connect to it. Why would it appear as a resource under my account? Let’s dig into the plexapi code again.

I was able to create a MyPlexResource object ( server_2 ) just fine and the exception comes from the connect function so let’s start there. The code for connect looks like this:

Copyright Michael Shepanski

Well that forcelocal thing looks like a problem. From the comment it appears to be that the API only checks non-local connections for resources we don’t own. That doesn’t appear to be what the code does though. It’s filtering out connections that aren’t owned and aren’t local. That isn’t going to work. I want to connect to my friend’s server which I don’t own and is not local. I’ll bet if we just remove that if forcelocal(c) from lines 6 and 7, we might then be able to connect to server_2 . With if forcelocal(c) removed:

$ python Python 3.5.2 (default, Nov 17 2016, 17:05:23)

[GCC 5.4.0 20160609] on linux

Type “help”, “copyright”, “credits” or “license” for more information.

>>> from plexapi.myplex import MyPlexAccount

>>> username = ‘username’

>>> password = ‘password’

>>> account = MyPlexAccount.signin(username, password)

>>> server_1_name = ‘server_1_name’

>>> server_2_name = ‘server_2_name’

>>> server_1 = account.resource(server_1_name)

>>> server_2 = account.resource(server_2_name)

>>> conn_1 = server_1.connect()

>>> conn_2 = server_2.connect()

>>>

AHA! That seems to have solved the problem. Let’s make sure that it actually connected properly to the server we intended by finding a show he has but I don’t. Relying on the (correct this time) documentation:

>>> conn_2.library.section(‘TV Shows’).get(‘Police Squad!’).episodes()

[<Episode:13132:b’A.Substantial.Gift.(‘>, <Episode:13133:b’Ring.of.Fear.(A.Dang’>, <Episode:13134:b’The.Butler.Did.It.(A’>, <Episode:13135:b’Revenge.and.Remorse.’>, <Episode:13136:b’Rendezvous.at.Big.Gu’>, <Episode:13137:b’Testimony.of.Evil.(D’>]

Looks like it works. Now we have to do steps 2 through 4. Hopefully we’re past the tricky part now. The documentation doesn’t specify how to list all of the shows in a library but I suspect searching for nothing will return everything.

>>> len(conn_2.library.section(‘TV Shows’).search())

58

That did the trick. The easiest way to find all the common shows between both servers will be to use sets and find the intersection of the set of shows on each server.

>>> server_1_shows = set(list(map((lambda x: x.title), conn_1.library.section(‘TV Shows’).search())))

>>> server_2_shows = set(list(map((lambda x: x.title), conn_2.library.section(‘TV Shows’).search())))

>>> common_shows = server_1_shows & server_2_shows

>>>

OK, we’ve got the names of all the shows the two servers have in common. The next two steps are to iterate over that list and sync the watched status for the episodes of each show. This isn’t quite straightforward because the episodes for each show are in a list and I don’t know if the order is guaranteed. It’s also possible that an episode only exists on one of the servers. This means we’re stuck with a linear search to match episodes.

That worked so let’s take it out of the REPL and put it all in a python file so that we can make some adjustments.

We succeeded in doing what we set out to do but it was pretty slow. How can we speed it up? Well the call to markWatched requires a network call and we’re calling it more often than we need. If an episode is already marked as watched on both servers we’re still calling markWatched . That’s going to be a lot of unnecessary network calls as we run this tool in the future. Let’s make a quick change.

That should just about do it. All that’s left to do is to submit a pull request for the change I made to plexapi. Until then, I’ve forked the repository and applied the fix on my GitHub. The final script: