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/
bin/
include/
.cache/
.Python
pip-selfcheck.json
pyvenv.cfg

View file

@ -5,3 +5,4 @@ Thanks to Contributors:
* Nate Mara (Timeline)
* Goni Zahavy (Sync, Media Parts)
* 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.
* Get URL to stream stream h264/aac video (playable in VLC,MPV,etc).
* Plex Sync Support.
* Plex Audio Support.
#### 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
"""
from plexapi import video, utils
from plexapi import video, audio, utils
from plexapi.exceptions import NotFound
@ -19,7 +19,8 @@ class Library(object):
def sections(self):
items = []
SECTION_TYPES = {MovieSection.TYPE:MovieSection, ShowSection.TYPE:ShowSection}
SECTION_TYPES = {MovieSection.TYPE:MovieSection, ShowSection.TYPE:ShowSection,
MusicSection.TYPE:MusicSection}
path = '/library/sections'
for elem in self.server.query(path):
stype = elem.attrib['type']
@ -49,7 +50,7 @@ class Library(object):
def getByKey(self, 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.
title: Title to search (pass None to search all titles).
filter: One of {'all', 'onDeck', 'recentlyAdded'}.
@ -64,6 +65,23 @@ class Library(object):
query = '/library/%s%s' % (filter, utils.joinArgs(args))
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):
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)
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):
return {c.attrib['title']:c.attrib['key'] for c in server.query(path)}

View file

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