Source code for flexget.plugins.input.plex
"""Plugin for plex media server (www.plexapp.com)."""
import os
import re
from os.path import basename
from socket import gethostbyname
from xml.dom.minidom import parseString
import pendulum
from loguru import logger
from flexget import plugin
from flexget.entry import Entry
from flexget.event import event
from flexget.utils import requests
logger = logger.bind(name='plex')
[docs]
class InputPlex:
"""Use a plex media server (www.plexapp.com) tv section as an input.
================= ===========================================================================================
Option Description
================= ===========================================================================================
section Required parameter, numerical (/library/sections/<num>) or section name.
selection Can be set to different keys:
* ``all`` Default
* ``unwatched``
* ``recentlyAdded``
* ``recentlyViewed``
* ``recentlyViewedShows`` Series only.
``all`` and ``recentlyViewedShows`` will only produce a list of show names while the other
three will produce filename and download url.
token Plex access token, used to connect to PMS
username Myplex (http://my.plexapp.com) username, used to connect to shared PMS'.
password Myplex (http://my.plexapp.com) password, used to connect to shared PMS'.
server Host/IP of PMS to connect to.
lowercase_title Convert filename (title) to lower case.
strip_non_alpha Sanitize filename (title), stripping all non-alphanumeric letters.
Better to turn off in case of non-english titles.
strip_year Remove year from title, ex: Show Name (2012) 01x01 => Show Name 01x01.
Movies will have year added to their filename unless this is set.
strip_parens Remove information in parens from title, ex: Show Name (UK)(2012) 01x01 => Show Name 01x01.
original_filename Use filename stored in PMS instead of transformed name. lowercase_title and strip_year
will be ignored.
unwatched_only Request only unwatched media from PMS.
fetch What to download, can be set to the following values:
* ``file`` The file itself, default.
* ``art`` Series or movie art as configured in PMS
* ``cover`` Series cover for series, movie cover for movies.
* ``thumb`` Episode thumbnail, series only.
* ``season_cover`` Season cover, series only. If used in movies, movie cover will be set.
================= ===========================================================================================
Default parameters::
server: localhost
port: 32400
selection: all
lowercase_title: no
strip_non_alpha: yes
strip_year: yes
strip_parens: no
original_filename: no
unwatched_only: no
fetch: file
Example::
plex:
server: 192.168.1.23
section: 3
selection: recentlyAdded
fetch: series_art
"""
schema = {
'type': 'object',
'properties': {
'server': {'type': 'string', 'default': '127.0.0.1'},
'port': {'type': 'integer', 'default': 32400},
'username': {'type': 'string'},
'password': {'type': 'string'},
'token': {'type': 'string'},
'section': {'type': ['string', 'integer']},
'selection': {'type': 'string', 'default': 'all'},
'lowercase_title': {'type': 'boolean', 'default': False},
'strip_non_alpha': {'type': 'boolean', 'default': True},
'strip_year': {'type': 'boolean', 'default': True},
'strip_parens': {'type': 'boolean', 'default': False},
'original_filename': {'type': 'boolean', 'default': False},
'unwatched_only': {'type': 'boolean', 'default': False},
'fetch': {
'type': 'string',
'default': 'file',
'enum': ['file', 'art', 'cover', 'thumb', 'season_cover'],
},
},
'required': ['section'],
'not': {
'anyOf': [{'required': ['token', 'username']}, {'required': ['token', 'password']}]
},
'error_not': 'Cannot specify `username` and `password` with `token`',
'dependentRequired': {'username': ['password'], 'password': ['username']},
'additionalProperties': False,
}
[docs]
def prepare_config(self, config):
config['plexserver'] = config['server']
return self.plex_format_server(config)
[docs]
def plex_get_globalaccesstoken(self, config):
header = {'X-Plex-Client-Identifier': 'flexget'}
try:
r = requests.post(
'https://my.plexapp.com/users/sign_in.xml',
auth=(config['username'], config['password']),
headers=header,
)
except requests.RequestException as error:
raise plugin.PluginError(f'Could not log in to myplex! Error: {error}')
if 'Invalid email' in r.text:
raise plugin.PluginError('Myplex: invalid username and/or password!')
dom = parseString(r.text)
globalaccesstoken = dom.getElementsByTagName('authentication-token')[
0
].firstChild.nodeValue
if not globalaccesstoken:
raise plugin.PluginError('Myplex: could not find a server!')
logger.debug('Myplex: Got global accesstoken: {}', globalaccesstoken)
return globalaccesstoken
[docs]
def plex_get_accesstoken(self, config, globalaccesstoken=''):
accesstoken = None
if not globalaccesstoken:
globalaccesstoken = self.plex_get_globalaccesstoken(config)
if config['server'] in ('localhost', '127.0.0.1'):
logger.debug('Server using localhost. Global Token will be used')
return globalaccesstoken
try:
r = requests.get(
f'https://my.plexapp.com/pms/servers?X-Plex-Token={globalaccesstoken}'
)
except requests.RequestException as e:
raise plugin.PluginError(
'Could not get servers from my.plexapp.com using '
f'authentication-token: {globalaccesstoken}. ({e})'
)
dom = parseString(r.text)
for node in dom.getElementsByTagName('Server'):
if config['server'] in (
node.getAttribute('address'),
node.getAttribute('localAddresses'),
):
accesstoken = node.getAttribute('accessToken')
logger.debug('Got plextoken: {}', accesstoken)
if not accesstoken:
raise plugin.PluginError(
'Could not retrieve accesstoken for {}.'.format(config['server'])
)
return accesstoken
[docs]
def plex_format_server(self, config):
if gethostbyname(config['server']) != config['server']:
config['server'] = gethostbyname(config['server'])
return config
[docs]
def on_task_input(self, task, config):
config = self.prepare_config(config)
urlconfig = {}
urlappend = '?'
entries = []
if (
config['unwatched_only']
and config['section'] != 'recentlyViewedShows'
and config['section'] != 'all'
):
urlconfig['unwatched'] = '1'
if config.get('token'):
accesstoken = config['token']
logger.debug('Using accesstoken: {}', accesstoken)
urlconfig['X-Plex-Token'] = accesstoken
elif config.get('username'):
accesstoken = self.plex_get_accesstoken(config)
logger.debug('Got accesstoken: {}', accesstoken)
urlconfig['X-Plex-Token'] = accesstoken
for key, value in urlconfig.items():
urlappend += f'{key}={value}&'
if not self.plex_section_is_int(config['section']):
try:
path = '/library/sections/'
r = requests.get(
'http://{}:{}{}{}'.format(
config['plexserver'], config['port'], path, urlappend
)
)
except requests.RequestException as e:
raise plugin.PluginError(f'Error retrieving source: {e}')
dom = parseString(r.text.encode('utf-8'))
for node in dom.getElementsByTagName('Directory'):
if node.getAttribute('title') == config['section']:
config['section'] = int(node.getAttribute('key'))
if not self.plex_section_is_int(config['section']):
raise plugin.PluginError("Could not find section '{}'".format(config['section']))
logger.debug(
'Fetching http://{}:{}/library/sections/{}/{}{}',
config['server'],
config['port'],
config['section'],
config['selection'],
urlappend,
)
try:
path = '/library/sections/{}/{}'.format(config['section'], config['selection'])
r = requests.get(
'http://{}:{}{}{}'.format(config['plexserver'], config['port'], path, urlappend)
)
except requests.RequestException as e:
raise plugin.PluginError(
'There is no section with number {}. ({})'.format(config['section'], e)
)
dom = parseString(r.text.encode('utf-8'))
plexsectionname = dom.getElementsByTagName('MediaContainer')[0].getAttribute('title1')
viewgroup = dom.getElementsByTagName('MediaContainer')[0].getAttribute('viewGroup')
logger.debug('Plex section "{}" is a "{}" section', plexsectionname, viewgroup)
if viewgroup not in ('movie', 'show', 'episode'):
raise plugin.PluginError('Section is neither a movie nor tv show section!')
domroot = 'Directory'
titletag = 'title'
if viewgroup == 'episode':
domroot = 'Video'
titletag = 'grandparentTitle'
thumbtag = 'thumb'
arttag = 'art'
seasoncovertag = 'parentThumb'
covertag = 'grandparentThumb'
elif viewgroup == 'movie':
domroot = 'Video'
titletag = 'title'
arttag = 'art'
seasoncovertag = 'thumb'
covertag = 'thumb'
if config['fetch'] == 'thumb':
raise plugin.PluginError(
'Movie sections does not have any thumbnails to download!'
)
for node in dom.getElementsByTagName(domroot):
e = Entry()
e['plex_server'] = config['plexserver']
e['plex_port'] = config['port']
e['plex_section'] = config['section']
e['plex_section_name'] = plexsectionname
e['plex_episode_thumb'] = ''
title = node.getAttribute(titletag)
if config['strip_year']:
title = re.sub(r'^(.*)\(\d{4}\)(.*)', r'\1\2', title)
if config['strip_parens']:
title = re.sub(r'\(.*?\)', r'', title)
title = title.strip()
if config['strip_non_alpha']:
title = re.sub(r'[\(\)]', r'', title)
title = re.sub(r'&', r'And', title)
title = re.sub(r'[^A-Za-z0-9- \']', r'', title)
if config['lowercase_title']:
title = title.lower()
if viewgroup == 'show':
e['title'] = title
e['url'] = 'NULL'
entries.append(e)
# show ends here.
continue
e['plex_art'] = 'http://{}:{}{}{}'.format(
config['server'],
config['port'],
node.getAttribute(arttag),
urlappend,
)
e['plex_cover'] = 'http://{}:{}{}{}'.format(
config['server'],
config['port'],
node.getAttribute(covertag),
urlappend,
)
e['plex_season_cover'] = 'http://{}:{}{}{}'.format(
config['server'],
config['port'],
node.getAttribute(seasoncovertag),
urlappend,
)
if viewgroup == 'episode':
e['plex_thumb'] = 'http://{}:{}{}{}'.format(
config['server'],
config['port'],
node.getAttribute('thumb'),
urlappend,
)
e['series_name'] = title
e['plex_ep_name'] = node.getAttribute('title')
season = int(node.getAttribute('parentIndex'))
if node.getAttribute('parentIndex') == node.getAttribute('year'):
season = node.getAttribute('originallyAvailableAt')
filenamemap = '%s_%s%s_%s_%s_%s.%s'
episode = ''
e['series_id_type'] = 'date'
e['series_date'] = season
elif node.getAttribute('index'):
episode = int(node.getAttribute('index'))
filenamemap = '%s_%02dx%02d_%s_%s_%s.%s'
e['series_season'] = season
e['series_episode'] = episode
e['series_id_type'] = 'ep'
e['series_id'] = f'S{season:02d}E{episode:02d}'
else:
logger.debug(
"Could not get episode number for '{}' (Hint, ratingKey: {})",
title,
node.getAttribute('ratingKey'),
)
break
elif viewgroup == 'movie':
filenamemap = '%s_%s_%s_%s.%s'
e['plex_year'] = node.getAttribute('year')
e['plex_added'] = pendulum.from_timestamp(int(node.getAttribute('addedAt')))
e['plex_duration'] = node.getAttribute('duration')
e['plex_summary'] = node.getAttribute('summary')
e['plex_userrating'] = node.getAttribute('userrating')
e['plex_key'] = node.getAttribute('ratingKey')
count = node.getAttribute('viewCount')
offset = node.getAttribute('viewOffset')
if count:
e['plex_status'] = 'seen'
elif offset:
e['plex_status'] = 'inprogress'
else:
e['plex_status'] = 'unwatched'
for media in node.getElementsByTagName('Media'):
entry = Entry(e)
vcodec = media.getAttribute('videoCodec')
acodec = media.getAttribute('audioCodec')
if media.hasAttribute('title'):
entry['plex_media_title'] = media.getAttribute('title')
if media.hasAttribute('optimizedForStreaming'):
entry['plex_stream_optimized'] = media.getAttribute('optimizedForStreaming')
if config['fetch'] == 'file' or not config['fetch']:
container = media.getAttribute('container')
else:
container = 'jpg'
resolution = media.getAttribute('videoResolution') + 'p'
for part in media.getElementsByTagName('Part'):
if config['fetch'] == 'file' or not config['fetch']:
key = part.getAttribute('key')
elif config['fetch'] == 'art' or config['fetch'] == 'cover':
key = node.getAttribute(arttag)
elif config['fetch'] == 'season_cover':
key = node.getAttribute(seasoncovertag)
elif config['fetch'] == 'thumb':
key = node.getAttribute(thumbtag)
# key = part.getAttribute('key')
duration = part.getAttribute('duration')
entry['plex_title'] = title
entry['title'] = title
if config['original_filename']:
filename, fileext = os.path.splitext(basename(part.getAttribute('file')))
if config['fetch'] != 'file':
filename += '.jpg'
else:
filename = f'{filename}{fileext}'
elif viewgroup == 'episode':
filename = filenamemap % (
title.replace(' ', '.'),
season,
episode,
resolution,
vcodec,
acodec,
container,
)
entry['title'] = filename
elif viewgroup == 'movie':
filename = filenamemap % (
title.replace(' ', '.'),
resolution,
vcodec,
acodec,
container,
)
entry['title'] = filename
entry['plex_url'] = 'http://{}:{}{}{}'.format(
config['server'],
config['port'],
key,
urlappend,
)
entry['plex_path'] = key
entry['url'] = 'http://{}:{}{}{}'.format(
config['server'],
config['port'],
key,
urlappend,
)
entry['plex_duration'] = duration
entry['filename'] = filename
if key == '':
logger.debug('Could not find anything in PMS to download. Next!')
else:
entries.append(entry)
return entries
[docs]
@event('plugin.register')
def register_plugin():
plugin.register(InputPlex, 'plex', api_ver=2)