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:
This is a relatively simple problem. It seems that there should only be a few steps:
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 linuxType “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.
git clone [email protected]:pkkid/python-plexapi.git
According to the exception, we’re looking for line 20 in myplex.py
$ find -name “myplex.py” | xargs cat — | head -25 | tail -10
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?
$ 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 linuxType “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 connectraise 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:
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 linuxType “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:
Hacker Noon is how hackers start their afternoons. We’re a part of the @AMIfamily. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.
To learn more, read our about page, like/message us on Facebook, or simply, tweet/DM @HackerNoon.
If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!