CHECKPOINT: Lots going on; Added new base class PlexObject that everything inherits from, this ensures all constructors are similar; Lots of work on new tool plexattrs that parses a full Plex library to find differences in attributes plexapi implements and what the Plex XML API offers up; Tests will most definetly be broken at this point, but I wanted to save my work.

This commit is contained in:
Michael Shepanski 2017-02-05 23:52:10 -05:00
parent fc28f7c1e6
commit 6a35f50a43
16 changed files with 741 additions and 812 deletions

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import NotFound
class Audio(PlexPartialObject):
@ -30,11 +31,9 @@ class Audio(PlexPartialObject):
"""
TYPE = None
def __init__(self, server, data, initpath):
super(Audio, self).__init__(data, initpath, server)
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.listType = 'audio'
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.index = data.attrib.get('index')
@ -54,15 +53,15 @@ class Audio(PlexPartialObject):
def thumbUrl(self):
""" Returns the URL to this items thumbnail image. """
if self.thumb:
return self.server.url(self.thumb)
return self._root.url(self.thumb)
def refresh(self):
""" Tells Plex to refresh the metadata for this and all subitems. """
self.server.query('%s/refresh' % self.key, method=self.server.session.put)
self._root.query('%s/refresh' % self.key, method=self._root.session.put)
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
return self.server.library.sectionByID(self.librarySectionID)
return self._root.library.sectionByID(self.librarySectionID)
@utils.register_libtype
@ -92,15 +91,9 @@ class Artist(Audio):
self.guid = data.attrib.get('guid')
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.location = utils.findLocations(data, single=True)
if self.isFullObject(): # check if this is needed
self.countries = [media.Country(self.server, e) for e in data if e.tag == media.Country.TYPE]
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
self.similar = [media.Similar(self.server, e) for e in data if e.tag == media.Similar.TYPE]
def albums(self):
""" Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """
path = '%s/children' % self.key
return utils.listItems(self.server, path, Album.TYPE)
self.countries = self._buildSubitems(data, media.Country)
self.genres = self._buildSubitems(data, media.Genre)
self.similar = self._buildSubitems(data, media.Similar)
def album(self, title):
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
@ -108,13 +101,15 @@ class Artist(Audio):
Parameters:
title (str): Title of the album to return.
"""
path = '%s/children' % self.key
return utils.findItem(self.server, path, title)
for album in self.albums():
if album.title.lower() == title.lower():
return album
raise NotFound('Unable to find album %s' % title)
def tracks(self):
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
path = '%s/allLeaves' % self.key
return utils.listItems(self.server, path)
def albums(self):
""" Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """
key = '%s/children' % self.key
return self._fetchItems(key, Album.TYPE)
def track(self, title):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
@ -122,8 +117,15 @@ class Artist(Audio):
Parameters:
title (str): Title of the track to return.
"""
path = '%s/allLeaves' % self.key
return utils.findItem(self.server, path, title)
for track in self.tracks():
if track.title.lower() == title.lower():
return track
raise NotFound('Unable to find track %s' % title)
def tracks(self):
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
key = '%s/allLeaves' % self.key
return self._fetchItems(key)
def get(self, title):
""" Alias of :func:`~plexapi.audio.Artist.track`. """
@ -142,13 +144,11 @@ class Artist(Audio):
function. If kwargs is not specified, the media items will be downloaded
and saved to disk.
"""
downloaded = []
filepaths = []
for album in self.albums():
for track in album.tracks():
dl = track.download(savepath=savepath, keep_orginal_name=keep_orginal_name, **kwargs)
if dl:
downloaded.extend(dl)
return downloaded
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
return filepaths
@utils.register_libtype
@ -186,13 +186,7 @@ class Album(Audio):
self.parentTitle = data.attrib.get('parentTitle')
self.studio = data.attrib.get('studio')
self.year = utils.cast(int, data.attrib.get('year'))
if self.isFullObject():
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
def tracks(self):
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
path = '%s/children' % self.key
return utils.listItems(self.server, path)
self.genres = self._buildSubitems(data, media.Genre)
def track(self, title):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
@ -200,8 +194,15 @@ class Album(Audio):
Parameters:
title (str): Title of the track to return.
"""
path = '%s/children' % self.key
return utils.findItem(self.server, path, title)
for track in self.tracks():
if track.title.lower() == title.lower():
return track
raise NotFound('Unable to find track %s' % title)
def tracks(self):
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
key = '%s/children' % self.key
return self._fetchItems(key)
def get(self, title):
""" Alias of :func:`~plexapi.audio.Album.track`. """
@ -209,7 +210,7 @@ class Album(Audio):
def artist(self):
""" Return :func:`~plexapi.audio.Artist` of this album. """
return utils.listItems(self.server, self.parentKey)[0]
return self._fetchItems(self.parentKey)[0]
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
""" Downloads all tracks for this artist to the specified location.
@ -224,13 +225,10 @@ class Album(Audio):
function. If kwargs is not specified, the media items will be downloaded
and saved to disk.
"""
downloaded = []
for ep in self.tracks():
dl = ep.download(savepath=savepath, keep_orginal_name=keep_orginal_name, **kwargs)
if dl:
downloaded.extend(dl)
return downloaded
filepaths = []
for track in self.tracks():
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
return filepaths
@utils.register_libtype
@ -295,33 +293,28 @@ class Track(Audio, Playable):
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
# media is included in /children
self.media = [media.Media(self.server, e, self.initpath, self)
for e in data if e.tag == media.Media.TYPE]
if self.isFullObject(): # check me
self.moods = [media.Mood(self.server, e) for e in data if e.tag == media.Mood.TYPE]
#self.media = [media.Media(self.server, e, self.initpath, self)
# for e in data if e.tag == media.Media.TYPE]
self.media = self._buildSubitems(data, media.Media)
self.moods = self._buildSubitems(data, media.Mood)
# data for active sessions and history
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))
self.username = utils.findUsername(data)
self.player = utils.findPlayer(self.server, data)
self.transcodeSession = utils.findTranscodeSession(self.server, data)
self.player = utils.findPlayer(self._root, data)
self.transcodeSession = utils.findTranscodeSession(self._root, data)
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
@property
def thumbUrl(self):
""" Returns the URL thumbnail image for this track's album. """
if self.parentThumb:
return self.server.url(self.parentThumb)
return self._root.url(self.parentThumb)
def album(self):
""" Return this track's :class:`~plexapi.audio.Album`. """
return utils.listItems(self.server, self.parentKey)[0]
return self._fetchItems(self.parentKey)[0]
def artist(self):
""" Return this track's :class:`~plexapi.audio.Artist`. """
return utils.listItems(self.server, self.grandparentKey)[0]
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
return self._fetchItems(self.grandparentKey)[0]

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
import re
from plexapi import utils
from plexapi import log, utils
from plexapi.compat import urlencode
from plexapi.exceptions import Unsupported
from plexapi.exceptions import NotFound, UnknownType, Unsupported
class Playable(object):
@ -23,8 +23,8 @@ class Playable(object):
# Load data for active sessions (/status/sessions)
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))
self.username = utils.findUsername(data)
self.player = utils.findPlayer(self.server, data)
self.transcodeSession = utils.findTranscodeSession(self.server, data)
self.player = utils.findPlayer(self._root, data)
self.transcodeSession = utils.findTranscodeSession(self._root, data)
# Load data for history details (/status/sessions/history/all)
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt'))
# Load data for playlist items
@ -60,7 +60,7 @@ class Playable(object):
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
# sort the keys since the randomness fucks with my tests..
sorted_params = sorted(params.items(), key=lambda val: val[0])
return self.server.url('/%s/:/transcode/universal/start.m3u8?%s' %
return self._root.url('/%s/:/transcode/universal/start.m3u8?%s' %
(streamtype, urlencode(sorted_params)))
def iterParts(self):
@ -101,15 +101,86 @@ class Playable(object):
if kwargs:
download_url = self.getStreamURL(**kwargs)
else:
download_url = self.server.url('%s?download=1' % location.key)
download_url = self._root.url('%s?download=1' % location.key)
filepath = utils.download(download_url, filename=filename,
savepath=savepath, session=self.server.session)
savepath=savepath, session=self._root.session)
if filepath:
filepaths.append(filepath)
return filepaths
class PlexPartialObject(object):
class PlexObject(object):
""" Base class for all Plex objects.
TODO: Finish documenting this.
"""
key = None
def __init__(self, root, data, initpath=None):
self._root = root # Root MyPlexAccount or PlexServer
self._data = data # XML data needed to build object
self._initpath = initpath or self.key # Request path used to fetch data
self._loadData(data)
def __setattr__(self, attr, value):
if value is not None or attr.startswith('_'):
self.__dict__[attr] = value
def _buildItem(self, elem, initpath, bytag=False):
""" Factory function to build objects based on registered LIBRARY_TYPES. """
libtype = elem.tag if bytag else elem.attrib.get('type')
if libtype == 'photo' and elem.tag == 'Directory':
libtype = 'photoalbum'
if libtype in utils.LIBRARY_TYPES:
cls = utils.LIBRARY_TYPES[libtype]
return cls(self._root, elem, initpath)
raise UnknownType('Unknown library type: %s' % libtype)
def _buildSubitems(self, data, cls, tag=None, filters=None, *args):
""" Build and return a list of items (optionally filtered by tag). """
items = []
tag = tag or cls.TYPE
filters = filters or {}
for elem in data:
if elem.tag == tag:
for attr, value in filters.items():
if elem.attrib.get(attr) != str(value):
continue
items.append(cls(self._root, elem, self._initpath, *args))
return items
def _fetchItem(self, key, title=None, name=None):
for elem in self._root._query(key):
if title and elem.attrib.get('title').lower() == title.lower():
return self._buildItem(elem, key)
if name and elem.attrib.get('name').lower() == name.lower():
return self._buildItem(elem, key)
raise NotFound('Unable to find item: %s' % (title or name))
def _fetchItems(self, key, libtype=None, bytag=False):
""" Fetch and build items from the specified key. """
items = []
for elem in self._root._query(key):
try:
if not libtype or elem.attrib.get('type') == libtype:
items.append(self._buildItem(elem, key, bytag))
except UnknownType:
pass
return items
def _loadData(self, data):
raise NotImplemented('Abstract method not implemented.')
def reload(self, safe=False):
""" Reload the data for this object from self.key. """
if not self.key:
if safe: return None
raise Unsupported('Cannot reload an object not built from a URL.')
self._initpath = self.key
data = self._root._query(self.key)
self._loadData(data[0])
class PlexPartialObject(PlexObject):
""" Not all objects in the Plex listings return the complete list of elements
for the object. This object will allow you to assume each object is complete,
and if the specified value you request is None it will fetch the full object
@ -120,12 +191,6 @@ class PlexPartialObject(object):
initpath (str): Relative path requested when retrieving specified `data` (optional).
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
"""
def __init__(self, data, initpath, server=None):
self.server = server
self.initpath = initpath
self._loadData(data)
self._reloaded = False
def __eq__(self, other):
return other is not None and self.key == other.key
@ -135,26 +200,20 @@ class PlexPartialObject(object):
title = self.title.replace(' ', '.')[0:20].encode('utf8')
return '<%s:%s:%s>' % (clsname, key, title)
def __getattr__(self, attr):
from plexapi import log
# Auto reload from the full key (path) when needed.
if attr == 'key' or self.__dict__.get(attr) or self.isFullObject() or attr.startswith('_'):
return self.__dict__.get(attr)
# Log warning about reloading the objects
def __getattribute__(self, attr):
# Check a few cases where we dont want to reload
value = super(PlexPartialObject, self).__getattribute__(attr)
if attr == 'key' or attr.startswith('_'): return value
if value not in (None, []): return value
if self.isFullObject(): return value
# Log warning that were reloading the object
clsname = self.__class__.__name__
title = self.__dict__.get('title') or self.__dict__.get('name')
title = self.__dict__.get('title', self.__dict__.get('name'))
objname = "%s '%s'" % (clsname, title) if title else clsname
log.warn("Reloading %s for attr '%s'" % (objname, attr))
# Reload the object
# Reload and return the value
self.reload()
return self.__dict__.get(attr)
def __setattr__(self, attr, value):
if value is not None or self.isFullObject():
self.__dict__[attr] = value
def _loadData(self, data):
raise NotImplemented('Abstract method not implemented.')
return super(PlexPartialObject, self).__getattribute__(attr)
def isFullObject(self):
""" Retruns True if this is already a full object. A full object means all attributes
@ -162,16 +221,8 @@ class PlexPartialObject(object):
search result for a movie often only contain a portion of the attributes a full
object (main url) for that movie contain.
"""
return not self.key or self.key == self.initpath
return not self.key or self.key == self._initpath
def isPartialObject(self):
""" Returns True if this is NOT a full object. """
""" Returns True if this is not a full object. """
return not self.isFullObject()
def reload(self):
""" Reload the data for this object from PlexServer XML. """
data = self.server.query(self.key)
self.initpath = self.key
self._loadData(data[0])
self._reloaded = True
return self

