PlexKodiConnect/resources/lib/plexnet/playqueue.py
2018-09-30 13:16:17 +02:00

767 lines
26 KiB
Python

import re
import urllib
import time
import plexapp
import plexrequest
import callback
import plexobjects
import util
import signalsmixin
class AudioUsage(object):
def __init__(self, skipsPerHour, playQueueId):
self.HOUR = 3600
self.skipsPerHour = skipsPerHour
self.playQueueId = playQueueId
self.skips = []
def allowSkip(self):
if self.skipsPerHour < 0:
return True
self.updateSkips()
return len(self.skips) < self.skipsPerHour
def updateSkips(self, reset=False):
if reset or len(self.skips) == 0:
if reset:
self.skips = []
return
# Remove old skips if applicable
epoch = util.now()
if self.skips[0] + self.HOUR < epoch:
newSkips = []
for skip in self.skips:
if skip + self.HOUR > epoch:
newSkips.append(skip)
self.skips = newSkips
self.log("updated skips")
def registerSkip(self):
self.skips.append(util.now())
self.updateSkips()
self.log("registered skip")
def allowSkipMessage(self):
if self.skipsPerHour < 0 or self.allowSkip():
return None
return "You can skip {0} songs an hour per mix.".format(self.skipsPerHour)
def log(self, prefix):
util.DEBUG_LOG("AudioUsage {0}: total skips={1}, allowed skips={2}".format(prefix, len(self.skips), self.skipsPerHour))
class UsageFactory(object):
def __init__(self, play_queue):
self.playQueue = play_queue
self.type = play_queue.type
self.usage = play_queue.usage
@classmethod
def createUsage(cls, playQueue):
obj = cls(playQueue)
if obj.type:
if obj.type == "audio":
return obj.createAudioUsage()
util.DEBUG_LOG("Don't know how to usage for " + str(obj.type))
return None
def createAudioUsage(self):
skips = self.playQueue.container.stationSkipsPerHour.asInt(-1)
if skips == -1:
return None
# Create new usage if invalid, or if we start a new PQ, otherwise
# we'll return the existing usage for the PQ.
if not self.usage or self.usage.playQueueId != self.playQueue.id:
self.usage = AudioUsage(skips, self.playQueue.id)
return self.usage
class PlayOptions(util.AttributeDict):
def __init__(self, *args, **kwargs):
util.AttributeDict.__init__(self, *args, **kwargs)
# At the moment, this is really just a glorified struct. But the
# expected fields include key, shuffle, extraPrefixCount,
# and unwatched. We may give this more definition over time.
# These aren't widely used yet, but half inspired by a PMS discussion...
self.CONTEXT_AUTO = 0
self.CONTEXT_SELF = 1
self.CONTEXT_PARENT = 2
self.CONTEXT_CONTAINER = 3
self.context = self.CONTEXT_AUTO
def createLocalPlayQueue(item, children, contentType, options):
pass
class PlayQueueFactory(object):
def getContentType(self, item):
if item.isMusicOrDirectoryItem():
return "audio"
elif item.isVideoOrDirectoryItem():
return "video"
elif item.isPhotoOrDirectoryItem():
return "photo"
return None
def canCreateRemotePlayQueue(self):
if self.item.getServer().isSecondary():
reason = "Server is secondary"
elif not (self.item.isLibraryItem() or self.item.isGracenoteCollection() or self.item.isLibraryPQ):
reason = "Item is not a library item or gracenote collection"
else:
return True
util.DEBUG_LOG("Requires local play queue: " + reason)
return False
def itemRequiresRemotePlayQueue(self):
# TODO(rob): handle entire section? (if we create PQ's of sections)
# return item instanceof PlexSection || item.type == PlexObject.Type.artist;
return self.item.type == "artist"
def createPlayQueueForItem(item, children=None, options=None, args=None):
obj = PlayQueueFactory()
contentType = obj.getContentType(item)
if not contentType:
# TODO(schuyler): We may need to try harder, but I'm not sure yet. For
# example, what if we're shuffling an entire library?
#
# No reason to crash here. We can safely return None and move on.
# We'll stop if we're in dev mode to catch and debug.
#
util.DEBUG_LOG("Don't know how to create play queue for item " + repr(item))
return None
obj.item = item
options = PlayOptions(options or {})
if obj.canCreateRemotePlayQueue():
return createRemotePlayQueue(item, contentType, options, args)
else:
if obj.itemRequiresRemotePlayQueue():
util.DEBUG_LOG("Can't create remote PQs and item does not support local PQs")
return None
else:
return createLocalPlayQueue(item, children, contentType, options)
class PlayQueue(signalsmixin.SignalsMixin):
TYPE = 'playqueue'
isRemote = True
def __init__(self, server, contentType, options=None):
signalsmixin.SignalsMixin.__init__(self)
self.id = None
self.selectedId = None
self.version = -1
self.isShuffled = False
self.isRepeat = False
self.isRepeatOne = False
self.isLocalPlayQueue = False
self.isMixed = None
self.totalSize = 0
self.windowSize = 0
self.forcedWindow = False
self.container = None
# Forced limitations
self.allowShuffle = False
self.allowSeek = True
self.allowRepeat = False
self.allowSkipPrev = False
self.allowSkipNext = False
self.allowAddToQueue = False
self.refreshOnTimeline = False
self.server = server
self.type = contentType
self._items = []
self.options = options or util.AttributeDict()
self.usage = None
self.refreshTimer = None
self.canceled = False
self.responded = False
self.initialized = False
self.composite = plexobjects.PlexValue('', parent=self)
# Add a few default options for specific PQ types
if self.type == "audio":
self.options.includeRelated = True
elif self.type == "photo":
self.setRepeat(True)
def get(self, name):
return getattr(self, name, plexobjects.PlexValue('', parent=self))
@property
def defaultArt(self):
return self.current().defaultArt
def waitForInitialization(self):
start = time.time()
timeout = util.TIMEOUT
util.DEBUG_LOG('Waiting for playQueue to initialize...')
while not self.canceled and not self.initialized:
if not self.responded and time.time() - start > timeout:
util.DEBUG_LOG('PlayQueue timed out wating for initialization')
return self.initialized
time.sleep(0.1)
if self.initialized:
util.DEBUG_LOG('PlayQueue initialized in {0:.2f} secs: {1}'.format(time.time() - start, self))
else:
util.DEBUG_LOG('PlayQueue failed to initialize')
return self.initialized
def onRefreshTimer(self):
self.refreshTimer = None
self.refresh(True, False)
def refresh(self, force=True, delay=False, wait=False):
# Ignore refreshing local PQs
if self.isLocal():
return
if wait:
self.responded = False
self.initialized = False
# We refresh our play queue if the caller insists or if we only have a
# portion of our play queue loaded. In particular, this means that we don't
# refresh the play queue if we're asked to refresh because a new track is
# being played but we have the entire album loaded already.
if force or self.isWindowed():
if delay:
# We occasionally want to refresh the PQ in response to moving to a
# new item and starting playback, but if we refresh immediately:
# we probably end up refreshing before PMS realizes we've moved on.
# There's no great solution, but delaying our refresh by just a few
# seconds makes us much more likely to get an accurate window (and
# accurate selected IDs) from PMS.
if not self.refreshTimer:
self.refreshTimer = plexapp.createTimer(5000, self.onRefreshTimer)
plexapp.APP.addTimer(self.refreshTimer)
else:
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id))
self.addRequestOptions(request)
context = request.createRequestContext("refresh", callback.Callable(self.onResponse))
plexapp.APP.startRequest(request, context)
if wait:
return self.waitForInitialization()
def shuffle(self, shuffle=True):
self.setShuffle(shuffle)
def setShuffle(self, shuffle=None):
if shuffle is None:
shuffle = not self.isShuffled
if self.isShuffled == shuffle:
return
if shuffle:
command = "/shuffle"
else:
command = "/unshuffle"
# Don't change self.isShuffled, it'll be set in OnResponse if all goes well
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + command, "PUT")
self.addRequestOptions(request)
context = request.createRequestContext("shuffle", callback.Callable(self.onResponse))
plexapp.APP.startRequest(request, context)
def setRepeat(self, repeat, one=False):
if self.isRepeat == repeat and self.isRepeatOne == one:
return
self.options.repeat = repeat
self.isRepeat = repeat
self.isRepeatOne = one
def moveItemUp(self, item):
for index in range(1, len(self._items)):
if self._items[index].get("playQueueItemID") == item.get("playQueueItemID"):
if index > 1:
after = self._items[index - 2]
else:
after = None
self.swapItem(index, -1)
self.moveItem(item, after)
return True
return False
def moveItemDown(self, item):
for index in range(len(self._items) - 1):
if self._items[index].get("playQueueItemID") == item.get("playQueueItemID"):
after = self._items[index + 1]
self.swapItem(index)
self.moveItem(item, after)
return True
return False
def moveItem(self, item, after):
if after:
query = "?after=" + after.get("playQueueItemID", "-1")
else:
query = ""
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + "/items/" + item.get("playQueueItemID", "-1") + "/move" + query, "PUT")
self.addRequestOptions(request)
context = request.createRequestContext("move", callback.Callable(self.onResponse))
plexapp.APP.startRequest(request, context)
def swapItem(self, index, delta=1):
before = self._items[index]
after = self._items[index + delta]
self._items[index] = after
self._items[index + delta] = before
def removeItem(self, item):
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + "/items/" + item.get("playQueueItemID", "-1"), "DELETE")
self.addRequestOptions(request)
context = request.createRequestContext("delete", callback.Callable(self.onResponse))
plexapp.APP.startRequest(request, context)
def addItem(self, item, addNext=False, excludeSeedItem=False):
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id), "PUT")
request.addParam("uri", item.getItemUri())
request.addParam("next", addNext and "1" or "0")
request.addParam("excludeSeedItem", excludeSeedItem and "1" or "0")
self.addRequestOptions(request)
context = request.createRequestContext("add", callback.Callable(self.onResponse))
plexapp.APP.startRequest(request, context)
def onResponse(self, request, response, context):
# Close any loading modal regardless of response status
# Application().closeLoadingModal()
util.DEBUG_LOG('playQueue: Received response')
self.responded = True
if response.parseResponse():
util.DEBUG_LOG('playQueue: {0} items'.format(len(response.items)))
self.container = response.container
# Handle an empty PQ if we have specified an pqEmptyCallable
if self.options and self.options.pqEmptyCallable:
callable = self.options.pqEmptyCallable
del self.options["pqEmptyCallable"]
if len(response.items) == 0:
callable.call()
return
self.id = response.container.playQueueID.asInt()
self.isShuffled = response.container.playQueueShuffled.asBool()
self.totalSize = response.container.playQueueTotalCount.asInt()
self.windowSize = len(response.items)
self.version = response.container.playQueueVersion.asInt()
itemsChanged = False
if len(response.items) == len(self._items):
for i in range(len(self._items)):
if self._items[i] != response.items[i]:
itemsChanged = True
break
else:
itemsChanged = True
if itemsChanged:
self._items = response.items
# Process any forced limitations
self.allowSeek = response.container.allowSeek.asBool()
self.allowShuffle = (
self.totalSize > 1 and response.container.allowShuffle.asBool() and not response.container.playQueueLastAddedItemID
)
self.allowRepeat = response.container.allowRepeat.asBool()
self.allowSkipPrev = self.totalSize > 1 and response.container.allowSkipPrevious != "0"
self.allowSkipNext = self.totalSize > 1 and response.container.allowSkipNext != "0"
# Figure out the selected track index and offset. PMS tries to make some
# of this easy, but it might not realize that we've advanced to a new
# track, so we can't blindly trust it. On the other hand, it's possible
# that PMS completely changed the PQ item IDs (e.g. upon shuffling), so
# we might need to use its values. We iterate through the items and try
# to find the item that we believe is selected, only settling for what
# PMS says if we fail.
playQueueOffset = None
selectedId = None
pmsSelectedId = response.container.playQueueSelectedItemID.asInt()
self.deriveIsMixed()
# lastItem = None # Not used
for index in range(len(self._items)):
item = self._items[index]
if not playQueueOffset and item.playQueueItemID.asInt() == pmsSelectedId:
playQueueOffset = response.container.playQueueSelectedItemOffset.asInt() - index + 1
# Update the index of everything we've already past, and handle
# wrapping indexes (repeat).
for i in range(index):
pqIndex = playQueueOffset + i
if pqIndex < 1:
pqIndex = pqIndex + self.totalSize
self._items[i].playQueueIndex = plexobjects.PlexValue(str(pqIndex), parent=self._items[i])
if playQueueOffset:
pqIndex = playQueueOffset + index
if pqIndex > self.totalSize:
pqIndex = pqIndex - self.totalSize
item.playQueueIndex = plexobjects.PlexValue(str(pqIndex), parent=item)
# If we found the item that we believe is selected: we should
# continue to treat it as selected.
# TODO(schuyler): Should we be checking the metadata ID (rating key)
# instead? I don't think it matters in practice, but it may be
# more correct.
if not selectedId and item.playQueueItemID.asInt() == self.selectedId:
selectedId = self.selectedId
if not selectedId:
self.selectedId = pmsSelectedId
# TODO(schuyler): Set repeat as soon as PMS starts returning it
# Fix up the container for all our items
response.container.address = "/playQueues/" + str(self.id)
# Create usage limitations
self.usage = UsageFactory.createUsage(self)
self.initialized = True
self.trigger("change")
if itemsChanged:
self.trigger("items.changed")
def isWindowed(self):
return (not self.isLocal() and (self.totalSize > self.windowSize or self.forcedWindow))
def hasNext(self):
if self.isRepeatOne:
return True
if not self.allowSkipNext and -1 < self.items().index(self.current()) < (len(self.items()) - 1): # TODO: Was 'or' - did change cause issues?
return self.isRepeat and not self.isWindowed()
return True
def hasPrev(self):
# return self.allowSkipPrev or self.items().index(self.current()) > 0
return self.items().index(self.current()) > 0
def next(self):
if not self.hasNext():
return None
if self.isRepeatOne:
return self.current()
pos = self.items().index(self.current()) + 1
if pos >= len(self.items()):
if not self.isRepeat or self.isWindowed():
return None
pos = 0
item = self.items()[pos]
self.selectedId = item.playQueueItemID.asInt()
return item
def prev(self):
if not self.hasPrev():
return None
if self.isRepeatOne:
return self.current()
pos = self.items().index(self.current()) - 1
item = self.items()[pos]
self.selectedId = item.playQueueItemID.asInt()
return item
def setCurrent(self, pos):
if pos < 0 or pos >= len(self.items()):
return False
item = self.items()[pos]
self.selectedId = item.playQueueItemID.asInt()
return item
def setCurrentItem(self, item):
self.selectedId = item.playQueueItemID.asInt()
def __eq__(self, other):
if not other:
return False
if self.__class__ != other.__class__:
return False
return self.id == other.id and self.type == other.type
def __ne__(self, other):
return not self.__eq__(other)
def addRequestOptions(self, request):
boolOpts = ["repeat", "includeRelated"]
for opt in boolOpts:
if self.options.get(opt):
request.addParam(opt, "1")
intOpts = ["extrasPrefixCount"]
for opt in intOpts:
if self.options.get(opt):
request.addParam(opt, str(self.options.get(opt)))
includeChapters = self.options.get('includeChapters') is not None and self.options.includeChapters or 1
request.addParam("includeChapters", str(includeChapters))
def __repr__(self):
return (
str(self.__class__.__name__) + " " +
str(self.type) + " windowSize=" +
str(self.windowSize) + " totalSize=" +
str(self.totalSize) + " selectedId=" +
str(self.selectedId) + " shuffled=" +
str(self.isShuffled) + " repeat=" +
str(self.isRepeat) + " mixed=" +
str(self.isMixed) + " allowShuffle=" +
str(self.allowShuffle) + " version=" +
str(self.version) + " id=" + str(self.id)
)
def isLocal(self):
return self.isLocalPlayQueue
def deriveIsMixed(self):
if self.isMixed is None:
self.isMixed = False
lastItem = None
for item in self._items:
if not self.isMixed:
if not item.get("parentKey"):
self.isMixed = True
else:
self.isMixed = lastItem and item.get("parentKey") != lastItem.get("parentKey")
lastItem = item
def items(self):
return self._items
def current(self):
for item in self.items():
if item.playQueueItemID.asInt() == self.selectedId:
return item
return None
def prevItem(self):
last = None
for item in self.items():
if item.playQueueItemID.asInt() == self.selectedId:
return last
last = item
return None
def createRemotePlayQueue(item, contentType, options, args):
util.DEBUG_LOG('Creating remote playQueue request...')
obj = PlayQueue(item.getServer(), contentType, options)
# The item's URI is made up of the library section UUID, a descriptor of
# the item type (item or directory), and the item's path, URL-encoded.
uri = "library://" + item.getLibrarySectionUuid() + "/"
itemType = item.isDirectory() and "directory" or "item"
path = None
if not options.key:
# if item.onDeck and len(item.onDeck) > 0:
# options.key = item.onDeck[0].getAbsolutePath("key")
# el
if not item.isDirectory():
options.key = item.get("key")
# If we're asked to play unwatched, ignore the option unless we are unwatched.
options.unwatched = options.unwatched and item.isUnwatched()
# TODO(schuyler): Until we build postplay, we're not allowed to queue containers for episodes.
if item.type == "episode":
options.context = options.CONTEXT_SELF
elif item.type == "movie":
if not options.extrasPrefixCount and not options.resume:
options.extrasPrefixCount = plexapp.INTERFACE.getPreference("cinema_trailers", 0)
# How exactly to construct the item URI depends on the metadata type, though
# whenever possible we simply use /library/metadata/:id.
if item.isLibraryItem() and not item.isLibraryPQ:
path = "/library/metadata/" + item.ratingKey
else:
path = item.getAbsolutePath("key")
if options.context == options.CONTEXT_SELF:
# If the context is specifically for just this item,: just use the
# item's key and get out.
pass
elif item.type == "playlist":
path = None
uri = item.get("ratingKey")
options.isPlaylist = True
elif item.type == "track":
# TODO(rob): Is there ever a time the container address is wrong? If we
# expect to play a single track,: use options.CONTEXT_SELF.
path = item.container.address or "/library/metadata/" + item.get("parentRatingKey", "")
itemType = "directory"
elif item.isPhotoOrDirectoryItem():
if item.type == "photoalbum" or item.parentKey:
path = item.getParentPath(item.type == "photoalbum" and "key" or "parentKey")
itemType = "item"
elif item.isDirectory():
path = item.getAbsolutePath("key")
else:
path = item.container.address
itemType = "directory"
options.key = item.getAbsolutePath("key")
elif item.type == "episode":
path = "/library/metadata/" + item.get("grandparentRatingKey", "")
itemType = "directory"
options.key = item.getAbsolutePath("key")
# elif item.type == "show":
# path = "/library/metadata/" + item.get("ratingKey", "")
if path:
if args:
path += util.joinArgs(args)
util.DEBUG_LOG("playQueue path: " + str(path))
if "/search" not in path:
# Convert a few params to the PQ spec
convert = {
'type': "sourceType",
'unwatchedLeaves': "unwatched"
}
for key in convert:
regex = re.compile("(?i)([?&])" + key + "=")
path = regex.sub("\1" + convert[key] + "=", path)
util.DEBUG_LOG("playQueue path: " + str(path))
uri = uri + itemType + "/" + urllib.quote_plus(path)
util.DEBUG_LOG("playQueue uri: " + str(uri))
# Create the PQ request
request = plexrequest.PlexRequest(obj.server, "/playQueues")
request.addParam(not options.isPlaylist and "uri" or "playlistID", uri)
request.addParam("type", contentType)
# request.addParam('X-Plex-Client-Identifier', plexapp.INTERFACE.getGlobal('clientIdentifier'))
# Add options we pass once during PQ creation
if options.shuffle:
request.addParam("shuffle", "1")
options.key = None
else:
request.addParam("shuffle", "0")
if options.key:
request.addParam("key", options.key)
# Add options we pass every time querying PQs
obj.addRequestOptions(request)
util.DEBUG_LOG('Initial playQueue request started...')
context = request.createRequestContext("create", callback.Callable(obj.onResponse))
plexapp.APP.startRequest(request, context, body='')
return obj
def createPlayQueueForId(id, server=None, contentType=None):
obj = PlayQueue(server, contentType)
obj.id = id
request = plexrequest.PlexRequest(server, "/playQueues/" + str(id))
request.addParam("own", "1")
obj.addRequestOptions(request)
context = request.createRequestContext("own", callback.Callable(obj.onResponse))
plexapp.APP.startRequest(request, context)
return obj
class AudioPlayer():
pass
class VideoPlayer():
pass
class PhotoPlayer():
pass
def addItemToPlayQueue(item, addNext=False):
# See if we have an active play queue for this self.dia type or if we need to
# create one.
if item.isMusicOrDirectoryItem():
player = AudioPlayer()
elif item.isVideoOrDirectoryItem():
player = VideoPlayer()
elif item.isPhotoOrDirectoryItem():
player = PhotoPlayer()
else:
player = None
if not player:
util.ERROR_LOG("Don't know how to add item to play queue: " + str(item))
return None
elif not player.allowAddToQueue():
util.DEBUG_LOG("Not allowed to add to this player")
return None
if player.playQueue:
playQueue = player.playQueue
playQueue.addItem(item, addNext)
else:
options = PlayOptions()
options.context = options.CONTEXT_SELF
playQueue = createPlayQueueForItem(item, None, options)
if playQueue:
player.setPlayQueue(playQueue, False)
return playQueue