Merge branch 'master' of github.com:mjs7231/python-plexapi

This commit is contained in:
Michael Shepanski 2016-03-14 22:08:06 -04:00
commit b52da3cd95
6 changed files with 325 additions and 10 deletions

6
.gitignore vendored
View file

@ -12,3 +12,9 @@ build
lib/ lib/
bin/ bin/
include/ include/
.cache/
.Python
pip-selfcheck.json
pyvenv.cfg

View file

@ -5,3 +5,4 @@ Thanks to Contributors:
* Nate Mara (Timeline) * Nate Mara (Timeline)
* Goni Zahavy (Sync, Media Parts) * Goni Zahavy (Sync, Media Parts)
* Simon W. Jackson (Stream URL) * Simon W. Jackson (Stream URL)
* Håvard Gulldahl (Plex Audio)

View file

@ -7,7 +7,7 @@ Python bindings for the Plex API.
* Play media on connected clients. * Play media on connected clients.
* Get URL to stream stream h264/aac video (playable in VLC,MPV,etc). * Get URL to stream stream h264/aac video (playable in VLC,MPV,etc).
* Plex Sync Support. * Plex Sync Support.
* Plex Audio Support.
#### Install ### #### Install ###

245
plexapi/audio.py Normal file
View file

@ -0,0 +1,245 @@
"""
PlexAudio
"""
import re
from requests import put
from plexapi.client import Client
from plexapi.media import Media, Genre, Producer, Country #, TranscodeSession
from plexapi.myplex import MyPlexUser
from plexapi.exceptions import NotFound, UnknownType, Unsupported
from plexapi.utils import PlexPartialObject, NA
from plexapi.utils import cast, toDatetime
from plexapi.video import Video # TODO: remove this when the Audio class can stand on its own legs
try:
from urllib import urlencode # Python2
except ImportError:
from urllib.parse import urlencode # Python3
class Audio(Video): # TODO: inherit from PlexPartialObject, like the Video class does
def _loadData(self, data):
self.type = data.attrib.get('type', NA)
self.key = data.attrib.get('key', NA)
self.librarySectionID = data.attrib.get('librarySectionID', NA)
self.ratingKey = data.attrib.get('ratingKey', NA)
self.title = data.attrib.get('title', NA)
self.summary = data.attrib.get('summary', NA)
self.art = data.attrib.get('art', NA)
self.thumb = data.attrib.get('thumb', NA)
self.addedAt = toDatetime(data.attrib.get('addedAt', NA))
self.updatedAt = toDatetime(data.attrib.get('updatedAt', NA))
#self.lastViewedAt = toDatetime(data.attrib.get('lastViewedAt', NA))
self.sessionKey = cast(int, data.attrib.get('sessionKey', NA))
self.user = self._find_user(data) # for active sessions
self.player = self._find_player(data) # for active sessions
self.transcodeSession = self._find_transcodeSession(data)
if self.isFullObject():
# These are auto-populated when requested
self.media = [Media(self.server, elem, self.initpath, self) for elem in data if elem.tag == Media.TYPE]
self.genres = [Genre(self.server, elem) for elem in data if elem.tag == Genre.TYPE]
self.producers = [Producer(self.server, elem) for elem in data if elem.tag == Producer.TYPE]
# will we ever see other elements?
#self.actors = [Actor(self.server, elem) for elem in data if elem.tag == Actor.TYPE]
#self.writers = [Writer(self.server, elem) for elem in data if elem.tag == Writer.TYPE]
def getStreamUrl(self, offset=0, **kwargs):
""" Fetch URL to stream audio directly.
offset: Start time (in seconds) audio will initiate from (ex: 300).
params: Dict of additional parameters to include in URL.
"""
if self.TYPE not in [Track.TYPE, Album.TYPE]:
raise Unsupported('Cannot get stream URL for %s.' % self.TYPE)
params = {}
params['path'] = self.key
params['offset'] = offset
params['copyts'] = kwargs.get('copyts', 1)
params['mediaIndex'] = kwargs.get('mediaIndex', 0)
params['X-Plex-Platform'] = kwargs.get('platform', 'Chrome')
if 'protocol' in kwargs:
params['protocol'] = kwargs['protocol']
return self.server.url('/audio/:/transcode/universal/start.m3u8?%s' % urlencode(params))
# TODO: figure out if we really need to override these methods, or if there is a bug in the default
# implementation
def isFullObject(self):
return self.initpath == '/library/metadata/{0!s}'.format(self.ratingKey)
def isPartialObject(self):
return self.initpath != '/library/metadata/{0!s}'.format(self.ratingKey)
def reload(self):
self.initpath = '/library/metadata/{0!s}'.format(self.ratingKey)
data = self.server.query(self.initpath)
self._loadData(data[0])
class Artist(Audio):
TYPE = 'artist'
def _loadData(self, data):
super(Artist, self)._loadData(data)
#TODO: get proper metadata for artists, not this blue copy
self.studio = data.attrib.get('studio', NA)
self.contentRating = data.attrib.get('contentRating', NA)
self.rating = data.attrib.get('rating', NA)
self.year = cast(int, data.attrib.get('year', NA))
self.banner = data.attrib.get('banner', NA)
self.theme = data.attrib.get('theme', NA)
self.duration = cast(int, data.attrib.get('duration', NA))
self.originallyAvailableAt = toDatetime(data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
self.leafCount = cast(int, data.attrib.get('leafCount', NA))
self.viewedLeafCount = cast(int, data.attrib.get('viewedLeafCount', NA))
self.childCount = cast(int, data.attrib.get('childCount', NA))
self.titleSort = data.attrib.get('titleSort', NA)
def albums(self):
path = '/library/metadata/%s/children' % self.ratingKey
return list_items(self.server, path, Album.TYPE)
def album(self, title):
path = '/library/metadata/%s/children' % self.ratingKey
return find_item(self.server, path, title)
def tracks(self, watched=None):
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
return list_items(self.server, leavesKey, watched=watched)
def track(self, title):
path = '/library/metadata/%s/allLeaves' % self.ratingKey
return find_item(self.server, path, title)
def watched(self):
return self.episodes(watched=True)
def unwatched(self):
return self.episodes(watched=False)
def get(self, title):
return self.track(title)
def refresh(self):
self.server.query('/library/metadata/%s/refresh' % self.ratingKey)
class Album(Audio):
TYPE = 'album'
def _loadData(self, data):
super(Album, self)._loadData(data)
#TODO: get proper metadata for artists, not this blue copy
self.librarySectionID = data.attrib.get('librarySectionID', NA)
self.librarySectionTitle = data.attrib.get('librarySectionTitle', NA)
self.parentRatingKey = data.attrib.get('parentRatingKey', NA)
self.parentKey = data.attrib.get('parentKey', NA)
self.parentTitle = data.attrib.get('parentTitle', NA)
self.parentSummary = data.attrib.get('parentSummary', NA)
self.index = data.attrib.get('index', NA)
self.parentIndex = data.attrib.get('parentIndex', NA)
self.parentThumb = data.attrib.get('parentThumb', NA)
self.parentTheme = data.attrib.get('parentTheme', NA)
self.leafCount = cast(int, data.attrib.get('leafCount', NA))
self.viewedLeafCount = cast(int, data.attrib.get('viewedLeafCount', NA))
self.year = cast(int, data.attrib.get('year', NA))
def tracks(self, watched=None):
childrenKey = '/library/metadata/%s/children' % self.ratingKey
return list_items(self.server, childrenKey, watched=watched)
def track(self, title):
path = '/library/metadata/%s/children' % self.ratingKey
return find_item(self.server, path, title)
def get(self, title):
return self.track(title)
def artist(self):
return list_items(self.server, self.parentKey)[0]
def watched(self):
return self.tracks(watched=True)
def unwatched(self):
return self.tracks(watched=False)
class Track(Audio):
TYPE = 'track'
def _loadData(self, data):
super(Track, self)._loadData(data)
self.librarySectionID = data.attrib.get('librarySectionID', NA)
self.librarySectionTitle = data.attrib.get('librarySectionTitle', NA)
self.grandparentKey = data.attrib.get('grandparentKey', NA)
self.grandparentTitle = data.attrib.get('grandparentTitle', NA)
self.grandparentThumb = data.attrib.get('grandparentThumb', NA)
self.grandparentArt = data.attrib.get('grandparentArt', NA)
self.parentKey = data.attrib.get('parentKey', NA)
self.parentTitle = data.attrib.get('parentTitle', NA)
self.parentIndex = data.attrib.get('parentIndex', NA)
self.parentThumb = data.attrib.get('parentThumb', NA)
self.contentRating = data.attrib.get('contentRating', NA)
self.index = data.attrib.get('index', NA)
self.rating = data.attrib.get('rating', NA)
self.duration = cast(int, data.attrib.get('duration', NA))
self.originallyAvailableAt = toDatetime(data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
@property
def thumbUrl(self):
return self.server.url(self.grandparentThumb)
def album(self):
return list_items(self.server, self.parentKey)[0]
def artist(self):
raise NotImplemented
#return list_items(self.server, self.grandparentKey)[0]
def build_item(server, elem, initpath):
AUDIOCLS = {Artist.TYPE:Artist, Album.TYPE:Album, Track.TYPE:Track}
atype = elem.attrib.get('type')
if atype in AUDIOCLS:
cls = AUDIOCLS[atype]
return cls(server, elem, initpath)
raise UnknownType('Unknown audio type: %s' % atype)
def find_key(server, key):
path = '/library/metadata/{0}'.format(key)
try:
# Video seems to be the first sub element
elem = server.query(path)[0]
return build_item(server, elem, path)
except:
raise NotFound('Unable to find key: %s' % key)
def find_item(server, path, title):
for elem in server.query(path):
if elem.attrib.get('title').lower() == title.lower():
return build_item(server, elem, path)
raise NotFound('Unable to find title: %s' % title)
def list_items(server, path, audiotype=None, watched=None):
items = []
for elem in server.query(path):
if audiotype and elem.attrib.get('type') != audiotype: continue
if watched is True and elem.attrib.get('viewCount', 0) == 0: continue
if watched is False and elem.attrib.get('viewCount', 0) >= 1: continue
try:
items.append(build_item(server, elem, path))
except UnknownType:
pass
return items
def search_type(audiotype):
if audiotype == Artist.TYPE: return 8
elif audiotype == Album.TYPE: return 9
elif audiotype == Track.TYPE: return 10
raise NotFound('Unknown audiotype: %s' % audiotype)

View file

@ -1,7 +1,7 @@
""" """
PlexLibrary PlexLibrary
""" """
from plexapi import video, utils from plexapi import video, audio, utils
from plexapi.exceptions import NotFound from plexapi.exceptions import NotFound
@ -19,7 +19,8 @@ class Library(object):
def sections(self): def sections(self):
items = [] items = []
SECTION_TYPES = {MovieSection.TYPE:MovieSection, ShowSection.TYPE:ShowSection} SECTION_TYPES = {MovieSection.TYPE:MovieSection, ShowSection.TYPE:ShowSection,
MusicSection.TYPE:MusicSection}
path = '/library/sections' path = '/library/sections'
for elem in self.server.query(path): for elem in self.server.query(path):
stype = elem.attrib['type'] stype = elem.attrib['type']
@ -49,7 +50,7 @@ class Library(object):
def getByKey(self, key): def getByKey(self, key):
return video.find_key(self.server, key) return video.find_key(self.server, key)
def search(self, title, filter='all', vtype=None, **tags): def searchVideo(self, title, filter='all', vtype=None, **tags):
""" Search all available content. """ Search all available content.
title: Title to search (pass None to search all titles). title: Title to search (pass None to search all titles).
filter: One of {'all', 'onDeck', 'recentlyAdded'}. filter: One of {'all', 'onDeck', 'recentlyAdded'}.
@ -64,6 +65,23 @@ class Library(object):
query = '/library/%s%s' % (filter, utils.joinArgs(args)) query = '/library/%s%s' % (filter, utils.joinArgs(args))
return video.list_items(self.server, query) return video.list_items(self.server, query)
search = searchVideo # TODO: make .search() a method to merge results of .searchVideo() and .searchAudio()
def searchAudio(self, title, filter='all', atype=None, **tags):
""" Search all available audio content.
title: Title to search (pass None to search all titles).
filter: One of {'all', 'onDeck', 'recentlyAdded'}.
atype: One of {'artist', 'album', 'track'}.
tags: One of {country, director, genre, producer, actor, writer}.
"""
args = {}
if title: args['title'] = title
if atype: args['type'] = audio.search_type(atype)
for tag, obj in tags.items():
args[tag] = obj.id
query = '/library/%s%s' % (filter, utils.joinArgs(args))
return audio.list_items(self.server, query)
def cleanBundles(self): def cleanBundles(self):
self.server.query('/library/clean/bundles') self.server.query('/library/clean/bundles')
@ -199,5 +217,37 @@ class ShowSection(LibrarySection):
return super(ShowSection, self).search(title, filter=filter, vtype=video.Episode.TYPE, **tags) return super(ShowSection, self).search(title, filter=filter, vtype=video.Episode.TYPE, **tags)
class MusicSection(LibrarySection):
TYPE = 'artist'
def search(self, title, filter='all', atype=None, **tags):
""" Search section content.
title: Title to search (pass None to search all titles).
filter: One of {'all', 'newest', 'onDeck', 'recentlyAdded', 'recentlyViewed', 'unwatched'}.
videotype: One of {'artist', 'album', 'track'}.
tags: One of {country, director, genre, producer, actor, writer}.
"""
args = {}
if title: args['title'] = title
if atype: args['type'] = audio.search_type(atype)
for tag, obj in tags.items():
args[tag] = obj.id
query = '/library/sections/%s/%s%s' % (self.key, filter, utils.joinArgs(args))
return audio.list_items(self.server, query)
def recentlyViewedShows(self):
return self._primary_list('recentlyViewedShows')
def searchArtists(self, title, filter='all', **tags):
return self.search(title, filter=filter, atype=audio.Artist.TYPE, **tags)
def searchAlbums(self, title, filter='all', **tags):
return self.search(title, filter=filter, atype=audio.Album.TYPE, **tags)
def searchTracks(self, title, filter='all', **tags):
return self.search(title, filter=filter, atype=audio.Track.TYPE, **tags)
def list_choices(server, path): def list_choices(server, path):
return {c.attrib['title']:c.attrib['key'] for c in server.query(path)} return {c.attrib['title']:c.attrib['key'] for c in server.query(path)}

View file

@ -4,7 +4,7 @@ PlexServer
import requests import requests
from requests.status_codes import _codes as codes from requests.status_codes import _codes as codes
from plexapi import BASE_HEADERS, TIMEOUT from plexapi import BASE_HEADERS, TIMEOUT
from plexapi import log, video from plexapi import log, video, audio
from plexapi.client import Client from plexapi.client import Client
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
from plexapi.library import Library from plexapi.library import Library
@ -24,9 +24,10 @@ DEFAULT_BASEURI = 'http://localhost:32400'
class PlexServer(object): class PlexServer(object):
def __init__(self, baseuri=None, token=None): def __init__(self, baseuri=None, token=None, session=None):
self.baseuri = baseuri or DEFAULT_BASEURI self.baseuri = baseuri or DEFAULT_BASEURI
self.token = token self.token = token
self.session = session # set this as a requests.session to use that session
data = self._connect() data = self._connect()
self.friendlyName = data.attrib.get('friendlyName') self.friendlyName = data.attrib.get('friendlyName')
self.machineIdentifier = data.attrib.get('machineIdentifier') self.machineIdentifier = data.attrib.get('machineIdentifier')
@ -80,10 +81,15 @@ class PlexServer(object):
headers['X-Plex-Token'] = self.token headers['X-Plex-Token'] = self.token
return headers return headers
def query(self, path, method=requests.get, **kwargs): def query(self, path, method=None, **kwargs):
global TOTAL_QUERIES global TOTAL_QUERIES
TOTAL_QUERIES += 1 TOTAL_QUERIES += 1
url = self.url(path) url = self.url(path)
if method is None:
if self.session is not None:
method = self.session.get
else:
method = requests.get
log.info('%s %s', method.__name__.upper(), url) log.info('%s %s', method.__name__.upper(), url)
response = method(url, headers=self.headers(), timeout=TIMEOUT, **kwargs) response = method(url, headers=self.headers(), timeout=TIMEOUT, **kwargs)
if response.status_code not in [200, 201]: if response.status_code not in [200, 201]:
@ -92,11 +98,18 @@ class PlexServer(object):
data = response.text.encode('utf8') data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data else None return ElementTree.fromstring(data) if data else None
def search(self, query, videotype=None): def search(self, query, mediatype=None):
query = quote(query) query = quote(query)
items = video.list_items(self, '/search?query=%s' % query) items = video.list_items(self, '/search?query=%s' % query)
if videotype: if mediatype:
return [item for item in items if item.type == videotype] return [item for item in items if item.type == mediatype]
return items
def searchAudio(self, query, mediatype=None):
query = quote(query)
items = audio.list_items(self, '/search?query=%s' % query)
if mediatype:
return [item for item in items if item.type == mediatype]
return items return items
def sessions(self): def sessions(self):