View file

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
import requests
from requests.status_codes import _codes as codes
from plexapi import BASE_HEADERS, TIMEOUT, log, utils
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
from plexapi import log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, Unsupported
from xml.etree import ElementTree
class PlexClient(object):
class PlexClient(PlexObject):
""" Main class for interacting with a Plex client. This class can connect
directly to the client and control it or proxy commands through your
Plex Server. To better understand the Plex client API's read this page:
@ -42,16 +44,31 @@ class PlexClient(object):
_proxyThroughServer (bool): Set to True after calling
:func:`~plexapi.client.PlexClient.proxyThroughServer()` (default False).
"""
key = '/resources'
def __init__(self, baseurl, token=None, session=None, server=None, data=None):
self.baseurl = baseurl.strip('/')
self.token = token
self.server = server
self._baseurl = baseurl or CONFIG.get('authentication.client_baseurl')
self._token = token or CONFIG.get('authentication.client_token')
if self._token:
logfilter.add_secret(self._token)
self._server = server
# session > server.session > requests.Session
_server_session = server.session if server else None
self.session = session or _server_session or requests.Session()
self._loadData(data) if data is not None else self.connect()
_server_session = server._session if server else None
self._session = session or _server_session or requests.Session()
self._proxyThroughServer = False
self._commandId = 0
data = data if data is not None else self._query('/resources')[0]
super(PlexClient, self).__init__(self, data, self.key)
def connect(self, safe=False):
""" Alias of reload as any subsequent requests to this client will be
made directly to the device even if the object attributes were initially
populated from a PlexServer.
"""
try:
self.reload()
except Exception:
if not safe: raise
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@ -72,26 +89,41 @@ class PlexClient(object):
self.vendor = data.attrib.get('vendor')
self.version = data.attrib.get('version')
def connect(self):
""" Connects to the client and reloads all class attributes.
Raises:
:class:`~plexapi.exceptions.NotFound`: No client found at the specified url.
"""
try:
data = self.query('/resources')[0]
self._loadData(data)
except Exception as err:
log.error('%s: %s', self.baseurl, err)
raise NotFound('No client found at: %s' % self.baseurl)
def __repr__(self):
return '<%s:%s>' % (self.__class__.__name__, self._baseurl)
def headers(self):
def _query(self, path, method=None, headers=None, **kwargs):
""" Main method used to handle HTTPS requests to the Plex client. This method helps
by encoding the response to utf-8 and parsing the returned XML into and
ElementTree object. Returns None if no data exists in the response.
"""
url = self._url(path)
method = method or self._session.get
log.info('%s %s', method.__name__.upper(), url)
headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=TIMEOUT, **kwargs)
if response.status_code not in (200, 201):
codename = codes.get(response.status_code)[0]
log.warn('BadRequest (%s) %s %s' % (response.status_code, codename, response.url))
raise BadRequest('(%s) %s %s' % (response.status_code, codename, response.url))
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data else None
def _headers(self, **kwargs):
""" Returns a dict of all default headers for Client requests. """
headers = BASE_HEADERS
if self.token:
headers['X-Plex-Token'] = self.token
if self._token:
headers['X-Plex-Token'] = self._token
headers.update(kwargs)
return headers
def _url(self, key):
""" Build a URL string with proper token argument. """
if self._token:
delim = '&' if '?' in key else '?'
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
return '%s%s' % (self._baseurl, key)
def proxyThroughServer(self, value=True):
""" Tells this PlexClient instance to proxy all future commands through the PlexServer.
Useful if you do not wish to connect directly to the Client device itself.
@ -106,30 +138,6 @@ class PlexClient(object):
raise Unsupported('Cannot use client proxy with unknown server.')
self._proxyThroughServer = value
def query(self, path, method=None, headers=None, **kwargs):
""" Returns an ElementTree object containing the response
from the specified request path.
Parameters:
path (str): Relative path to query.
method (func): `self.session.get` or `self.session.post`
headers (dict): Additional headers to include or override in the request.
**kwargs (TYPE): Additional arguments to inclde in the request.<method> call.
Raises:
:class:`~plexapi.exceptions.BadRequest`: When the response is not in [200, 201]
"""
url = self.url(path)
method = method or self.session.get
log.info('%s %s', method.__name__.upper(), url)
headers = dict(self.headers(), **(headers or {})) # remove hack
response = method(url, headers=headers, timeout=TIMEOUT, **kwargs)
if response.status_code not in [200, 201]:
codename = codes.get(response.status_code)[0]
raise BadRequest('(%s) %s' % (response.status_code, codename))
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data else None
def sendCommand(self, command, proxy=None, **params):
""" Convenience wrapper around :func:`~plexapi.client.PlexClient.query()` to more easily
send simple commands to the client. Returns an ElementTree object containing
@ -156,18 +164,7 @@ class PlexClient(object):
if proxy:
return self.server.query(path, headers=headers)
path = '/player/%s%s' % (command, utils.joinArgs(params))
return self.query(path, headers=headers)
def url(self, path):
""" Given a path, this retuns the full PlexClient the PlexServer URL to request.
Parameters:
path (str): Relative path to be converted.
"""
if self.token:
delim = '&' if '?' in path else '?'
return '%s%s%sX-Plex-Token=%s' % (self.baseurl, path, delim, self.token)
return '%s%s' % (self.baseurl, path)
return self._query(path, headers=headers)
#---------------------
# Navigation Commands
@ -240,7 +237,7 @@ class PlexClient(object):
"""
if not self.server:
raise Unsupported('A server must be specified before using this command.')
server_url = media.server.baseurl.split(':')
server_url = media.server._baseurl.split(':')
self.sendCommand('mirror/details', **dict({
'machineIdentifier': self.server.machineIdentifier,
'address': server_url[1].strip('/'),
@ -407,7 +404,7 @@ class PlexClient(object):
"""
if not self.server:
raise Unsupported('A server must be specified before using this command.')
server_url = media.server.baseurl.split(':')
server_url = media.server._baseurl.split(':')
playqueue = self.server.createPlayQueue(media)
self.sendCommand('playback/playMedia', **dict({
'machineIdentifier': self.server.machineIdentifier,

View file

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
import logging
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
from plexapi.base import PlexObject
from plexapi.compat import unquote
from plexapi.media import MediaTag, Genre, Role, Director
from plexapi.exceptions import BadRequest, NotFound
class Library(object):
class Library(PlexObject):
""" Represents a PlexServer library. This contains all sections of media defined
in your Plex server including video, shows and audio.
@ -17,14 +18,15 @@ class Library(object):
title1 (str): 'Plex Library' (not sure how useful this is).
title2 (str): Second title (this is blank on my setup).
"""
def __init__(self, server, data):
key = '/library'
def _loadData(self, data):
self._data = data
self._sectionsByID = {} # cached Section UUIDs
self.identifier = data.attrib.get('identifier')
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
self.server = server
self.title1 = data.attrib.get('title1')
self.title2 = data.attrib.get('title2')
self._sectionsByID = {} # cached Section UUIDs
def __repr__(self):
return '<Library:%s>' % self.title1.encode('utf8')
@ -34,19 +36,15 @@ class Library(object):
:class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`,
:class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`.
"""
SECTION_TYPES = {MovieSection.TYPE:MovieSection, ShowSection.TYPE:ShowSection,
MusicSection.TYPE: MusicSection, PhotoSection.TYPE: PhotoSection}
items = []
SECTION_TYPES = {
MovieSection.TYPE: MovieSection,
ShowSection.TYPE: ShowSection,
MusicSection.TYPE: MusicSection,
PhotoSection.TYPE: PhotoSection,
}
path = '/library/sections'
for elem in self.server.query(path):
key = '/library/sections'
for elem in self._root._query(key):
stype = elem.attrib['type']
if stype in SECTION_TYPES:
cls = SECTION_TYPES[stype]
section = cls(self.server, elem, path)
section = cls(self._root, elem, key)
self._sectionsByID[section.key] = section
items.append(section)
return items
@ -157,7 +155,7 @@ class Library(object):
return len(self.sections())
class LibrarySection(object):
class LibrarySection(PlexObject):
""" Base class for a single library section.
Parameters:
@ -189,17 +187,15 @@ class LibrarySection(object):
ALLOWED_SORT = ()
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
def __init__(self, server, data, initpath):
def _loadData(self, data):
self._data = data
self.server = server
self.initpath = initpath
self.agent = data.attrib.get('agent')
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
self.art = data.attrib.get('art')
self.composite = data.attrib.get('composite')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.filters = data.attrib.get('filters')
self.key = data.attrib.get('key')
self.key = data.attrib.get('key') # invalid key from plex
self.language = data.attrib.get('language')
self.locations = utils.findLocations(data)
self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
@ -220,12 +216,13 @@ class LibrarySection(object):
Parameters:
title (str): Title of the item to return.
"""
path = '/library/sections/%s/all' % self.key
return utils.findItem(self.server, path, title)
key = '/library/sections/%s/all' % self.key
return utils.findItem(self.server, key, title)
def all(self):
""" Returns a list of media from this library section. """
return utils.listItems(self.server, '/library/sections/%s/all' % self.key)
key = '/library/sections/%s/all' % self.key
return self._fetchItems(key)
def onDeck(self):
""" Returns a list of media items on deck from this library section. """

View file

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest
from plexapi.utils import cast, listItems
class Media(object):
class Media(PlexObject):
""" Container object for all MediaPart objects. Provides useful data about the
video this media belong to such as video framerate, resolution, etc.
@ -35,10 +36,8 @@ class Media(object):
"""
TYPE = 'Media'
def __init__(self, server, data, initpath, video):
self.server = server
self.initpath = initpath
self.video = video
def _loadData(self, data):
self._data = data
self.aspectRatio = cast(float, data.attrib.get('aspectRatio'))
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec')
@ -53,14 +52,14 @@ class Media(object):
self.videoFrameRate = data.attrib.get('videoFrameRate')
self.videoResolution = data.attrib.get('videoResolution')
self.width = cast(int, data.attrib.get('width'))
self.parts = [MediaPart(server, e, initpath, self) for e in data]
self.parts = self._buildSubitems(data, MediaPart)
def __repr__(self):
title = self.video.title.replace(' ','.')[0:20]
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
class MediaPart(object):
class MediaPart(PlexObject):
""" Represents a single media part (often a single file) for the media this belongs to.
Attributes:
@ -77,37 +76,23 @@ class MediaPart(object):
"""
TYPE = 'Part'
def __init__(self, server, data, initpath, media):
self.server = server
self.initpath = initpath
self.media = media
def _loadData(self, data):
self._data = data
self.container = data.attrib.get('container')
self.duration = cast(int, data.attrib.get('duration'))
self.file = data.attrib.get('file')
self.id = cast(int, data.attrib.get('id'))
self.key = data.attrib.get('key')
self.size = cast(int, data.attrib.get('size'))
self.streams = [MediaPartStream.parse(self.server, e, self.initpath, self) for e in data if e.tag == 'Stream']
self.videoStreams = self._buildSubitems(data, VideoStream, 'Stream', {'streamType':VideoStream.STREAMTYPE})
self.audioStreams = self._buildSubitems(data, AudioStream, 'Stream', {'streamType':AudioStream.STREAMTYPE})
self.subtitleStreams = self._buildSubitems(data, SubtitleStream, 'Stream', {'streamType':SubtitleStream.STREAMTYPE})
def __repr__(self):
return '<%s:%s>' % (self.__class__.__name__, self.id)
def selectedStream(self, stream_type):
""" Return the selected stream for the specified stream_type.
Paramters:
stream_type (int): Specify which stream type you want the result for. This value
should be one of (1=:class:`~plexapi.media.VideoStream`,
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`).
"""
streams = filter(lambda x: stream_type == x.type, self.streams)
selected = list(filter(lambda x: x.selected is True, streams))
if len(selected) == 0:
return None
return selected[0]
class MediaPartStream(object):
class MediaPartStream(PlexObject):
""" Base class for media streams. These consist of video, audio and subtitles.
Attributes:
@ -128,10 +113,8 @@ class MediaPartStream(object):
TYPE = None
STREAMTYPE = None
def __init__(self, server, data, initpath, part):
self.server = server
self.initpath = initpath
self.part = part
def _loadData(self, data):
self._data = data
self.codec = data.attrib.get('codec')
self.codecID = data.attrib.get('codecID')
self.id = cast(int, data.attrib.get('id'))
@ -143,12 +126,12 @@ class MediaPartStream(object):
self.type = cast(int, data.attrib.get('streamType'))
@staticmethod
def parse(server, data, initpath, part):
def parse(server, data, initpath):
""" Factory method returns a new MediaPartStream from xml data. """
STREAMCLS = {1:VideoStream, 2:AudioStream, 3:SubtitleStream}
stype = cast(int, data.attrib.get('streamType'))
cls = STREAMCLS.get(stype, MediaPartStream)
return cls(server, data, initpath, part)
return cls(server, data, initpath)
def __repr__(self):
return '<%s:%s>' % (self.__class__.__name__, self.id)
@ -178,8 +161,8 @@ class VideoStream(MediaPartStream):
TYPE = 'videostream'
STREAMTYPE = 1
def __init__(self, server, data, initpath, part):
super(VideoStream, self).__init__(server, data, initpath, part)
def _loadData(self, data):
super(VideoStream, self)._loadData(data)
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
self.bitrate = cast(int, data.attrib.get('bitrate'))
self.cabac = cast(int, data.attrib.get('cabac'))
@ -215,8 +198,8 @@ class AudioStream(MediaPartStream):
TYPE = 'audiostream'
STREAMTYPE = 2
def __init__(self, server, data, initpath, part):
super(AudioStream, self).__init__(server, data, initpath, part)
def _loadData(self, data):
super(AudioStream, self)._loadData(data)
self.audioChannelLayout = data.attrib.get('audioChannelLayout')
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
self.bitrate = cast(int, data.attrib.get('bitrate'))
@ -239,21 +222,21 @@ class SubtitleStream(MediaPartStream):
TYPE = 'subtitlestream'
STREAMTYPE = 3
def __init__(self, server, data, initpath, part):
super(SubtitleStream, self).__init__(server, data, initpath, part)
def _loadData(self, data):
super(SubtitleStream, self)._loadData(data)
self.format = data.attrib.get('format')
self.key = data.attrib.get('key')
self.title = data.attrib.get('title')
class TranscodeSession(object):
class TranscodeSession(PlexObject):
""" Represents a current transcode session.
TODO: Document this.
"""
TYPE = 'TranscodeSession'
def __init__(self, server, data):
self.server = server
def _loadData(self, data):
self._data = data
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec')
self.audioDecision = data.attrib.get('audioDecision')
@ -272,7 +255,7 @@ class TranscodeSession(object):
self.width = cast(int, data.attrib.get('width'))
class MediaTag(object):
class MediaTag(PlexObject):
""" Base class for media tags used for filtering and searching your library
items or navigating the metadata of media items in your library. Tags are
the construct used for things such as Country, Director, Genre, etc.
@ -300,9 +283,8 @@ class MediaTag(object):
"""
TYPE = None
def __init__(self, server, data):
def _loadData(self, data):
self._data = data
self.server = server
self.id = cast(int, data.attrib.get('id'))
self.role = data.attrib.get('role')
self.tag = data.attrib.get('tag')
@ -333,51 +315,44 @@ class Collection(MediaTag):
TYPE = 'Collection'
FILTER = 'collection'
class Country(MediaTag):
TYPE = 'Country'
FILTER = 'country'
class Director(MediaTag):
TYPE = 'Director'
FILTER = 'director'
class Genre(MediaTag):
TYPE = 'Genre'
FILTER = 'genre'
class Mood(MediaTag):
TYPE = 'Mood'
FILTER = 'mood'
class Producer(MediaTag):
TYPE = 'Producer'
FILTER = 'producer'
class Role(MediaTag):
TYPE = 'Role'
FILTER = 'role'
class Similar(MediaTag):
TYPE = 'Similar'
FILTER = 'similar'
class Writer(MediaTag):
TYPE = 'Writer'
FILTER = 'writer'
class Field(object):
class Field(PlexObject):
TYPE = 'Field'
def __init__(self, data):
def _loadData(self, data):
self._data = data
self.name = data.attrib.get('name')
self.locked = cast(bool, data.attrib.get('locked'))

View file

@ -1,15 +1,16 @@
# -*- coding: utf-8 -*-
import plexapi, requests
from plexapi import TIMEOUT, log, logfilter, utils
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
import requests
from requests.status_codes import _codes as codes
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
from plexapi import log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, NotFound
from plexapi.client import PlexClient
from plexapi.compat import ElementTree
from plexapi.server import PlexServer
from requests.status_codes import _codes as codes
CONFIG = plexapi.CONFIG
class MyPlexAccount(object):
class MyPlexAccount(PlexObject):
""" 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
@ -43,15 +44,26 @@ class MyPlexAccount(object):
username (str): Your account username.
uuid (str): <Unknown>
"""
BASEURL = 'https://plex.tv/users/account'
SIGNIN = 'https://my.plexapp.com/users/sign_in.xml'
key = 'https://plex.tv/users/account'
def __init__(self, data=None, initpath=None, session=None):
self._data = data
def __init__(self, username=None, password=None, session=None):
self._session = session or requests.Session()
self.authenticationToken = data.attrib.get('authenticationToken')
if self.authenticationToken:
logfilter.add_secret(self.authenticationToken)
self._token = None
username = username or CONFIG.get('authentication.myplex_username')
password = password or CONFIG.get('authentication.myplex_password')
data = self._query(self.SIGNIN, method=self._session.post, auth=(username, password))
super(MyPlexAccount, self).__init__(self, data, self.SIGNIN)
def __repr__(self):
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, self.username.encode('utf8'))
def _loadData(self, data):
self._data = data
self._token = data.attrib.get('authenticationToken')
if self._token:
logfilter.add_secret(self._token)
self.authenticationToken = self._token
self.certificateVersion = data.attrib.get('certificateVersion')
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
self.email = data.attrib.get('email')
@ -79,8 +91,20 @@ class MyPlexAccount(object):
self.roles = None
self.entitlements = None
def __repr__(self):
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, self.username.encode('utf8'))
def _query(self, url, method=None, headers=None, **kwargs):
method = method or self._session.get
delim = '&' if '?' in url else '?'
url = '%s%sX-Plex-Token=%s' % (url, delim, self._token)
log.info('%s %s', method.__name__.upper(), url)
allheaders = BASE_HEADERS.copy()
allheaders.update(headers or {})
response = method(url, headers=allheaders, timeout=TIMEOUT, **kwargs)
if response.status_code not in (200, 201):
codename = codes.get(response.status_code)[0]
log.warn('BadRequest (%s) %s %s' % (response.status_code, codename, response.url))
raise BadRequest('(%s) %s %s' % (response.status_code, codename, response.url))
text = response.text.encode('utf8')
return ElementTree.fromstring(text) if text else None
def device(self, name):
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
@ -88,15 +112,15 @@ class MyPlexAccount(object):
Parameters:
name (str): Name to match against.
"""
return _findItem(self.devices(), name)
for device in self.devices():
if device.name.lower() == name.lower():
return device
raise NotFound('Unable to find device %s' % name)
def devices(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
return _listItems(MyPlexDevice.BASEURL, self.authenticationToken, MyPlexDevice)
def resources(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """
return _listItems(MyPlexResource.BASEURL, self.authenticationToken, MyPlexResource)
data = self._query(MyPlexDevice.key)
return [MyPlexDevice(self, elem) for elem in data]
def resource(self, name):
""" Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified.
@ -104,11 +128,15 @@ class MyPlexAccount(object):
Parameters:
name (str): Name to match against.
"""
return _findItem(self.resources(), name)
for resource in self.resources():
if resource.name.lower() == name.lower():
return resource
raise NotFound('Unable to find resource %s' % name)
def users(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. """
return _listItems(MyPlexUser.BASEURL, self.authenticationToken, MyPlexUser)
def resources(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """
data = self._query(MyPlexResource.key)
return [MyPlexResource(self, elem) for elem in data]
def user(self, email):
""" Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified.
@ -116,41 +144,18 @@ class MyPlexAccount(object):
Parameters:
email (str): Username or email to match against.
"""
return _findItem(self.users(), email, ['username', 'email'])
for user in self.users():
if email.lower() in (user.username.lower(), user.email.lower()):
return user
raise NotFound('Unable to find user %s' % email)
@classmethod
def signin(cls, username=None, password=None, session=None):
""" Returns a new :class:`~myplex.MyPlexAccount` object by connecting to MyPlex with the
specified username and password. This is essentially logging into MyPlex and often
the very first entry point to using this API.
Parameters:
username (str): Your MyPlex.tv username. If not specified, it will check the config.ini file.
password (str): Your MyPlex.tv password. If not specified, it will check the config.ini file.
Raises:
:class:`~plexapi.exceptions.Unauthorized`: (401) If the username or password are invalid.
:class:`~plexapi.exceptions.BadRequest`: If any other errors occured not allowing us to log into MyPlex.tv.
"""
if 'X-Plex-Token' in plexapi.BASE_HEADERS:
del plexapi.BASE_HEADERS['X-Plex-Token']
username = username or CONFIG.get('authentication.username')
password = password or CONFIG.get('authentication.password')
auth = (username, password)
log.info('POST %s', cls.SIGNIN)
sess = session or requests.Session()
response = sess.post(
cls.SIGNIN, headers=plexapi.BASE_HEADERS, auth=auth, timeout=TIMEOUT)
if response.status_code != requests.codes.created:
codename = codes.get(response.status_code)[0]
if response.status_code == 401:
raise Unauthorized('(%s) %s' % (response.status_code, codename))
raise BadRequest('(%s) %s' % (response.status_code, codename))
data = ElementTree.fromstring(response.text.encode('utf8'))
return MyPlexAccount(data, cls.SIGNIN, session=sess)
def users(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. """
data = self._query(MyPlexUser.key)
return [MyPlexUser(self, elem) for elem in data]
class MyPlexUser(object):
class MyPlexUser(PlexObject):
""" This object represents non-signed in users such as friends and linked
accounts. NOTE: This should not be confused with the :class:`~myplex.MyPlexAccount`
which is your specific account. The raw xml for the data presented here
@ -175,9 +180,9 @@ class MyPlexUser(object):
title (str): Seems to be an aliad for username
username (str): User's username
"""
BASEURL = 'https://plex.tv/api/users/'
key = 'https://plex.tv/api/users/'
def __init__(self, data, initpath=None):
def _loadData(self, data):
self._data = data
self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels'))
@ -201,7 +206,7 @@ class MyPlexUser(object):
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, self.username)
class MyPlexResource(object):
class MyPlexResource(PlexObject):
""" This object represents resources connected to your Plex server that can provide
content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml
for the data presented here can be found at: https://plex.tv/api/resources?includeHttps=1
@ -226,9 +231,9 @@ class MyPlexResource(object):
player, pubsub-player, etc.)
synced (bool): Unknown (possibly True if the resource has synced content?)
"""
BASEURL = 'https://plex.tv/api/resources?includeHttps=1'
key = 'https://plex.tv/api/resources?includeHttps=1'
def __init__(self, data):
def _loadData(self, data):
self._data = data
self.name = data.attrib.get('name')
self.accessToken = data.attrib.get('accessToken')
@ -247,12 +252,12 @@ class MyPlexResource(object):
self.home = utils.cast(bool, data.attrib.get('home'))
self.synced = utils.cast(bool, data.attrib.get('synced'))
self.presence = utils.cast(bool, data.attrib.get('presence'))
self.connections = [ResourceConnection(elem) for elem in data if elem.tag == 'Connection']
self.connections = self._buildSubitems(data, ResourceConnection)
def __repr__(self):
return '<%s:%s>' % (self.__class__.__name__, self.name.encode('utf8'))
def connect(self, ssl=None):
def connect(self, ssl=None, safe=False):
""" Returns a new :class:`~server.PlexServer` object. Often times there is more than
one address specified for a server or client. This function will prioritize local
connections before remote and HTTPS before HTTP. After trying to connect to all
@ -263,6 +268,8 @@ class MyPlexResource(object):
ssl (optional): Set True to only connect to HTTPS connections. Set False to
only connect to HTTP connections. Set None (default) to connect to any
HTTP or HTTPS connection.
safe (bool): If True this function will return None if unable to connect
instead of raising an exception (default False).
Raises:
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
@ -289,18 +296,20 @@ class MyPlexResource(object):
log.info('Testing resource connection: %s?X-Plex-Token=%s %s', url, token, okerr)
results = [r[2] for r in results if r and r[2] is not None]
if not results:
if safe: return log.warn('Unable to connect to resource: %s' % self.name)
raise NotFound('Unable to connect to resource: %s' % self.name)
log.info('Connecting to server: %s?X-Plex-Token=%s', results[0].baseurl, results[0].token)
log.info('Connecting to server: %s?X-Plex-Token=%s', results[0]._baseurl, results[0]._token)
return results[0]
def _connect(self, url, results, i):
try:
results[i] = (url, self.accessToken, PlexServer(url, self.accessToken))
except NotFound:
except Exception as err:
log.error('%s: %s', url, err)
results[i] = (url, self.accessToken, None)
class ResourceConnection(object):
class ResourceConnection(PlexObject):
""" Represents a Resource Connection object found within the
:class:`~myplex.MyPlexResource` objects.
@ -312,7 +321,9 @@ class ResourceConnection(object):
protocol (str): HTTP or HTTPS
uri (str): External address
"""
def __init__(self, data):
TYPE = 'Connection'
def _loadData(self, data):
self._data = data
self.protocol = data.attrib.get('protocol')
self.address = data.attrib.get('address')
@ -325,7 +336,7 @@ class ResourceConnection(object):
return '<%s:%s>' % (self.__class__.__name__, self.uri.encode('utf8'))
class MyPlexDevice(object):
class MyPlexDevice(PlexObject):
""" This object represents resources connected to your Plex server that provide
playback ability from your Plex Server, iPhone or Android clients, Plex Web,
this API, etc. The raw xml for the data presented here can be found at:
@ -351,9 +362,9 @@ class MyPlexDevice(object):
vendor (str): Device vendor (ubuntu, etc).
version (str): Unknown (1, 2, 1.3.3.3148-b38628e, 1.3.15, etc.)
"""
BASEURL = 'https://plex.tv/devices.xml'
key = 'https://plex.tv/devices.xml'
def __init__(self, data):
def _loadData(self, data):
self._data = data
self.name = data.attrib.get('name')
self.publicAddress = data.attrib.get('publicAddress')
@ -378,16 +389,20 @@ class MyPlexDevice(object):
def __repr__(self):
return '<%s:%s:%s>' % (self.__class__.__name__, self.name.encode('utf8'), self.product.encode('utf8'))
def connect(self):
def connect(self, safe=False):
""" Returns a new :class:`~plexapi.client.PlexClient` object. Sometimes there is more than
one address specified for a server or client. After trying to connect to all
available addresses for this resource and assuming at least one connection was
available addresses for this client and assuming at least one connection was
successful, the PlexClient object is built and returned.
Parameters:
safe (bool): If True this function will return None if unable to connect
instead of raising an exception (default False).
Raises:
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
"""
# Try connecting to all known resource connections in parellel, but
# Try connecting to all known clients in parellel, but
# only return the first server (in order) that provides a response.
listargs = [[c] for c in self.connections]
results = utils.threaded(self._connect, listargs)
@ -399,34 +414,14 @@ class MyPlexDevice(object):
log.info('Testing device connection: %s?X-Plex-Token=%s %s', url, token, okerr)
results = [r[2] for r in results if r and r[2] is not None]
if not results:
raise NotFound('Unable to connect to resource: %s' % self.name)
log.info('Connecting to server: %s?X-Plex-Token=%s', results[0].baseurl, results[0].token)
if safe: return log.warn('Unable to connect to client: %s' % self.name)
raise NotFound('Unable to connect to client: %s' % self.name)
log.info('Connecting to client: %s?X-Plex-Token=%s', results[0]._baseurl, results[0]._token)
return results[0]
def _connect(self, url, results, i):
try:
results[i] = (url, self.token, PlexClient(url, self.token))
except NotFound:
except Exception as err:
log.error('%s: %s', url, err)
results[i] = (url, self.token, None)
def _findItem(items, value, attrs=None):
""" This will return the first item in the list of items where value is
found in any of the specified attributes.
"""
attrs = attrs or ['name']
for item in items:
for attr in attrs:
if value.lower() == getattr(item, attr).lower():
return item
raise NotFound('Unable to find item %s' % value)
def _listItems(url, token, cls):
""" Builds list of classes from a XML response. """
headers = plexapi.BASE_HEADERS
headers['X-Plex-Token'] = token
log.info('GET %s?X-Plex-Token=%s', url, token)
response = requests.get(url, headers=headers, timeout=TIMEOUT)
data = ElementTree.fromstring(response.text.encode('utf8'))
return [cls(elem) for elem in data]

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from plexapi import media, utils
from plexapi.base import PlexPartialObject
from plexapi.exceptions import NotFound
@utils.register_libtype
@ -30,9 +31,6 @@ class Photoalbum(PlexPartialObject):
"""
TYPE = 'photoalbum'
def __init__(self, server, data, initpath):
super(Photoalbum, self).__init__(data, initpath, server)
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.listType = 'photo'
@ -52,17 +50,19 @@ class Photoalbum(PlexPartialObject):
def photos(self):
""" Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """
path = '/library/metadata/%s/children' % self.ratingKey
return utils.listItems(self.server, path, Photo.TYPE)
key = '/library/metadata/%s/children' % self.ratingKey
return self._fetchItems(key)
def photo(self, title):
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """
path = '/library/metadata/%s/children' % self.ratingKey
return utils.findItem(self.server, path, title)
for photo in self.photos():
if photo.attrib.get('title').lower() == title.lower():
return photo
raise NotFound('Unable to find photo: %s' % title)
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
return self.server.library.sectionByID(self.librarySectionID)
return self._root.library.sectionByID(self.librarySectionID)
@utils.register_libtype
@ -93,9 +93,6 @@ class Photo(PlexPartialObject):
"""
TYPE = 'photo'
def __init__(self, server, data, initpath):
super(Photo, self).__init__(data, initpath, server)
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.listType = 'photo'
@ -113,14 +110,12 @@ class Photo(PlexPartialObject):
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.year = utils.cast(int, data.attrib.get('year'))
if self.isFullObject():
self.media = [media.Media(self.server, e, self.initpath, self)
for e in data if e.tag == media.Media.TYPE]
self.media = self._buildSubitems(data, media.Media)
def photoalbum(self):
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
return utils.listItems(self.server, self.parentKey)[0]
return utils.listItems(self._root, self.parentKey)[0]
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
return self.server.library.sectionByID(self.photoalbum().librarySectionID)
return self._root.library.sectionByID(self.photoalbum().librarySectionID)

View file

@ -9,23 +9,7 @@ from plexapi.utils import cast, toDatetime
class Playlist(PlexPartialObject, Playable):
TYPE = 'playlist'
def __init__(self, server, data, initpath):
"""Playlist stuff.
Args:
server (Plexserver): The PMS server your connected to
data (Element): Element built from server.query
initpath (str): Relativ path fx /library/sections/1/all
"""
super(Playlist, self).__init__(data, initpath, server)
def _loadData(self, data):
"""Used to set the attributes
Args:
data (Element): Usually built from server.query
"""
Playable._loadData(self, data)
self.addedAt = toDatetime(data.attrib.get('addedAt'))
self.composite = data.attrib.get('composite') # url to thumbnail
@ -44,9 +28,9 @@ class Playlist(PlexPartialObject, Playable):
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
def items(self):
"""Return all items in the playlist."""
path = '%s/items' % self.key
return utils.listItems(self.server, path)
""" Returns a list of all items in the playlist. """
key = '%s/items' % self.key
return self._fetchItems(key)
def addItems(self, items):
"""Add items to a playlist."""
@ -62,28 +46,28 @@ class Playlist(PlexPartialObject, Playable):
path = '%s/items%s' % (self.key, utils.joinArgs({
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys),
}))
return self.server.query(path, method=self.server.session.put)
return self._root._query(path, method=self._root._session.put)
def removeItem(self, item):
"""Remove a file from a playlist."""
path = '%s/items/%s' % (self.key, item.playlistItemID)
return self.server.query(path, method=self.server.session.delete)
return self._root._query(path, method=self._root._session.delete)
def moveItem(self, item, after=None):
"""Move a to a new position in playlist."""
path = '%s/items/%s/move' % (self.key, item.playlistItemID)
if after:
path += '?after=%s' % after.playlistItemID
return self.server.query(path, method=self.server.session.put)
return self._root._query(path, method=self._root._session.put)
def edit(self, title=None, summary=None):
"""Edit playlist."""
path = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title':title, 'summary':summary}))
return self.server.query(path, method=self.server.session.put)
return self._root._query(path, method=self._root._session.put)
def delete(self):
"""Delete playlist."""
return self.server.query(self.key, method=self.server.session.delete)
return self._root._query(self.key, method=self._root._session.delete)
@classmethod
def create(cls, server, title, items):
@ -91,12 +75,10 @@ class Playlist(PlexPartialObject, Playable):
if not isinstance(items, (list, tuple)):
items = [items]
ratingKeys = []
for item in items:
if item.listType != items[0].listType:
raise BadRequest('Can not mix media types when building a playlist')
ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys)
uuid = items[0].section().uuid
path = '/playlists%s' % utils.joinArgs({
@ -105,6 +87,5 @@ class Playlist(PlexPartialObject, Playable):
'title': title,
'smart': 0
})
data = server.query(path, method=server.session.post)[0]
data = server._query(path, method=server._session.post)[0]
return cls(server, data, initpath=path)

View file

@ -1,35 +1,27 @@
# -*- coding: utf-8 -*-
import plexapi
import requests
from plexapi import utils
from plexapi.base import PlexObject
class PlayQueue(object):
"""Summary
class PlayQueue(PlexObject):
""" Summary
Attributes:
identifier (TYPE): Description
initpath (TYPE): Description
items (TYPE): Description
mediaTagPrefix (TYPE): Description
mediaTagVersion (TYPE): Description
playQueueID (TYPE): Description
playQueueSelectedItemID (TYPE): Description
playQueueSelectedItemOffset (TYPE): Description
playQueueTotalCount (TYPE): Description
playQueueVersion (TYPE): Description
server (TYPE): Description
"""
def __init__(self, server, data, initpath):
"""Summary
Args:
server (TYPE): Description
data (TYPE): Description
Attributes:
identifier (TYPE): Description
initpath (TYPE): Description
"""
self.server = server
self.initpath = initpath
items (TYPE): Description
mediaTagPrefix (TYPE): Description
mediaTagVersion (TYPE): Description
playQueueID (TYPE): Description
playQueueSelectedItemID (TYPE): Description
playQueueSelectedItemOffset (TYPE): Description
playQueueTotalCount (TYPE): Description
playQueueVersion (TYPE): Description
server (TYPE): Description
"""
def _loadData(self, data):
self._data = data
self.identifier = data.attrib.get('identifier')
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
@ -38,22 +30,22 @@ class PlayQueue(object):
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
self.playQueueVersion = data.attrib.get('playQueueVersion')
self.items = [utils.buildItem(server, elem, initpath) for elem in data]
self.items = [self._buildItem(elem, self._initpath) for elem in data]
@classmethod
def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1):
"""Summary
""" Create a new playqueue
Args:
server (TYPE): Description
item (TYPE): Description
shuffle (int, optional): Description
repeat (int, optional): Description
includeChapters (int, optional): Description
includeRelated (int, optional): Description
Paramaters:
server (TYPE): Description
item (TYPE): Description
shuffle (int, optional): Description
repeat (int, optional): Description
includeChapters (int, optional): Description
includeRelated (int, optional): Description
Returns:
TYPE: Description
Returns:
TYPE: Description
"""
args = {}
args['includeChapters'] = includeChapters
@ -69,5 +61,5 @@ class PlayQueue(object):
args['type'] = item.listType
args['uri'] = 'library://%s/item/%s' % (uuid, item.key)
path = '/playQueues%s' % utils.joinArgs(args)
data = server.query(path, method=requests.post)
data = server._query(path, method=requests.post)
return cls(server, data, initpath=path)

View file

@ -3,6 +3,7 @@ import requests
from requests.status_codes import _codes as codes
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
from plexapi import log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.client import PlexClient
from plexapi.compat import ElementTree, urlencode
from plexapi.exceptions import BadRequest, NotFound
@ -10,11 +11,11 @@ from plexapi.library import Library
from plexapi.playlist import Playlist
from plexapi.playqueue import PlayQueue
from plexapi.utils import cast
# import media to populate utils.LIBRARY_TYPES.
# We import media to populate utils.LIBRARY_TYPES
from plexapi import audio, video, photo, playlist as _pl
class PlexServer(object):
class PlexServer(PlexObject):
""" This is the main entry point to interacting with a Plex server. It allows you to
list connected clients, browse your library sections and perform actions such as
emptying trash. If you do not know the auth token required to access your Plex
@ -83,14 +84,16 @@ class PlexServer(object):
version (str): Current Plex version (ex: 1.3.2.3112-1751929)
voiceSearch (bool): True if voice search is enabled. (is this Google Voice search?)
"""
key = '/'
def __init__(self, baseurl='http://localhost:32400', token=None, session=None):
self.baseurl = baseurl or CONFIG.get('authentication.baseurl')
self.token = token or CONFIG.get('authentication.token')
if self.token:
logfilter.add_secret(self.token)
self.session = session or requests.Session()
self._baseurl = baseurl or CONFIG.get('authentication.server_baseurl')
self._token = token or CONFIG.get('authentication.server_token')
if self._token:
logfilter.add_secret(self._token)
self._session = session or requests.Session()
self._library = None # cached library
self.reload()
super(PlexServer, self).__init__(self, self._query(self.key), self.key)
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@ -138,18 +141,51 @@ class PlexServer(object):
self.voiceSearch = cast(bool, data.attrib.get('voiceSearch'))
def __repr__(self):
return '<%s:%s>' % (self.__class__.__name__, self.baseurl)
return '<%s:%s>' % (self.__class__.__name__, self._baseurl)
def _query(self, key, method=None, headers=None, **kwargs):
""" Main method used to handle HTTPS requests to the Plex server. This method helps
by encoding the response to utf-8 and parsing the returned XML into and
ElementTree object. Returns None if no data exists in the response.
"""
url = self._url(key)
method = method or self._session.get
log.info('%s %s', method.__name__.upper(), url)
headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=TIMEOUT, **kwargs)
if response.status_code not in (200, 201):
codename = codes.get(response.status_code)[0]
log.warn('BadRequest (%s) %s %s' % (response.status_code, codename, response.url))
raise BadRequest('(%s) %s %s' % (response.status_code, codename, response.url))
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data else None
def _headers(self, **kwargs):
""" Returns dict containing base headers for all requests to the server. """
headers = BASE_HEADERS.copy()
if self._token:
headers['X-Plex-Token'] = self._token
headers.update(kwargs)
return headers
def _url(self, key):
""" Build a URL string with proper token argument. """
if self._token:
delim = '&' if '?' in key else '?'
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
return '%s%s' % (self._baseurl, key)
@property
def library(self):
""" Library to browse or search your media. """
if not self._library:
self._library = Library(self, self.query('/library/'))
data = self._query(Library.key)
self._library = Library(self, data)
return self._library
def account(self):
""" Returns the :class:`~plexapi.server.Account` object this server belongs to. """
data = self.query('/myplex/account')
data = self._query(Account.key)
return Account(self, data)
def clients(self):
@ -157,7 +193,8 @@ class PlexServer(object):
connected to this server.
"""
items = []
for elem in self.query('/clients'):
for elem in self._query('/clients'):
print(elem.attrib)
baseurl = 'http://%s:%s' % (elem.attrib['host'], elem.attrib['port'])
items.append(PlexClient(baseurl, server=self, data=elem))
return items
@ -171,7 +208,7 @@ class PlexServer(object):
Raises:
:class:`~plexapi.exceptions.NotFound`: Unknown client name
"""
for elem in self.query('/clients'):
for elem in self._query('/clients'):
if elem.attrib.get('name').lower() == name.lower():
baseurl = 'http://%s:%s' % (elem.attrib['host'], elem.attrib['port'])
return PlexClient(baseurl, server=self, data=elem)
@ -194,22 +231,16 @@ class PlexServer(object):
"""
return PlayQueue.create(self, item)
def headers(self):
""" Returns a dict containing base headers to include in all requests to the server. """
headers = BASE_HEADERS
if self.token:
headers['X-Plex-Token'] = self.token
return headers
def history(self):
""" Returns a list of media items from watched history. """
return utils.listItems(self, '/status/sessions/history/all')
#return utils.listItems(self, '/status/sessions/history/all')
return self._fetchItems('/status/sessions/history/all')
def playlists(self):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
# TODO: Add sort and type options?
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
return utils.listItems(self, '/playlists')
return self._fetchItems('/playlists')
def playlist(self, title):
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified title.
@ -225,45 +256,6 @@ class PlexServer(object):
return item
raise NotFound('Invalid playlist title: %s' % title)
def query(self, path, method=None, headers=None, **kwargs):
""" Main method used to handle HTTPS requests to the Plex server. This method helps
by encoding the response to utf-8 and parsing the returned XML into and
ElementTree object. Returns None if no data exists in the response.
Parameters:
path (str): Relative path to query on the server api (ex: '/search?query=HELLO')
method (func): requests.method to use for this query (request.get or
requests.put; defaults to get)
headers (dict): Optionally include additional headers for this request.
**kwargs (dict): Optionally include additional kwargs for in the specified
reuqest method. These kwargs are simply passed through to method().
Raises:
:class:`~plexapi.exceptions.BadRequest`: Raised when response is not in (200, 201).
"""
url = self.url(path)
method = method or self.session.get
log.info('%s %s', method.__name__.upper(), url)
h = self.headers().copy()
if headers:
h.update(headers)
response = method(url, headers=h, timeout=TIMEOUT, **kwargs)
#print(response.url)
if response.status_code not in [200, 201]: # pragma: no cover
codename = codes.get(response.status_code)[0]
raise BadRequest('(%s) %s %s' % (response.status_code, codename, response.url))
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data else None
def reload(self):
""" Reload attribute values from Plex XML response. """
try:
data = self.query('/')
self._loadData(data)
except Exception as err:
log.error('%s: %s', self.baseurl, err)
raise NotFound('No server found at: %s' % self.baseurl)
def search(self, query, mediatype=None, limit=None):
""" Returns a list of media items or filter categories from the resulting
`Hub Search <https://www.plex.tv/blog/seek-plex-shall-find-leveling-web-app/>`_
@ -294,16 +286,8 @@ class PlexServer(object):
def sessions(self):
""" Returns a list of all active session (currently playing) media objects. """
return utils.listItems(self, '/status/sessions')
def url(self, path):
""" Utility function to help build proper URL strings as well as always include
the requred authentication token for all api requests to the server.
"""
if self.token:
delim = '&' if '?' in path else '?'
return '%s%s%sX-Plex-Token=%s' % (self.baseurl, path, delim, self.token)
return '%s%s' % (self.baseurl, path)
return self._fetchItems('/status/sessions')
#return utils.listItems(self, '/status/sessions')
def transcodeImage(self, media, height, width, opacity=100, saturation=100):
""" Returns the URL for a transcoded image from the specified media object.
@ -322,7 +306,7 @@ class PlexServer(object):
return self.url(transcode_url)
class Account(object):
class Account(PlexObject):
""" Contains the locally cached MyPlex account information. The properties provided don't
match the :class:`~plexapi.myplex.MyPlexAccount` object very well. I believe this exists
because access to myplex is not required to get basic plex information. I can't imagine
@ -351,7 +335,9 @@ class Account(object):
subscriptionState (str): 'Active' if this subscription is active.
username (str): Plex account username (user@example.com).
"""
def __init__(self, server, data):
key = '/myplex/account'
def _loadData(self, data):
self._data = data
self.authToken = data.attrib.get('authToken')
self.username = data.attrib.get('username')

View file

@ -5,32 +5,29 @@ from plexapi.exceptions import NotFound
class SyncItem(object):
"""Summary
""" Sync Item (NOT WORKING?)
Attributes:
device (TYPE): Description
id (TYPE): Description
location (TYPE): Description
machineIdentifier (TYPE): Description
MediaSettings (TYPE): Description
metadataType (TYPE): Description
policy (TYPE): Description
rootTitle (TYPE): Description
servers (TYPE): Description
status (TYPE): Description
title (TYPE): Description
version (TYPE): Description
Attributes:
device (TYPE): Description
id (TYPE): Description
location (TYPE): Description
machineIdentifier (TYPE): Description
MediaSettings (TYPE): Description
metadataType (TYPE): Description
policy (TYPE): Description
rootTitle (TYPE): Description
servers (TYPE): Description
status (TYPE): Description
title (TYPE): Description
version (TYPE): Description
"""
def __init__(self, device, data, servers=None):
"""Summary
self._device = device
self._servers = servers
self._loadData(data)
Args:
device (TYPE): Description
data (TYPE): Description
servers (None, optional): Description
"""
self.device = device
self.servers = servers
def _loadData(self, data):
self._data = data
self.id = utils.cast(int, data.attrib.get('id'))
self.version = utils.cast(int, data.attrib.get('version'))
self.rootTitle = data.attrib.get('rootTitle')
@ -43,49 +40,21 @@ class SyncItem(object):
self.location = data.find('Location').attrib.copy()
def __repr__(self):
"""Summary
Returns:
TYPE: Description
"""
return '<%s:%s>' % (self.__class__.__name__, self.id)
def server(self):
"""Summary
Returns:
TYPE: Description
Raises:
NotFound: Description
"""
server = list(filter(lambda x: x.machineIdentifier ==
self.machineIdentifier, self.servers))
server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers))
if 0 == len(server):
raise NotFound('Unable to find server with uuid %s' %
self.machineIdentifier)
raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier)
return server[0]
def getMedia(self):
"""Summary
Returns:
TYPE: Description
"""
server = self.server().connect()
items = utils.listItems(server, '/sync/items/%s' % self.id)
return items
def markAsDone(self, sync_id):
"""Summary
Args:
sync_id (TYPE): Description
Returns:
TYPE: Description
"""
server = self.server().connect()
url = '/sync/%s/%s/files/%s/downloaded' % (
self.device.clientIdentifier, server.machineIdentifier, sync_id)
self._device.clientIdentifier, server.machineIdentifier, sync_id)
server.query(url, method=requests.put)

