I've just released version 1.0 of the Transmission plugin for the Plex Media Server. As with all good software projects, there were actually many releases before 1.0 but I thought this was the right time to write up a walk-through of what the code looks like and does. I didn't write the initial version of the plugin but I've maintained it since v0.7 and now pretty much rewritten everything a couple of times. I'll post my walkthrough in a few installments because it's quite long.
This plugin is built to be compatible with Plex Plugin Framework v1 which initially looked like quite a major change from the previous version of the framework but isn't really that different. For Plex plugins, every time an action is performed, the system generates a URL and passes it down through its own menu structure until it gets to a plugin that handles that URL. The plugin then deals with the info in the URL however it likes. In the previous versions of this plugin, the URL was parsed manually and split into strings separated by '/'
For example the URL:
/video/transmission/id/status/action/
would first cause Plex to look in its 'video' menu item and find the plugin that said it could handle 'transmission' URLs. The plugin would take the rest of the string and separate it out. First contact the Transmission application, ask for all information about the torrent called 'id', check its 'status' and then perform the 'action' if possible.
The new plugin does pretty much the same except I no longer manually parse the URL. The plugin still registers with Plex to say it can handle URLs starting '/video/transmission' but then it passes functions through instead of URL-catching menu items. If you're familiar with JavaScript, it's like passing an anonymous function to handle something instead of catching the event manually.
Anyway, here's the python code with a running commentary:
Imports
First we import the Plex Media Server framework:
from PMS import *
from PMS.Objects import *
from PMS.Shortcuts import *
These are a couple of handy functions from the very first version of the plugin which make the outputs much more readable.
from texttime import prettyduration
from textbytes import prettysize
This next line actually causes Plex to issue a warning. These libraries won't all be available in the next version of the framework. Instead of urllib and urllib2, developers are to use the built-in HTTP module. Unfortunately, HTTP doesn't allow access to the headers of responses and the Transmission authentication system relies on exchanging a session ID via headers.
import urllib,urllib2,base64
Declarations
Set up some constants to save typing later on
PLUGIN_PREFIX = "/video/transmission"
PLUGIN_TITLE = "Transmission"
This is the first call to the Localisation module. Plex plugins allow for complete string localisation via a JSON file (I've only included English in this version because my torrent-related German and Japanese are poor). This will look in the JSON file for the key 'Title' and return whatever value is associated with it (or 'Title' if there is none).
NAME = L('Title')
More shorthand
ART = 'art-default.jpg'
ICON = 'icon-default.png'
SETTINGS = 'settings-hi.png'
PAUSE = 'pause-hi.png'
RESUME = 'resume-hi.png'
SEARCH = 'search-hi.png'
TV = 'tv-hi.png'
TRANSMISSION_WAITING = 1
TRANSMISSION_CHECKING = 2
TRANSMISSION_DOWNLOADING = 4
TRANSMISSION_SEEDING = 8
TRANSMISSION_PAUSED = 16
Definitions
Required
Here is where we start the plugin code. This is one of the standard functions which gets called when the plugin is initialised.
def Start():
Tell Plex we can handle '/video/transmission' URLs and that our main function is called 'MainMenu'
Plugin.AddPrefixHandler(
PLUGIN_PREFIX,
MainMenu,
PLUGIN_TITLE,
ICON,
ART)
MediaContainer.art = R(ART)
MediaContainer.title1 = NAME
DirectoryItem.thumb = R(ICON)
Another standard function, this handles the preferences. To connect to Transmission, you need the URL and port it is running on (127.0.0.1:9091 if it's on the same machine as the Plex Media Server) and the username and password if you have set them.
def CreatePrefs():
Prefs.Add(id='hostname', type='text', default='127.0.0.1:9091', label='Hostname')
Prefs.Add(id='username', type='text', default='', label='Username')
Prefs.Add(id='password', type='text', default='', label='Password', option='hidden')
This is called immediately after the preferences dialog is submitted. This is the most basic checking you can do but it could include a call to Transmission to verify the info provided.
def ValidatePrefs():
u = Prefs.Get('username')
p = Prefs.Get('password')
h = Prefs.Get('hostname')
if( h ):
return MessageContainer(
"Success",
"Info provided is ok"
)
else:
return MessageContainer(
"Error",
"You need to provide url, username, and password"
)
You'll notice the return here is a MessageContainer. That's Plex's version of an alert. It doesn't generate a new page, just pops up a little window.
Custom
That was the end of the predefined functions, the plugin proper starts here. As Transmission requires a username, password and a short-lived session ID (since Transmission v1.53) to perform actions, we define a function which will attempt to make a connection with just username & password. Transmission will then send back a 409 Conflict response to basically say "Urk, that's not quite right. If you want to talk to me, you'll need this:" and give us our session ID in a header.
def GetSession():
h = Prefs.Get('hostname')
u = Prefs.Get('username')
p = Prefs.Get('password')
url = "http://%s/transmission/rpc/" % h
request = { "method" : "session-get" }
headers = {}
if( u and p and h):
headers["Authorization"] = "Basic %s" %
(base64.encodestring("%s:%s" % (u, p))[:-1])
try:
body = urllib2.urlopen(
urllib2.Request(
url,
JSON.StringFromObject(request),
headers
)
).read()
except urllib2.HTTPError, e:
if e.code == 401 or e.code == 403:
return L('ErrorInvalidUsername'), {}
return e.hdrs['X-Transmission-Session-Id']
except:
return L('ErrorNotRunning'), {}
Once the HTTP module allows access to returned headers, we will be able to use something like this to set global authorisation once and forget about it:
response = HTTP.Request(
url,
{ "method" : "session-get" },
headers={},
cacheTime=None
)
HTTP.SetPassword(h,u,p)
HTTP.SetHeader(
'X-Transmission-Session-Id',
response.headers['X-Transmission-Session-Id']
)
Remote Transmission Calls
This uses the RPC API of Transmission to do everything we need. We pass into the function 'What we want to do' and 'Who we want it done to' basically.
def RTC(method, arguments = {}, headers = {}):
h = Prefs.Get('hostname')
u = Prefs.Get('username')
p = Prefs.Get('password')
url = "http://%s/transmission/rpc/" % h
session_id = GetSession()
request = {
"method": method,
"arguments": arguments
}
Setup authentication here because, even though we've already gotten the session ID, it's useless if we don't actually use it.
if( u and p ):
headers["Authorization"] = "Basic %s" %
(base64.encodestring("%s:%s" % (u, p))[:-1])
headers["X-Transmission-Session-Id"] = session_id
Now that we've built our instruction, throw it at Transmission and see what comes back.
try:
body = urllib2.urlopen(
urllib2.Request(
url,
JSON.StringFromObject(request),
headers)
).read()
except urllib2.HTTPError, e:
if e.code == 401 or e.code == 403:
return L('ErrorInvalidUsername'), {}
return "Error reading response from Transmission", {}
except urllib2.URLError, e:
return e.reason[1], {}
result = JSON.ObjectFromString(body)
We don't do error handling here as we want this function to be as generic as possible so we send anything we receive straight back to the calling function.
if result["result"] == "success":
result["result"] = None
if result["arguments"] == None:
result["arguments"] = {}
return result["result"], result["arguments"]
Menus
Right, we've got our helper methods set up, we're ready to make our first menu. This is the main one we mentioned earlier.
def MainMenu():
You can set your menu screen to be laid out as “List”, “InfoList”, “MediaPreview”, “Showcase”, “CoverFlow”, “PanelStream” or “WallStream”. I'm keeping it simple here. Also, there's an extra call to GetSession
just to check everything's fine and wake the app up.
dir = MediaContainer(viewGroup="List")
GetSession()
Pretty much all the menu items throughout the rest of this plugin are added using the same code which boils down to:
dir.Append(
Function(
DirectoryItem(
FunctionName,
"Pretty Menu Item Name",
subtitle="Short subtitle",
summary="Longer menu item summary and description",
thumb=R(ICON),
art=R(ART)
)
)
)
Starting in the middle, this reads as:
- Create a
DirectoryItem
. - When this item is selected, use the function
FunctionName
to handle it. - Display the text
"Pretty Menu Item Name"
for this item - Display the text
"Short subtitle"
underneath this item (or None) - Display the text
"Longer menu item summary and description"
for this item if required (or None) - Use the resource called ICON (mentioned above) as the icon for this item
- Use the resource ART as the background
- This is a
Function
menu item - Finally,
Append
this to the current menu
The first two main menu items are built exactly like that:
dir.Append(
Function(
DirectoryItem(
TorrentList,
"Torrents",
subtitle=None,
summary="View torrent progress and control your downloads.",
thumb=R(ICON),
art=R(ART)
)
)
)
dir.Append(
Function(
DirectoryItem(
SearchTorrents,
"Search for a torrent",
subtitle=None,
summary="Browse the TV shows directory or search for files to download.",
thumb=R(SEARCH),
art=R(ART)
)
)
)
This is a special 'Preferences' item that will call the Prefs functions defined at the top.
dir.Append(
PrefsItem(
title="Preferences",
subtitle="Set Transmission access details",
summary="Make sure Transmission is running and 'Remote access' is enabled then enter the access details here.",
thumb=R(SETTINGS)
)
)
Send the directory (or Menu) back to Plex
return dir
The rest of the code deals with torrent control and some clever built-in site scraping functionality which I'll cover later.