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