View file

@ -39,26 +39,26 @@ def register_libtype(cls):
return cls
def buildItem(server, elem, initpath, bytag=False):
""" Factory function to build the objects used within the PlexAPI.
# def buildItem(server, elem, initpath, bytag=False):
# """ Factory function to build the objects used within the PlexAPI.
Parameters:
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
elem (ElementTree): XML data needed to build the object.
initpath (str): Relative path requested when retrieving specified `data` (optional).
bytag (bool): Creates the object from the name specified by the tag instead of the
default which builds the object specified by the type attribute. <tag type='foo' />
# Parameters:
# server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
# elem (ElementTree): XML data needed to build the object.
# initpath (str): Relative path requested when retrieving specified `data` (optional).
# bytag (bool): Creates the object from the name specified by the tag instead of the
# default which builds the object specified by the type attribute. <tag type='foo' />
Raises:
UnknownType: Unknown library type.
"""
libtype = elem.tag if bytag else elem.attrib.get('type')
if libtype == 'photo' and elem.tag == 'Directory':
libtype = 'photoalbum'
if libtype in LIBRARY_TYPES:
cls = LIBRARY_TYPES[libtype]
return cls(server, elem, initpath)
raise UnknownType('Unknown library type: %s' % libtype)
# Raises:
# UnknownType: Unknown library type.
# """
# libtype = elem.tag if bytag else elem.attrib.get('type')
# if libtype == 'photo' and elem.tag == 'Directory':
# libtype = 'photoalbum'
# if libtype in LIBRARY_TYPES:
# cls = LIBRARY_TYPES[libtype]
# return cls(server, elem, initpath)
# raise UnknownType('Unknown library type: %s' % libtype)
def cast(func, value):
@ -319,7 +319,6 @@ def threaded(callback, listargs):
threads[-1].start()
for thread in threads:
thread.join()
return results
@ -366,6 +365,7 @@ def download(url, filename=None, savepath=None, session=None, chunksize=4024, mo
>>> download(a_episode.getStreamURL(), a_episode.location)
/path/to/file
"""
# TODO: Review this; It should be properly logging and raising exceptions..
session = session or requests.Session()
print('Mocked download %s' % mocked)
if savepath is None:

View file

@ -7,23 +7,9 @@ from plexapi.base import Playable, PlexPartialObject
class Video(PlexPartialObject):
TYPE = None
def __init__(self, server, data, initpath):
"""Default class for all video types.
Args:
server (Plexserver): The PMS server your connected to
data (Element): Element built from server.query
initpath (str): Relativ path fx /library/sections/1/all
"""
super(Video, self).__init__(data, initpath, server)
def _loadData(self, data):
"""Used to set the attributes
Args:
data (Element): Usually built from server.query
"""
""" Used to set the attributes. """
self._data = data
self.listType = 'video'
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.key = data.attrib.get('key')
@ -42,7 +28,7 @@ class Video(PlexPartialObject):
def thumbUrl(self):
"""Return url to thumb image."""
if self.thumb:
return self.server.url(self.thumb)
return self._root.url(self.thumb)
def analyze(self):
"""The primary purpose of media analysis is to gather information about
@ -50,28 +36,28 @@ class Video(PlexPartialObject):
that are useful to knowwhether it's a video file,
a music track, or one of your photos.
"""
self.server.query('/%s/analyze' % self.key.lstrip('/'), method=self.server.session.put)
self._root.query('/%s/analyze' % self.key.lstrip('/'), method=self._root._session.put)
def markWatched(self):
"""Mark a items as watched."""
path = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self.server.query(path)
self._root.query(path)
self.reload()
def markUnwatched(self):
"""Mark a item as unwatched."""
path = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self.server.query(path)
self._root.query(path)
self.reload()
def refresh(self):
"""Refresh a item."""
self.server.query('%s/refresh' %
self.key, method=self.server.session.put)
self._root.query('%s/refresh' %
self.key, method=self._root._session.put)
def section(self):
"""Library section."""
return self.server.library.sectionByID(self.librarySectionID)
return self._root.library.sectionByID(self.librarySectionID)
@utils.register_libtype
@ -106,20 +92,18 @@ class Movie(Video, Playable):
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
if self.isFullObject(): # check this
self.collections = [media.Collection(self.server, e) for e in data if e.tag == media.Collection.TYPE]
self.countries = [media.Country(self.server, e) for e in data if e.tag == media.Country.TYPE]
self.directors = [media.Director(self.server, e) for e in data if e.tag == media.Director.TYPE]
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
self.media = [media.Media(self.server, e, self.initpath, self) for e in data if e.tag == media.Media.TYPE]
self.producers = [media.Producer(self.server, e) for e in data if e.tag == media.Producer.TYPE]
self.roles = [media.Role(self.server, e) for e in data if e.tag == media.Role.TYPE]
self.writers = [media.Writer(self.server, e) for e in data if e.tag == media.Writer.TYPE]
self.fields = [media.Field(e) for e in data if e.tag == media.Field.TYPE]
self.videoStreams = utils.findStreams(self.media, 'videostream')
self.audioStreams = utils.findStreams(self.media, 'audiostream')
self.subtitleStreams = utils.findStreams(
self.media, 'subtitlestream')
self.collections = self._buildSubitems(data, media.Collection)
self.countries = self._buildSubitems(data, media.Country)
self.directors = self._buildSubitems(data, media.Director)
self.fields = self._buildSubitems(data, media.Field)
self.genres = self._buildSubitems(data, media.Genre)
self.media = self._buildSubitems(data, media.Media)
self.producers = self._buildSubitems(data, media.Producer)
self.roles = self._buildSubitems(data, media.Role)
self.writers = self._buildSubitems(data, media.Writer)
# self.videoStreams = utils.findStreams(self.media, 'videostream') # these dont go here
# self.audioStreams = utils.findStreams(self.media, 'audiostream') # these dont go here
# self.subtitleStreams = utils.findStreams(self.media, 'subtitlestream') # these dont go here
@property
def actors(self):
@ -152,8 +136,8 @@ class Movie(Video, Playable):
if kwargs:
download_url = self.getStreamURL(**kwargs)
else:
download_url = self.server.url('%s?download=1' % loc.key)
dl = utils.download(download_url, filename=name, savepath=savepath, session=self.server.session)
download_url = self._root.url('%s?download=1' % loc.key)
dl = utils.download(download_url, filename=name, savepath=savepath, session=self._root._session)
if dl:
downloaded.append(dl)
return downloaded
@ -164,13 +148,8 @@ class Show(Video):
TYPE = 'show'
def _loadData(self, data):
"""Used to set the attributes
Args:
data (Element): Usually built from server.query
"""
Video._loadData(self, data)
# Incase this was loaded from search etc
# fix the key if this was loaded from search..
self.key = self.key.replace('/children', '')
self.art = data.attrib.get('art')
self.banner = data.attrib.get('banner')
@ -186,12 +165,10 @@ class Show(Video):
self.rating = utils.cast(float, data.attrib.get('rating'))
self.studio = data.attrib.get('studio')
self.theme = data.attrib.get('theme')
self.viewedLeafCount = utils.cast(
int, data.attrib.get('viewedLeafCount'))
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year'))
if self.isFullObject(): # will be fixed with docs.
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
self.roles = [media.Role(self.server, e) for e in data if e.tag == media.Role.TYPE]
self.genres = self._buildSubitems(data, media.Genre)
self.roles = self._buildSubitems(data, media.Role)
@property
def actors(self):
@ -203,20 +180,19 @@ class Show(Video):
def seasons(self):
"""Returns a list of Season."""
path = '/library/metadata/%s/children' % self.ratingKey
return utils.listItems(self.server, path, Season.TYPE)
key = '/library/metadata/%s/children' % self.ratingKey
return self._fetchItems(key, Season.TYPE)
def season(self, title=None):
"""Returns a Season
""" Returns the season with the specified title or number.
Args:
title (str, int): fx Season 1
Parameters:
title (str or int): Title or Number of the season to return.
"""
if isinstance(title, int):
title = 'Season %s' % title
path = '/library/metadata/%s/children' % self.ratingKey
return utils.findItem(self.server, path, title)
key = '/library/metadata/%s/children' % self.ratingKey
return self._fetchItem(key, title)
def episodes(self, watched=None):
"""Returs a list of Episode
@ -225,7 +201,7 @@ class Show(Video):
watched (bool): Defaults to None. Exclude watched episodes
"""
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
return utils.listItems(self.server, leavesKey, watched=watched)
return utils.listItems(self._root, leavesKey, watched=watched)
def episode(self, title=None, season=None, episode=None):
"""Find a episode using a title or season and episode.
@ -254,18 +230,14 @@ class Show(Video):
"""
if not title and (not season or not episode):
raise TypeError('Missing argument: title or season and episode are required')
if title:
path = '/library/metadata/%s/allLeaves' % self.ratingKey
return utils.findItem(self.server, path, title)
return utils.findItem(self._root, path, title)
elif season and episode:
results = [i for i in self.episodes()
if i.seasonNumber == season and i.index == episode]
results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode]
if results:
return results[0]
else:
raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode))
raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode))
def watched(self):
"""Return a list of watched episodes"""
@ -289,7 +261,7 @@ class Show(Video):
def refresh(self):
"""Refresh the metadata."""
self.server.query('/library/metadata/%s/refresh' % self.ratingKey, method=self.server.session.put)
self._root.query('/library/metadata/%s/refresh' % self.ratingKey, method=self._root._session.put)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
downloaded = []
@ -317,8 +289,7 @@ class Season(Video):
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentTitle = data.attrib.get('parentTitle')
self.viewedLeafCount = utils.cast(
int, data.attrib.get('viewedLeafCount'))
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
@property
def isWatched(self):
@ -330,18 +301,15 @@ class Season(Video):
return self.index
def episodes(self, watched=None):
"""Returs a list of Episode
""" Returs a list of Episode
Args:
Parameters:
watched (bool): Defaults to None. Exclude watched episodes
Returns:
list: of Episode
"""
childrenKey = '/library/metadata/%s/children' % self.ratingKey
return utils.listItems(self.server, childrenKey, watched=watched)
key = '/library/metadata/%s/children' % self.ratingKey
return self._fetchItems(key, Episode.TYPE)
# childrenKey = '/library/metadata/%s/children' % self.ratingKey
# return utils.listItems(self._root, childrenKey, watched=watched)
def episode(self, title=None, episode=None):
"""Find a episode using a title or season and episode.
@ -371,7 +339,7 @@ class Season(Video):
raise TypeError('Missing argument, you need to use title or episode.')
if title:
path = '/library/metadata/%s/children' % self.ratingKey
return utils.findItem(self.server, path, title)
return utils.findItem(self._root, path, title)
elif episode:
results = [i for i in self.episodes() if i.seasonNumber == self.index and i.index == episode]
if results:
@ -391,7 +359,7 @@ class Season(Video):
def show(self):
"""Return this seasons show."""
return utils.listItems(self.server, self.parentKey)[0]
return utils.listItems(self._root, self.parentKey)[0]
def watched(self):
"""Returns a list of watched Episode"""
@ -428,6 +396,7 @@ class Episode(Video, Playable):
"""
Video._loadData(self, data)
Playable._loadData(self, data)
self._seasonNumber = None # cached season number
self.art = data.attrib.get('art')
self.chapterSource = data.attrib.get('chapterSource')
self.contentRating = data.attrib.get('contentRating')
@ -448,19 +417,17 @@ class Episode(Video, Playable):
self.rating = utils.cast(float, data.attrib.get('rating'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.directors = [media.Director(self.server, e) for e in data if e.tag == media.Director.TYPE]
self.media = [media.Media(self.server, e, self.initpath, self) for e in data if e.tag == media.Media.TYPE]
self.writers = [media.Writer(self.server, e) for e in data if e.tag == media.Writer.TYPE]
self.videoStreams = utils.findStreams(self.media, 'videostream')
self.audioStreams = utils.findStreams(self.media, 'audiostream')
self.subtitleStreams = utils.findStreams(self.media, 'subtitlestream')
self.directors = self._buildSubitems(data, media.Director)
self.media = self._buildSubitems(data, media.Media)
self.writers = self._buildSubitems(data, media.Writer)
# self.videoStreams = utils.findStreams(self.media, 'videostream')
# self.audioStreams = utils.findStreams(self.media, 'audiostream')
# self.subtitleStreams = utils.findStreams(self.media, 'subtitlestream')
# data for active sessions and history
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))
self.username = utils.findUsername(data)
self.player = utils.findPlayer(self.server, data)
self.transcodeSession = utils.findTranscodeSession(self.server, data)
# Cached season number
self._seasonNumber = None
self.player = utils.findPlayer(self._root, data)
self.transcodeSession = utils.findTranscodeSession(self._root, data)
def __repr__(self):
clsname = self.__class__.__name__
@ -484,15 +451,15 @@ class Episode(Video, Playable):
def thumbUrl(self):
"""Return url to thumb image."""
if self.grandparentThumb:
return self.server.url(self.grandparentThumb)
return self._root.url(self.grandparentThumb)
def season(self):
"""Return this episode Season"""
return utils.listItems(self.server, self.parentKey)[0]
return utils.listItems(self._root, self.parentKey)[0]
def show(self):
"""Return this episodes Show"""
return utils.listItems(self.server, self.grandparentKey)[0]
return utils.listItems(self._root, self.grandparentKey)[0]
@property
def location(self):

View file

@ -15,9 +15,9 @@ from utils import log, itertests
def runtests(args):
# Get username and password from environment
username = args.username or CONFIG.get('authentication.username')
password = args.password or CONFIG.get('authentication.password')
resource = args.resource or CONFIG.get('authentication.resource')
username = args.username or CONFIG.get('authentication.myplex_username')
password = args.password or CONFIG.get('authentication.myplex_password')
resource = args.resource or CONFIG.get('authentication.server_resource')
# Register known tests
for loader, name, ispkg in pkgutil.iter_modules([dirname(abspath(__file__))]):
if name.startswith('test_'):

View file

@ -58,7 +58,7 @@ def plex_account():
username = test_username
password = test_password
assert username and password
account = MyPlexAccount.signin(username, password)
account = MyPlexAccount(username, password)
assert account
return account

View file

@ -5,120 +5,250 @@ This script loops through all media items to build a collection of attributes on
each media type. The resulting list can be compared with the current object
implementation in python-plex api to track new attributes and depricate old ones.
"""
import argparse, copy, pickle, plexapi, os, sys
import argparse, copy, pickle, plexapi, os, sys, time
from collections import defaultdict
from datetime import datetime
from plexapi import library
from plexapi.base import PlexObject
from plexapi.myplex import MyPlexAccount
from plexapi.server import PlexServer
from plexapi.utils import NA
from plexapi.playqueue import PlayQueue
CACHEPATH = '/tmp/findattrs.pickle'
NAMESPACE = {
'xml': defaultdict(int),
'obj': defaultdict(int),
'examples': defaultdict(set),
'total': 0
'categories': defaultdict(set),
'total': 0,
'old': 0,
'new': 0,
}
IGNORES = {
'server.PlexServer': ['baseurl', 'token', 'session'],
'myplex.ResourceConnection': ['httpuri'],
'client.PlexClient': ['baseurl'],
}
DONT_RELOAD = (
'library.MovieSection',
'library.MusicSection',
'library.PhotoSection',
'library.ShowSection',
'media.MediaPart', # tries to download the file
'media.VideoStream',
'media.AudioStream',
'media.SubtitleStream',
'myplex.MyPlexAccount',
'myplex.MyPlexUser',
'myplex.MyPlexResource',
'myplex.ResourceConnection',
'myplex.MyPlexDevice',
'photo.Photoalbum',
'server.Account',
'client.PlexClient', # we dont have the token to reload.
#'server.PlexServer', # setting version to None? :(
)
STOP_RECURSING_AT = (
#'media.MediaPart',
)
class PlexAttributes():
def __init__(self, opts):
self.opts = opts # command line options
self.clsnames = [c for c in opts.clsnames.split(',') if c] # list of clsnames to report (blank=all)
self.account = MyPlexAccount.signin() # MyPlexAccount instance
self.account = MyPlexAccount() # MyPlexAccount instance
self.plex = PlexServer() # PlexServer instance
self.total = 0 # Total objects parsed
self.attrs = defaultdict(dict) # Attrs result set
def run(self):
# MyPlex
self._load_attrs(self.account)
self._load_attrs(self.account.devices())
for resource in self.account.resources():
self._load_attrs(resource)
self._load_attrs(resource.connections)
self._load_attrs(self.account.users())
# Server
self._load_attrs(self.plex)
self._load_attrs(self.plex.account())
self._load_attrs(self.plex.history()[:20])
self._load_attrs(self.plex.playlists())
for search in ('cre', 'ani', 'mik', 'she'):
self._load_attrs(self.plex.search('cre'))
self._load_attrs(self.plex.sessions())
# Library
self._load_attrs(self.plex.library)
self._load_attrs(self.plex.library.sections())
self._load_attrs(self.plex.library.all()[:20])
self._load_attrs(self.plex.library.onDeck()[:20])
self._load_attrs(self.plex.library.recentlyAdded()[:20])
for search in ('cat', 'dog', 'rat'):
self._load_attrs(self.plex.library.search(search)[:20])
# Client
self._load_attrs(self.plex.clients())
starttime = time.time()
# self._parse_myplex()
# self._parse_server()
# self._parse_library()
# self._parse_audio()
# self._parse_photo()
# self._parse_movie()
# self._parse_show()
# self._parse_client()
# self._parse_playlist()
# self._parse_sync()
self.runtime = round((time.time() - starttime) / 60.0, 1)
return self
def _load_attrs(self, obj):
if isinstance(obj, (list, tuple)):
return [self._parse_objects(x) for x in obj]
return self._parse_objects(obj)
def _parse_myplex(self):
self._load_attrs(self.account, 'myplex')
self._load_attrs(self.account.devices(), 'myplex')
for resource in self.account.resources():
self._load_attrs(resource, 'myplex')
self._load_attrs(resource.connections, 'myplex')
self._load_attrs(self.account.users(), 'myplex')
def _parse_objects(self, obj):
def _parse_server(self):
self._load_attrs(self.plex, 'serv')
self._load_attrs(self.plex.account(), 'serv')
self._load_attrs(self.plex.history()[:20], 'hist')
# self._load_attrs(self.plex.playlists())
# for search in ('cre', 'ani', 'mik', 'she'):
# self._load_attrs(self.plex.search('cre'))
# self._load_attrs(self.plex.sessions(), 'sess')
def _parse_library(self):
cat = 'lib'
self._load_attrs(self.plex.library, cat)
# self._load_attrs(self.plex.library.sections())
# self._load_attrs(self.plex.library.all()[:20])
# self._load_attrs(self.plex.library.onDeck()[:20])
# self._load_attrs(self.plex.library.recentlyAdded()[:20])
# for search in ('cat', 'dog', 'rat'):
# self._load_attrs(self.plex.library.search(search)[:20])
def _parse_audio(self):
cat = 'lib'
for musicsection in self.plex.library.sections():
if musicsection.TYPE == library.MusicSection.TYPE:
self._load_attrs(musicsection, cat)
for artist in musicsection.all():
self._load_attrs(artist, cat)
for album in artist.albums():
self._load_attrs(album, cat)
for track in album.tracks():
self._load_attrs(track, cat)
def _parse_photo(self):
cat = 'lib'
for photosection in self.plex.library.sections():
if photosection.TYPE == library.PhotoSection.TYPE:
self._load_attrs(photosection, cat)
for photoalbum in photosection.all():
self._load_attrs(photoalbum, cat)
for photo in photoalbum.photos():
self._load_attrs(photo, cat)
def _parse_movie(self):
cat = 'lib'
for moviesection in self.plex.library.sections():
if moviesection.TYPE == library.MovieSection.TYPE:
self._load_attrs(moviesection, cat)
for movie in moviesection.all():
self._load_attrs(movie, cat)
def _parse_show(self):
cat = 'lib'
for showsection in self.plex.library.sections():
if showsection.TYPE == library.ShowSection.TYPE:
self._load_attrs(showsection, cat)
for show in showsection.all():
self._load_attrs(show, cat)
for season in show.seasons():
self._load_attrs(show, cat)
for episode in season.episodes():
self._load_attrs(episode, cat)
def _parse_client(self):
for device in self.account.devices():
client = device.connect(safe=True)
if client is not None:
self._load_attrs(client, 'myplex')
for client in self.plex.clients():
client.connect(safe=True)
self._load_attrs(client, 'client')
def _parse_playlist(self):
for playlist in self.plex.playlists():
self._load_attrs(playlist, 'pl')
for item in playlist.items():
self._load_attrs(item, 'pl')
playqueue = PlayQueue.create(self.plex, playlist)
self._load_attrs(playqueue, 'pq')
def _parse_sync(self):
# TODO: Get this working..
pass
def _load_attrs(self, obj, cat=None):
if isinstance(obj, (list, tuple)):
return [self._parse_objects(item, cat) for item in obj]
self._parse_objects(obj, cat)
def _parse_objects(self, obj, cat=None):
clsname = '%s.%s' % (obj.__module__, obj.__class__.__name__)
clsname = clsname.replace('plexapi.', '')
if self.clsnames and clsname not in self.clsnames:
return None
sys.stdout.write('.'); sys.stdout.flush()
self._print_the_little_dot()
if clsname not in self.attrs:
self.attrs[clsname] = copy.deepcopy(NAMESPACE)
self.attrs[clsname]['total'] += 1
self._load_xml_attrs(clsname, obj._data, self.attrs[clsname]['xml'], self.attrs[clsname]['examples'])
self._load_xml_attrs(clsname, obj._data, self.attrs[clsname]['xml'],
self.attrs[clsname]['examples'], self.attrs[clsname]['categories'], cat)
self._load_obj_attrs(clsname, obj, self.attrs[clsname]['obj'])
def _load_xml_attrs(self, clsname, elem, attrs, examples):
if elem in (None, NA): return None
def _print_the_little_dot(self):
self.total += 1
if not self.total % 100:
sys.stdout.write('.')
if not self.total % 8000:
sys.stdout.write('\n')
sys.stdout.flush()
def _load_xml_attrs(self, clsname, elem, attrs, examples, categories, cat):
if elem is None: return None
for attr in sorted(elem.attrib.keys()):
attrs[attr] += 1
if cat: categories[attr].add(cat)
if elem.attrib[attr] and len(examples[attr]) <= self.opts.examples:
examples[attr].add(elem.attrib[attr])
def _load_obj_attrs(self, clsname, obj, attrs):
if clsname in STOP_RECURSING_AT: return None
if isinstance(obj, PlexObject) and clsname not in DONT_RELOAD: obj.reload(safe=True)
for attr, value in obj.__dict__.items():
if value in (None, NA) or isinstance(value, (str, bool, float, int, datetime)):
if value is None or isinstance(value, (str, bool, float, int, datetime)):
if not attr.startswith('_') and attr not in IGNORES.get(clsname, []):
attrs[attr] += 1
if isinstance(value, list):
if not attr.startswith('_') and attr not in IGNORES.get(clsname, []):
if value and isinstance(value[0], PlexObject):
attrs['%s[]' % attr] += 1
[self._parse_objects(obj) for obj in value]
def print_report(self):
total_attrs = 0
for clsname in sorted(self.attrs.keys()):
meta = self.attrs[clsname]
count = meta['total']
print(_('\n%s (%s)\n%s' % (clsname, count, '-'*(len(clsname)+8)), 'yellow'))
print(_('\n%s (%s)\n%s' % (clsname, count, '-'*30), 'yellow'))
attrs = sorted(set(list(meta['xml'].keys()) + list(meta['obj'].keys())))
for attr in attrs:
state = self._attr_state(attr, meta)
state = self._attr_state(clsname, attr, meta)
count = meta['xml'].get(attr, 0)
example = list(meta['examples'].get(attr, ['--']))[0][:80]
print('%-4s %4s %-30s %s' % (state, count, attr, example))
categories = ','.join(meta['categories'].get(attr, ['--']))
examples = '; '.join(list(meta['examples'].get(attr, ['--']))[:3])[:80]
print('%7s %3s %-30s %-20s %s' % (count, state, attr, categories, examples))
total_attrs += count
print(_('\nSUMMARY\n------------', 'yellow'))
print('Plex Version %s' % self.plex.version)
print('PlexAPI Version %s' % plexapi.VERSION)
print('Total Objects %s\n' % sum([x['total'] for x in self.attrs.values()]))
print(_('\nSUMMARY\n%s' % ('-'*30), 'yellow'))
print('%7s %3s %3s %-20s %s' % ('total', 'new', 'old', 'categories', 'clsname'))
for clsname in sorted(self.attrs.keys()):
print('%-34s %s' % (clsname, self.attrs[clsname]['total']))
print()
print('%7s %12s %12s %s' % (self.attrs[clsname]['total'],
_(self.attrs[clsname]['new'] or '', 'cyan'),
_(self.attrs[clsname]['old'] or '', 'red'),
clsname))
print('\nPlex Version %s' % self.plex.version)
print('PlexAPI Version %s' % plexapi.VERSION)
print('Total Objects %s' % sum([x['total'] for x in self.attrs.values()]))
print('Runtime %s min\n' % self.runtime)
def _attr_state(self, attr, meta):
if attr in meta['xml'].keys() and attr not in meta['obj'].keys(): return _('new', 'blue')
if attr not in meta['xml'].keys() and attr in meta['obj'].keys(): return _('old', 'red')
def _attr_state(self, clsname, attr, meta):
if attr in meta['xml'].keys() and attr not in meta['obj'].keys():
self.attrs[clsname]['new'] += 1
return _('new', 'blue')
if attr not in meta['xml'].keys() and attr in meta['obj'].keys():
self.attrs[clsname]['old'] += 1
return _('old', 'red')
return _(' ', 'green')
def _(text, color):
FMTSTR = '\033[%dm%s\033[0m'
@ -144,102 +274,3 @@ if __name__ == '__main__':
pickle.dump(plexattrs, handle)
# Print Report
plexattrs.print_report()
# MAX_SEEN = 9999
# MAX_EXAMPLES = 10
# PICKLE_PATH = '/tmp/findattrs.pickle'
# ETYPES = ['artist', 'album', 'track', 'movie', 'show', 'season', 'episode']
# COLORS = {'blue':34, 'cyan':36, 'green':32, 'grey':30, 'magenta':35, 'red':31, 'white':37, 'yellow':33}
# RESET = '\033[0m'
# def color(text, color=None):
# """ Colorize text {red, green, yellow, blue, magenta, cyan, white}. """
# fmt_str = '\033[%dm%s'
# if color is not None:
# text = fmt_str % (COLORS[color], text)
# text += RESET
# return text
# def find_attrs(plex, key, result, examples, seen, indent=0):
# for elem in plex.query(key):
# attrs = sorted(elem.attrib.keys())
# etype = elem.attrib.get('type')
# if not etype or seen[etype] >= MAX_SEEN:
# continue
# seen[etype] += 1
# sys.stdout.write('.'); sys.stdout.flush()
# # find all attriutes in this element
# for attr in attrs:
# result[attr].add(etype)
# if elem.attrib[attr] and len(examples[attr]) <= MAX_EXAMPLES:
# examples[attr].add(elem.attrib[attr])
# # iterate all subelements
# for subelem in elem:
# if subelem.tag not in ETYPES:
# subattr = subelem.tag + '[]'
# result[subattr].add(etype)
# if len(examples[subattr]) <= MAX_EXAMPLES:
# xmlstr = ElementTree.tostring(subelem, encoding='utf8').split('\n')[1]
# examples[subattr].add(xmlstr)
# # recurse into the main element (loading its key)
# if etype in ETYPES:
# subkey = elem.attrib['key']
# if key != subkey and seen[etype] < MAX_SEEN:
# find_attrs(plex, subkey, result, examples, seen, indent+2)
# return result
# def find_all_attrs():
# try:
# result = defaultdict(set)
# examples = defaultdict(set)
# seen = defaultdict(int)
# plex = PlexServer()
# find_attrs(plex, '/status/sessions', result, examples, seen)
# for section in plex.library.sections():
# for elem in section.all():
# # check weve seen too many of this type of elem
# if seen[elem.type] >= MAX_SEEN:
# continue
# seen[elem.type] += 1
# # fetch the xml for this elem
# key = elem.key.replace('/children', '')
# find_attrs(plex, key, result, examples, seen)
# except KeyboardInterrupt:
# pass
# return result, examples, seen
# def print_results(result, examples, seen):
# print_general_summary(result, examples)
# print_summary_by_etype(result, examples)
# print_seen_etypes(seen)
# def print_general_summary(result, examples):
# print(color('\n--- general summary ---', 'cyan'))
# for attr, etypes in sorted(result.items(), key=lambda x: x[0].lower()):
# args = [attr]
# args += [etype if etype in etypes else '--' for etype in ETYPES]
# args.append(list(examples[attr])[0][:30] if examples[attr] else '_NA_')
# print('%-23s %-8s %-8s %-8s %-8s %-8s %-8s %-8s %s' % tuple(args))
# def print_summary_by_etype(result, examples):
# # find super attributes (in all etypes)
# result['super'] = set(['librarySectionID', 'index', 'titleSort'])
# for attr, etypes in result.items():
# if len(etypes) == 7:
# result['super'].add(attr)
# # print the summary
# for etype in ['super'] + ETYPES:
# print(color('\n--- %s ---' % etype, 'cyan'))
# for attr in sorted(result.keys(), key=lambda x:x[0].lower()):
# if (etype in result[attr] and attr not in result['super']) or (etype == 'super' and attr in result['super']):
# example = list(examples[attr])[0][:80] if examples[attr] else '_NA_'
# print('%-23s %s' % (attr, example))
# def print_seen_etypes(seen):
# print('\n')
# for etype, count in seen.items():
# print('%-8s %s' % (etype, count))