diff --git a/emby/api.py b/emby/api.py index 9426edb8b..f653a1b1d 100644 --- a/emby/api.py +++ b/emby/api.py @@ -642,12 +642,12 @@ def get_Image_Binary(self, Id, ImageType, ImageIndex, ImageTag, UserImage): if UserImage: Params["Format"] = "original" - _, Header, Payload = self.EmbyServer.http.request("GET", f"Users/{Id}/Images/{ImageType}", Params, {}, True, "", None, "") + _, Header, Payload = self.EmbyServer.http.request("GET", f"Users/{Id}/Images/{ImageType}", Params, {}, True, "", None, "", False) else: if ImageTag: Params["tag"] = ImageTag - _, Header, Payload = self.EmbyServer.http.request("GET", f"Items/{Id}/Images/{ImageType}/{ImageIndex}", Params, {}, True, "", None, "") + _, Header, Payload = self.EmbyServer.http.request("GET", f"Items/{Id}/Images/{ImageType}/{ImageIndex}", Params, {}, True, "", None, "", False) if 'content-type' in Header: ContentType = Header['content-type'] @@ -884,6 +884,15 @@ def get_upcoming(self, ParentId): return [] + def get_Episodes(self, SeriesId, AdjacentTo): + Params = {'UserId': self.EmbyServer.ServerData['UserId'], 'AdjacentTo': AdjacentTo, 'Fields': self.get_Fields("episode", False, False, True), 'EnableImages': True, 'EnableUserData': True} + _, _, Payload = self.EmbyServer.http.request("GET", f"Shows/{SeriesId}/Episodes", Params, {}, False, "", None, "") + + if 'Items' in Payload: + return Payload['Items'] + + return [] + def get_NextUp(self, ParentId): _, _, Payload = self.EmbyServer.http.request("GET", "Shows/NextUp", {'UserId': self.EmbyServer.ServerData['UserId'], 'ParentId': ParentId, 'Fields': self.get_Fields("episode", False, True, True), 'EnableImages': True, 'EnableUserData': True, 'LegacyNextUp': True}, {}, False, "", None, "") embydb = dbio.DBOpenRO(self.EmbyServer.ServerData['ServerId'], "get_NextUp") diff --git a/emby/http.py b/emby/http.py index 3fb540514..c8e0c2347 100644 --- a/emby/http.py +++ b/emby/http.py @@ -363,7 +363,7 @@ def socket_open(self, ConnectionString, ConnectionId, CloseConnection): if utils.DebugLog: xbmc.log(f"EMBY.emby.http (DEBUG): Socket {ConnectionId} opened", 1) # LOGDEBUG return 0 - def socket_close(self, ConnectionId): + def socket_close(self, ConnectionId, SkipPing=False): if ConnectionId in self.Connection: # Close sessions if ConnectionId == "WEBSOCKET": # close websocket @@ -372,7 +372,7 @@ def socket_close(self, ConnectionId): self.websocket_send(b"", 0x8) # Close except Exception as error: xbmc.log(f"EMBY.emby.http: Socket {ConnectionId} send close error 1: {error}", 2) # LOGWARNING - elif ConnectionId in ("MAIN", "MAINFALLBACK", "ASYNC"): # send final ping to change tcp session from keep-alive to close + elif ConnectionId in ("MAIN", "MAINFALLBACK", "ASYNC") and not SkipPing: # send final ping to change tcp session from keep-alive to close try: self.Connection[ConnectionId]["Socket"].settimeout(1) # set timeout self.Connection[ConnectionId]["Socket"].send(f'POST {self.Connection[ConnectionId]["SubUrl"]}System/Ping HTTP/1.1\r\nHost: {self.Connection[ConnectionId]["Hostname"]}:{self.Connection[ConnectionId]["Port"]}\r\nContent-Type: application/json; charset=utf-8\r\nAccept-Charset: utf-8\r\nAccept-Encoding: gzip,deflate\r\nUser-Agent: {utils.addon_name}/{utils.addon_version}\r\nConnection: close\r\nAuthorization: Emby Client="{utils.addon_name}", Device="{utils.device_name}", DeviceId="{self.EmbyServer.ServerData["DeviceId"]}", Version="{utils.addon_version}"\r\nContent-Length: 0\r\n\r\n'.encode("utf-8")) @@ -753,7 +753,7 @@ def download_file(self): break - def request(self, Method, Handler, Params, RequestHeader, Binary, ConnectionString, BusyFunction, ConnectionId): + def request(self, Method, Handler, Params, RequestHeader, Binary, ConnectionString, BusyFunction, ConnectionId, FollowRedirects=True): CloseConnection = False # Set Ids @@ -781,7 +781,7 @@ def request(self, Method, Handler, Params, RequestHeader, Binary, ConnectionStri # Simple request if CloseConnection or not BusyFunction or not self.ThreadsRunning["QUEUEDREQUESTMAIN"] or not self.ThreadsRunning["QUEUEDREQUESTMAINFALLBACK"]: - self.send_request(Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, ConnectionId, RequestId) + self.send_request(Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, ConnectionId, RequestId, FollowRedirects) Data = self.Response[RequestId] del self.Response[RequestId] @@ -796,7 +796,7 @@ def request(self, Method, Handler, Params, RequestHeader, Binary, ConnectionStri self.RequestBusy[ConnectionId] = threading.Lock() self.RequestBusy[RequestId] = threading.Lock() - self.Queues[f"QUEUEDREQUEST{ConnectionId}"].put(((Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, RequestId),)) + self.Queues[f"QUEUEDREQUEST{ConnectionId}"].put(((Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, RequestId, FollowRedirects),)) # Check conditions while waiting for data -> BusyFunction while True: @@ -841,11 +841,11 @@ def queued_request(self, ConnectionId): return - Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, RequestId = Incoming + Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, RequestId, FollowRedirects = Incoming if utils.DebugLog: xbmc.log(f"EMBY.emby.http (DEBUG): [ http ] Method: {Method} / Handler: {Handler} / Params: {Params} / Binary: {Binary} / ConnectionString: {ConnectionString} / CloseConnection: {CloseConnection} / RequestHeader: {RequestHeader}", 1) # LOGDEBUG - self.send_request(Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, ConnectionId, RequestId) + self.send_request(Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, ConnectionId, RequestId, FollowRedirects) - def send_request(self, Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, ConnectionId, RequestId): + def send_request(self, Method, Handler, Params, RequestHeader, Binary, ConnectionString, CloseConnection, ConnectionId, RequestId, FollowRedirects=True): self.Requests_Counter(True) if not ConnectionString: @@ -891,6 +891,11 @@ def send_request(self, Method, Handler, Params, RequestHeader, Binary, Connectio # Redirects if StatusCode in (301, 302, 307, 308): + if not FollowRedirects: + self.socket_close(ConnectionId, True) + self.Response[RequestId] = noData(StatusCode, {}, Binary) + break + self.socket_close(ConnectionId) Location = Header.get("location", "") Scheme, Hostname, Port, _ = utils.get_url_info(Location) diff --git a/emby/metadata.py b/emby/metadata.py index 7ceecbb3f..9c94c4241 100644 --- a/emby/metadata.py +++ b/emby/metadata.py @@ -1,8 +1,10 @@ from urllib.parse import unquote +import re import xbmc MediaIdMapping = {"m": "movie", "e": "episode", "M": "musicvideo", "p": "picture", "a": "audio", "t": "tvchannel", "i": "movie", "T": "video", "v": "video", "c": "channel"} # T=trailer, i=iso EmbyArtworkIDs = {"p": "Primary", "a": "Art", "b": "Banner", "d": "Disc", "l": "Logo", "t": "Thumb", "B": "Backdrop", "c": "Chapter"} +IdentifierPattern = re.compile(r"^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$") MediaSourceContextMenu = -1 def load_MetaData(Payload, isPicture, isAudio): @@ -22,19 +24,32 @@ def load_MetaData(Payload, isPicture, isAudio): if isPicture: # Image/picture MetaData["PlayerId"] = -1 - Data = PayloadMod[PayloadMod.rfind("/") + 1:].split("-") # MetaData - ServerId = PayloadSplit[2] - EmbyId = Data[1] - DataLen = len(Data) - - if DataLen < 5: + Data = PayloadSplit[-1].split("-") # MetaData + + if ( + len(PayloadSplit) != 4 + or PayloadSplit[:2] != ["", "picture"] + or len(Data) < 5 + or any(Character.isspace() or ord(Character) < 32 or ord(Character) == 127 for Character in PayloadMod) + or "?" in PayloadMod + or "#" in PayloadMod + or Data[0] != "p" + or not IdentifierPattern.fullmatch(PayloadSplit[2]) + or not IdentifierPattern.fullmatch(Data[1]) + or not Data[2].isascii() + or not Data[2].isdigit() + or Data[3] not in EmbyArtworkIDs + or not IdentifierPattern.fullmatch(Data[4]) + ): xbmc.log(f"EMBY.hooks.webservice: Invalid picture {PayloadMod}", 2) # LOGERROR return {} + ServerId = PayloadSplit[2] + EmbyId = Data[1] MetaData.update({'ImageIndex': Data[2], 'ImageType': EmbyArtworkIDs[Data[3]], 'ImageTag': Data[4]}) - if DataLen >= 6 and Data[5]: - MetaData['Overlay'] = unquote(Data[5]) + if len(Data) >= 6 and Data[5]: + MetaData['Overlay'] = unquote("-".join(Data[5:])) else: MetaData['Overlay'] = "" elif isAudio: diff --git a/helper/player.py b/helper/player.py index 350353aad..b10dffca4 100644 --- a/helper/player.py +++ b/helper/player.py @@ -6,7 +6,7 @@ import xbmc from database import dbio from emby import listitem -from helper import utils, playerops, queue, cache +from helper import utils, playerops, queue, cache, upnext from dialogs import skipintrocredits TrackerPaused = False VideoPlayback = "READY" @@ -320,6 +320,7 @@ def PlayerCommands(): PlayingItem = QueuedPlayingItem.copy() QueuedPlayingItem = [] init_EmbyPlayback() + upnext.dispatch(PlayingItem) if VideoPlayback == "CONTENT": VideoPlayback = "READY" @@ -407,22 +408,29 @@ def PlayerCommands(): if utils.DebugLog: xbmc.log("EMBY.hooks.player (DEBUG): --<[ paused ]", 1) # LOGDEBUG elif Commands[0] == "stop": # {'end': True, 'item': {'id': 33874, 'type': 'episode'}}; '{"end":false,"item":{"id":107446349,"type":"song"}}' xbmc.log("EMBY.hooks.player: [ onPlayBackStopped ]", 1) # LOGINFO - PlayItem = (0, "") EventData = json.loads(Commands[1]) KodiId = 0 KodiTypeId = 0 + + if "item" in EventData: + if 'id' in EventData['item']: + KodiId = EventData["item"]["id"] + KodiTypeId = EventData["item"]["type"] + + if PlayItem[0] and KodiId and PlayItem != (KodiId, KodiTypeId): + xbmc.log(f"EMBY.hooks.player: Ignore stale stop for {KodiTypeId}/{KodiId}", 1) # LOGINFO + continue + + PlayItem = (0, "") utils.update_SyncPause('playing', False) utils.unset_SyncLock() ProgressBarEnable = 5 if "item" in EventData: # remove from skipped items list if 'id' in EventData['item']: - KodiId = EventData["item"]["id"] - KodiTypeId = EventData["item"]["type"] - - if KodiId: - if not EventData['end']: # remove from skipped items list - ItemsUpdateQueue.put(f'{{"DELETE": [{KodiId}, "{KodiTypeId}"]}}') # Do not delete the item diectly from utils.ItemKodiSkipUpdate, to keep the events in order + if KodiId: + if not EventData['end']: # remove from skipped items list + ItemsUpdateQueue.put(f'{{"DELETE": [{KodiId}, "{KodiTypeId}"]}}') # Do not delete the item diectly from utils.ItemKodiSkipUpdate, to keep the events in order # Dummy (blankwav) played if ForceStopKodiId == EventData["item"]["id"]: diff --git a/helper/upnext.py b/helper/upnext.py new file mode 100644 index 000000000..80000d972 --- /dev/null +++ b/helper/upnext.py @@ -0,0 +1,251 @@ +import base64 +import json +import math +import re +from urllib.parse import urlencode + +import xbmc + +from helper import utils + + +ADDON_ENABLED = "System.AddonIsEnabled(service.upnext)" +ART_TEMPLATE = "http://127.0.0.1:57342/picture/{}/p-{}-0-{}-{}" +IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$") + + +def dispatch(playing_item): + if len(playing_item) < 7 or str(playing_item[6]).lower() != "episode": + return + + if not xbmc.getCondVisibility(ADDON_ENABLED): + return + + session_data = playing_item[0] + server = playing_item[4] + + if not session_data or not server or not session_data.get("ItemId"): + return + + utils.start_thread( + send_upnext, + ( + server, + session_data["ItemId"], + session_data.get("RunTimeTicks"), + playing_item[3], + ), + ) + + +def send_upnext(server, item_id, runtime_ticks, credits_ticks): + try: + return _send_upnext(server, item_id, runtime_ticks, credits_ticks) + except Exception: + xbmc.log("EMBY.helper.upnext: Up Next integration failed", 2) + return False + + +def _send_upnext(server, item_id, runtime_ticks, credits_ticks): + current = server.API.get_Item(item_id, ("Episode",), True, False, True) + + if current.get("Type") != "Episode" or not current.get("SeriesId"): + return False + + episodes = server.API.get_Episodes(current["SeriesId"], current["Id"]) + following = _following_episode(episodes, current.get("Id")) + + if not following: + return False + + server_id = server.ServerData["ServerId"] + following_id = following.get("Id", "") + + if not _valid_identifier(server_id) or not _valid_identifier(following_id): + return False + + payload = { + "current_episode": _episode_info(current, server_id), + "next_episode": _episode_info(following, server_id), + "play_url": ( + "plugin://plugin.service.emby-next-gen/" + f"?{urlencode((('mode', 'play'), ('server', server_id), ('item', following_id)))}" + ), + } + notification_time = _notification_time(runtime_ticks, credits_ticks) + + if notification_time is not None: + payload["notification_time"] = notification_time + + encoded_payload = base64.b64encode( + json.dumps(payload).encode("utf-8") + ).decode("ascii") + xbmc.executeJSONRPC( + json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "JSONRPC.NotifyAll", + "params": { + "sender": "plugin.service.emby-next-gen.SIGNAL", + "message": "upnext_data", + "data": [encoded_payload], + }, + } + ) + ) + return True + + +def _following_episode(episodes, current_id): + current_found = False + + for item in episodes: + if current_found: + return item + + if str(item.get("Id")) == str(current_id): + current_found = True + + return None + + +def _episode_info(item, server_id): + user_data = item.get("UserData") + + if not isinstance(user_data, dict): + user_data = {} + + return { + "episodeid": item.get("Id", ""), + "tvshowid": item.get("SeriesId", ""), + "title": _text(item.get("Name")), + "art": _art(item, server_id), + "season": _integer(item.get("ParentIndexNumber")), + "episode": _integer(item.get("IndexNumber")), + "showtitle": _text(item.get("SeriesName")), + "plot": _text(item.get("Overview")), + "playcount": _integer(user_data.get("PlayCount")), + "rating": _number(item.get("CommunityRating")), + "firstaired": _text(item.get("PremiereDate")), + "runtime": _runtime(item.get("RunTimeTicks")), + } + + +def _art(item, server_id): + art = { + "thumb": "", + "tvshow.clearart": "", + "tvshow.clearlogo": "", + "tvshow.fanart": "", + "tvshow.landscape": "", + "tvshow.poster": "", + } + image_tags = item.get("ImageTags") + + if not isinstance(image_tags, dict): + image_tags = {} + + if _valid_tag(image_tags.get("Primary")): + art["thumb"] = _picture(server_id, item.get("Id"), "p", image_tags["Primary"]) + elif _valid_tag(image_tags.get("Thumb")): + art["thumb"] = _picture(server_id, item.get("Id"), "t", image_tags["Thumb"]) + + inherited_art = ( + ("tvshow.clearart", "ParentArtItemId", "ParentArtImageTag", "a"), + ("tvshow.clearlogo", "ParentLogoItemId", "ParentLogoImageTag", "l"), + ("tvshow.landscape", "ParentThumbItemId", "ParentThumbImageTag", "t"), + ("tvshow.poster", "SeriesId", "SeriesPrimaryImageTag", "p"), + ) + + for art_key, item_key, tag_key, art_kind in inherited_art: + tag = item.get(tag_key) + + if _valid_tag(tag): + art[art_key] = _picture(server_id, item.get(item_key), art_kind, tag) + + backdrop_tags = item.get("ParentBackdropImageTags") + + if isinstance(backdrop_tags, (list, tuple)) and backdrop_tags: + if _valid_tag(backdrop_tags[0]): + art["tvshow.fanart"] = _picture( + server_id, + item.get("ParentBackdropItemId"), + "B", + backdrop_tags[0], + ) + + return art + + +def _picture(server_id, item_id, art_kind, image_tag): + if ( + not _valid_identifier(server_id) + or not _valid_identifier(item_id) + or art_kind not in ("p", "a", "l", "t", "B") + or not _valid_identifier(image_tag) + ): + return "" + + return ART_TEMPLATE.format(server_id, item_id, art_kind, image_tag) + + +def _valid_identifier(value): + return isinstance(value, (str, int)) and bool(IDENTIFIER_PATTERN.fullmatch(str(value))) + + +def _valid_tag(value): + return bool(value and value != "None") + + +def _text(value): + return value if isinstance(value, str) else "" + + +def _number(value): + if isinstance(value, bool): + return 0 + + try: + value = float(value) + except (TypeError, ValueError): + return 0 + + if not math.isfinite(value): + return 0 + + if value.is_integer(): + return int(value) + + return value + + +def _integer(value): + return int(_number(value)) + + +def _runtime(runtime_ticks): + runtime_ticks = _number(runtime_ticks) + + if runtime_ticks <= 0: + return 0 + + return int(runtime_ticks / 10_000_000) + + +def _notification_time(runtime_ticks, credits_ticks): + runtime_ticks = _number(runtime_ticks) + credits_ticks = _number(credits_ticks) + + if runtime_ticks <= 0 or credits_ticks <= 0 or credits_ticks >= runtime_ticks: + return None + + seconds = (runtime_ticks - credits_ticks) / 10_000_000 + + if seconds <= 0: + return None + + if seconds.is_integer(): + return int(seconds) + + return seconds diff --git a/hooks/webservice.py b/hooks/webservice.py index 146bf8503..51dd2714e 100644 --- a/hooks/webservice.py +++ b/hooks/webservice.py @@ -1,7 +1,8 @@ import threading -from urllib.parse import parse_qsl +from urllib.parse import parse_qsl, unquote_plus import uuid import socket +import re import xbmc from hooks import favorites from database import dbio @@ -26,6 +27,7 @@ WorkerQueue = queue.Queue() AsyncCommandQueue = queue.Queue() DelayedContentCondition = threading.Condition(threading.Lock()) +IdentifierPattern = re.compile(r"^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$") xbmc.log(f"EMBY.hooks.webservice: Number of workers {MaxWorkers}", 1) # LOGINFO # Load binary files once @@ -180,7 +182,7 @@ def worker_Query(WorkerNumber): # thread by caller client.settimeout(None) data = client.recv(16384).decode() if utils.DebugLog: xbmc.log(f"EMBY.hooks.webservice: [ worker_Query/{WorkerNumber} ] Incoming Data: {data}", 1) # LOGDEBUG - IncomingData = data.split(' ') + IncomingData = data.split(' ', 1) if IncomingData[0] in ("PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "DELETE", "LOCK", "UNLOCK"): # webdav methodS, currently not supported client.send(sendNotFound) @@ -189,7 +191,7 @@ def worker_Query(WorkerNumber): # thread by caller # events by event.py if IncomingData[0] == "EVENT": - args = IncomingData[1].split(";") + args = IncomingData[1].split(";", 2) if utils.DebugLog: xbmc.log(f"EMBY.hooks.webservice (DEBUG): [ worker_Query/{WorkerNumber} ] {IncomingData[1]}", 1) # LOGDEBUG @@ -363,7 +365,18 @@ def worker_Query(WorkerNumber): # thread by caller params = params[:-1] Handle = args[1] - params = dict(parse_qsl(params[1:])) + Query = params[1:] + + if is_play_query(Query): + params = get_play_params(Query) + + if not params: + client.send(sendNotFound) + client.close() + continue + else: + params = dict(parse_qsl(Query, keep_blank_values=True)) + mode = params.get('mode', "") ServerId = params.get('server', "") @@ -446,6 +459,7 @@ def worker_Query(WorkerNumber): # thread by caller if mode == 'play': client.send(sendOK) client.close() + player.PlaylistRemoveItem = playerops.GetPlaylistPosition(1) playerops.PlayEmby((params.get('item'),), "PlayNow", -1, -1, utils.EmbyServers[ServerId], 0) if utils.DebugLog: xbmc.log(f"EMBY.hooks.webservice (DEBUG): THREAD: [ worker_Query/{WorkerNumber} ] event play", 1) # LOGDEBUG continue @@ -486,6 +500,8 @@ def worker_Query(WorkerNumber): # thread by caller client.close() continue + IncomingData[1] = IncomingData[1].split(' ', 1)[0] + # Detect content type isPicture = False isAudio = False @@ -527,6 +543,41 @@ def worker_Query(WorkerNumber): # thread by caller if utils.DebugLog: xbmc.log(f"EMBY.hooks.webservice (DEBUG): THREAD: ---<[ worker_Query/{WorkerNumber} ] not running", 1) # LOGDEBUG +def is_play_query(Query): + for Param in Query.split("&"): + Key, Separator, Value = Param.partition("=") + + if Separator and unquote_plus(Key) == "mode" and unquote_plus(Value) == "play": + return True + + return False + +def get_play_params(Query): + if any(Character == ";" or Character.isspace() or ord(Character) < 32 or ord(Character) == 127 for Character in Query): + return None + + ParamPairs = parse_qsl(Query, keep_blank_values=True) + + if len(ParamPairs) != 3: + return None + + Params = {} + + for Key, Value in ParamPairs: + if Key not in ("mode", "server", "item") or Key in Params: + return None + + Params[Key] = Value + + if ( + Params.get("mode") != "play" + or not IdentifierPattern.fullmatch(Params.get("server", "")) + or not IdentifierPattern.fullmatch(Params.get("item", "")) + ): + return None + + return Params + def LoadISO(MetaData, client): # native content player.MultiselectionDone = True Path = MetaData['MediaSources'][MetaData['SelectionIndexMediaSource']][0]['Path'] @@ -626,12 +677,17 @@ def GetRequest(client, Payload, isDelayedContent, isPicture, isAudio, isVideo): # Load parameters from url request MetaData = metadata.load_MetaData(Payload, isPicture, isAudio) - MetaData['ETag'] = f'{str(uuid.uuid4()).replace("-", "")}{Payload[-5:]}' if not MetaData: # Invalid request client.send(sendNotFound) return + MetaData['ETag'] = f'{str(uuid.uuid4()).replace("-", "")}{Payload[-5:]}' + + if isPicture and MetaData['ServerId'] not in utils.EmbyServers: + client.send(sendNotFound) + return + if MetaData['Type'] in ("movie", "episode", "musicvideo", "tvchannel", "video"): playerops.PlayerId = 1 elif MetaData['Type'] == "audio": @@ -652,15 +708,18 @@ def GetRequest(client, Payload, isDelayedContent, isPicture, isAudio, isVideo): if utils.DebugLog: xbmc.log(f"EMBY.hooks.webservice (DEBUG): Load artwork: {Payload}", 1) # LOGDEBUG if not MetaData['Overlay']: - if utils.enableCoverArt: - Enhancers = "&EnableImageEnhancers=True" - else: - Enhancers = "&EnableImageEnhancers=False" + try: + BinaryData, ContentType, _ = utils.EmbyServers[MetaData['ServerId']].API.get_Image_Binary(MetaData['EmbyId'], MetaData['ImageType'], MetaData['ImageIndex'], MetaData['ImageTag'], False) + except Exception: + client.send(sendNotFound) + return - client.send(f"HTTP/1.1 307 Temporary Redirect\r\nServer: Emby-Next-Gen\r\nConnection: close\r\nLocation: {utils.EmbyServers[MetaData['ServerId']].ServerData['ServerUrl']}/emby/Items/{MetaData['EmbyId']}/Images/{MetaData['ImageType']}/{MetaData['ImageIndex']}?&api_key={utils.EmbyServers[MetaData['ServerId']].ServerData['AccessToken']}{Enhancers}\r\nAccept-Ranges: none\r\nContent-Length: 0\r\n\r\n".encode()) - return + if not BinaryData: + client.send(sendNotFound) + return + else: + BinaryData, ContentType, _ = utils.image_overlay(MetaData['ImageTag'], MetaData['ServerId'], MetaData['EmbyId'], MetaData['ImageType'], MetaData['ImageIndex'], MetaData['Overlay']) - BinaryData, ContentType, _ = utils.image_overlay(MetaData['ImageTag'], MetaData['ServerId'], MetaData['EmbyId'], MetaData['ImageType'], MetaData['ImageIndex'], MetaData['Overlay']) client.send(f"HTTP/1.1 200 OK\r\nServer: Emby-Next-Gen\r\nConnection: close\r\nContent-Length: {len(BinaryData)}\r\nContent-Type: {ContentType}\r\nAccept-Ranges: none\r\n\r\n".encode() + BinaryData) if utils.DebugLog: xbmc.log(f"EMBY.hooks.webservice (DEBUG): Loaded Delayed Content for {Payload}", 1) # LOGDEBUG return diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_upnext.py b/tests/test_upnext.py new file mode 100644 index 000000000..cd998e2e4 --- /dev/null +++ b/tests/test_upnext.py @@ -0,0 +1,469 @@ +import base64 +import importlib +import json +import sys +import types +import unittest +from unittest import mock + + +xbmc = types.ModuleType("xbmc") +xbmc.executeJSONRPC = mock.Mock(return_value='{"jsonrpc":"2.0","id":1,"result":"OK"}') +xbmc.getCondVisibility = mock.Mock(return_value=False) +xbmc.log = mock.Mock() +sys.modules["xbmc"] = xbmc + +utils = types.ModuleType("helper.utils") +utils.start_thread = mock.Mock() +sys.modules["helper.utils"] = utils + +upnext = importlib.import_module("helper.upnext") + + +def episode(item_id, season, number, **extra): + item = { + "Id": str(item_id), + "Type": "Episode", + "SeriesId": "series-1", + "SeriesName": "Example Show", + "Name": f"Episode {number}", + "ParentIndexNumber": season, + "IndexNumber": number, + "RunTimeTicks": 1_800 * 10_000_000, + } + item.update(extra) + return item + + +class FakeAPI: + def __init__(self, current, episodes): + self.current = current + self.episodes = episodes + self.get_item_calls = [] + self.get_items_calls = [] + self.get_episodes_calls = [] + + def get_Item(self, *args): + self.get_item_calls.append(args) + return self.current + + def get_Items(self, *args): + self.get_items_calls.append(args) + return iter(self.episodes) + + def get_Episodes(self, *args): + self.get_episodes_calls.append(args) + return self.episodes + + +class FakeServer: + def __init__(self, current, episodes): + self.API = FakeAPI(current, episodes) + self.ServerData = { + "ServerId": "server-1", + "ServerUrl": "https://emby.example", + "AccessToken": "secret-token", + "DeviceId": "secret-device", + } + + +class EpisodeEndpointTests(unittest.TestCase): + def test_requests_server_episode_order_adjacent_to_current_episode(self): + dbio = types.ModuleType("database.dbio") + listitem = types.ModuleType("emby.listitem") + httpcache = types.ModuleType("emby.httpcache") + + with mock.patch.dict( + sys.modules, + { + "database.dbio": dbio, + "emby.listitem": listitem, + "emby.httpcache": httpcache, + }, + ): + api_module = importlib.import_module("emby.api") + + server = mock.Mock() + server.ServerData = {"UserId": "user-1"} + server.http.request.return_value = ( + 200, + {}, + {"Items": [episode(10, 1, 1), episode(15, 0, 0)]}, + ) + api = object.__new__(api_module.API) + api.EmbyServer = server + api.DynamicListsRemoveFields = () + + result = api.get_Episodes("series-1", "10") + + self.assertEqual([item["Id"] for item in result], ["10", "15"]) + request = server.http.request.call_args.args + self.assertEqual(request[0:2], ("GET", "Shows/series-1/Episodes")) + self.assertEqual(request[2]["AdjacentTo"], "10") + self.assertEqual(request[2]["UserId"], "user-1") + self.assertTrue(request[2]["EnableImages"]) + self.assertTrue(request[2]["EnableUserData"]) + self.assertNotIn("SortBy", request[2]) + self.assertNotIn("SortOrder", request[2]) + + +class UpNextSignalTests(unittest.TestCase): + def setUp(self): + xbmc.executeJSONRPC.reset_mock() + xbmc.getCondVisibility.reset_mock() + xbmc.getCondVisibility.return_value = True + xbmc.log.reset_mock() + utils.start_thread.reset_mock() + + def decoded_signal(self): + xbmc.executeJSONRPC.assert_called_once() + envelope = json.loads(xbmc.executeJSONRPC.call_args.args[0]) + self.assertEqual(len(envelope["params"]["data"]), 1) + encoded_payload = envelope["params"]["data"][0] + payload = json.loads(base64.b64decode(encoded_payload).decode("utf-8")) + return envelope, payload + + def test_sends_exact_signal_envelope_metadata_art_and_safe_play_url(self): + current = episode( + 10, + 1, + 1, + Name="Pilot", + Overview="Current plot", + CommunityRating="8.5", + PremiereDate="2025-01-01", + UserData={"PlayCount": 2}, + ImageTags={"Primary": "episode-current"}, + SeriesPrimaryImageTag="series-poster", + ParentLogoItemId="series-1", + ParentLogoImageTag="series-logo", + ParentBackdropItemId="series-1", + ParentBackdropImageTags=["series-fanart"], + ParentThumbItemId="series-1", + ParentThumbImageTag="series-landscape", + ) + following = episode(11, 1, 2, Name="Second") + server = FakeServer(current, [current, following]) + + self.assertTrue(upnext.send_upnext(server, "10", 1_800 * 10_000_000, 0)) + + envelope, payload = self.decoded_signal() + self.assertEqual( + envelope, + { + "jsonrpc": "2.0", + "id": 1, + "method": "JSONRPC.NotifyAll", + "params": { + "sender": "plugin.service.emby-next-gen.SIGNAL", + "message": "upnext_data", + "data": envelope["params"]["data"], + }, + }, + ) + self.assertEqual( + payload["current_episode"], + { + "episodeid": "10", + "tvshowid": "series-1", + "title": "Pilot", + "art": { + "thumb": ( + "http://127.0.0.1:57342/picture/server-1/" + "p-10-0-p-episode-current" + ), + "tvshow.clearart": "", + "tvshow.clearlogo": ( + "http://127.0.0.1:57342/picture/server-1/" + "p-series-1-0-l-series-logo" + ), + "tvshow.fanart": ( + "http://127.0.0.1:57342/picture/server-1/" + "p-series-1-0-B-series-fanart" + ), + "tvshow.landscape": ( + "http://127.0.0.1:57342/picture/server-1/" + "p-series-1-0-t-series-landscape" + ), + "tvshow.poster": ( + "http://127.0.0.1:57342/picture/server-1/" + "p-series-1-0-p-series-poster" + ), + }, + "season": 1, + "episode": 1, + "showtitle": "Example Show", + "plot": "Current plot", + "playcount": 2, + "rating": 8.5, + "firstaired": "2025-01-01", + "runtime": 1800, + }, + ) + self.assertEqual( + payload["play_url"], + "plugin://plugin.service.emby-next-gen/" + "?mode=play&server=server-1&item=11", + ) + serialized = json.dumps(payload) + self.assertNotIn("secret-token", serialized) + self.assertNotIn("secret-device", serialized) + self.assertNotIn("emby.example", serialized) + + def test_rejects_unsafe_server_and_item_ids_before_emitting_urls(self): + unsafe_ids = ( + "11&mode=nodesreset", + "11=nodesreset", + "11%26mode%3Dnodesreset", + "11?mode=nodesreset", + "11/mode", + "11\\mode", + "11\r\nmode", + ) + + for unsafe_id in unsafe_ids: + with self.subTest(identifier=unsafe_id, location="item"): + current = episode(10, 1, 1) + following = episode(unsafe_id, 1, 2) + server = FakeServer(current, [current, following]) + xbmc.executeJSONRPC.reset_mock() + + self.assertFalse(upnext.send_upnext(server, "10", 0, 0)) + xbmc.executeJSONRPC.assert_not_called() + self.assertEqual(upnext._picture("server-1", unsafe_id, "p", "tag"), "") + + with self.subTest(identifier=unsafe_id, location="server"): + current = episode(10, 1, 1) + following = episode(11, 1, 2) + server = FakeServer(current, [current, following]) + server.ServerData["ServerId"] = unsafe_id + xbmc.executeJSONRPC.reset_mock() + + self.assertFalse(upnext.send_upnext(server, "10", 0, 0)) + xbmc.executeJSONRPC.assert_not_called() + self.assertEqual(upnext._picture(unsafe_id, "11", "p", "tag"), "") + + def test_supports_numeric_guid_and_established_server_ids(self): + valid_pairs = ( + ("2a38697ffc1b428b943aa1b6014e2263", "58574"), + ( + "2a38697f-fc1b-428b-943a-a1b6014e2263", + "58575", + ), + ) + + for server_id, item_id in valid_pairs: + with self.subTest(server_id=server_id, item_id=item_id): + current = episode(10, 1, 1) + following = episode(item_id, 1, 2) + server = FakeServer(current, [current, following]) + server.ServerData["ServerId"] = server_id + xbmc.executeJSONRPC.reset_mock() + + self.assertTrue(upnext.send_upnext(server, "10", 0, 0)) + + _, payload = self.decoded_signal() + self.assertEqual( + payload["play_url"], + "plugin://plugin.service.emby-next-gen/" + f"?mode=play&server={server_id}&item={item_id}", + ) + + def test_selects_first_episode_of_next_season_in_server_order(self): + current = episode(20, 1, 10) + following = episode(21, 2, 1) + server = FakeServer(current, [episode(19, 1, 9), current, following]) + + self.assertTrue(upnext.send_upnext(server, "20", 0, 0)) + + _, payload = self.decoded_signal() + self.assertEqual(payload["next_episode"]["episodeid"], "21") + self.assertEqual(server.API.get_episodes_calls, [("series-1", "20")]) + self.assertEqual(server.API.get_items_calls, []) + + def test_selects_special_from_server_defined_aired_order(self): + current = episode(10, 1, 1) + special = episode( + 15, + 0, + 0, + AirsBeforeSeasonNumber=1, + AirsBeforeEpisodeNumber=2, + ) + server = FakeServer(current, [current, special, episode(11, 1, 2)]) + + self.assertTrue(upnext.send_upnext(server, "10", 0, 0)) + + _, payload = self.decoded_signal() + self.assertEqual(payload["next_episode"]["episodeid"], "15") + self.assertEqual(server.API.get_episodes_calls, [("series-1", "10")]) + self.assertEqual(server.API.get_items_calls, []) + + def test_preserves_server_order_when_episode_indexes_are_missing(self): + current = episode(20, None, None, Name="Unnumbered episode") + following = episode(21, None, None, Name="Next unnumbered episode") + server = FakeServer(current, [current, following]) + + self.assertTrue(upnext.send_upnext(server, "20", 0, 0)) + + _, payload = self.decoded_signal() + self.assertEqual(payload["next_episode"]["episodeid"], "21") + self.assertEqual(server.API.get_episodes_calls, [("series-1", "20")]) + self.assertEqual(server.API.get_items_calls, []) + + def test_selects_server_ordered_second_part_with_duplicate_episode_number(self): + current = episode(30, 1, 2, Name="Episode 2, part one") + following = episode(31, 1, 2, Name="Episode 2, part two") + server = FakeServer(current, [current, following, episode(32, 1, 3)]) + + self.assertTrue(upnext.send_upnext(server, "30", 0, 0)) + + _, payload = self.decoded_signal() + self.assertEqual(payload["next_episode"]["episodeid"], "31") + self.assertEqual(server.API.get_episodes_calls, [("series-1", "30")]) + self.assertEqual(server.API.get_items_calls, []) + + def test_does_not_signal_for_non_episode(self): + current = {"Id": "movie-1", "Type": "Movie", "SeriesId": "series-1"} + server = FakeServer(current, [current, episode(2, 1, 2)]) + + self.assertFalse(upnext.send_upnext(server, "movie-1", 0, 0)) + + xbmc.executeJSONRPC.assert_not_called() + self.assertEqual(server.API.get_items_calls, []) + + def test_does_not_signal_when_current_episode_is_missing(self): + server = FakeServer({}, []) + + self.assertFalse(upnext.send_upnext(server, "missing", 0, 0)) + + xbmc.executeJSONRPC.assert_not_called() + + def test_does_not_signal_for_last_episode(self): + current = episode(30, 3, 8) + server = FakeServer(current, [episode(29, 3, 7), current]) + + self.assertFalse(upnext.send_upnext(server, "30", 0, 0)) + + xbmc.executeJSONRPC.assert_not_called() + + def test_metadata_and_artwork_use_safe_defaults(self): + current = {"Id": "40", "Type": "Episode", "SeriesId": "series-1"} + following = {"Id": "41", "Type": "Episode", "SeriesId": "series-1"} + server = FakeServer(current, [current, following]) + + self.assertTrue(upnext.send_upnext(server, "40", 0, 0)) + + _, payload = self.decoded_signal() + expected = { + "episodeid": "40", + "tvshowid": "series-1", + "title": "", + "art": { + "thumb": "", + "tvshow.clearart": "", + "tvshow.clearlogo": "", + "tvshow.fanart": "", + "tvshow.landscape": "", + "tvshow.poster": "", + }, + "season": 0, + "episode": 0, + "showtitle": "", + "plot": "", + "playcount": 0, + "rating": 0, + "firstaired": "", + "runtime": 0, + } + self.assertEqual(payload["current_episode"], expected) + + def test_includes_notification_time_from_valid_emby_ticks(self): + current = episode(50, 1, 1) + following = episode(51, 1, 2) + server = FakeServer(current, [current, following]) + + self.assertTrue( + upnext.send_upnext( + server, + "50", + 1_800 * 10_000_000, + 1_740 * 10_000_000, + ) + ) + + _, payload = self.decoded_signal() + self.assertEqual(payload["notification_time"], 60) + + def test_omits_notification_time_for_invalid_ticks(self): + invalid_values = ( + (0, 100), + (100, 0), + (100, 100), + (100, 101), + (-100, 50), + ("invalid", 50), + (100, "invalid"), + (None, None), + ) + + for runtime_ticks, credits_ticks in invalid_values: + with self.subTest(runtime_ticks=runtime_ticks, credits_ticks=credits_ticks): + current = episode(60, 1, 1) + following = episode(61, 1, 2) + server = FakeServer(current, [current, following]) + xbmc.executeJSONRPC.reset_mock() + + self.assertTrue( + upnext.send_upnext( + server, + "60", + runtime_ticks, + credits_ticks, + ) + ) + + _, payload = self.decoded_signal() + self.assertNotIn("notification_time", payload) + + def test_dispatches_episode_lookup_on_thread_only_when_enabled(self): + playing_item = [ + {"ItemId": "70", "RunTimeTicks": 1_800 * 10_000_000}, + 0, + 0, + 1_740 * 10_000_000, + object(), + 1, + "episode", + "", + ] + + upnext.dispatch(playing_item) + + xbmc.getCondVisibility.assert_called_once_with( + "System.AddonIsEnabled(service.upnext)" + ) + utils.start_thread.assert_called_once_with( + upnext.send_upnext, + ( + playing_item[4], + "70", + 1_800 * 10_000_000, + 1_740 * 10_000_000, + ), + ) + + for enabled, media_type in ((False, "episode"), (True, "movie")): + with self.subTest(enabled=enabled, media_type=media_type): + xbmc.getCondVisibility.reset_mock() + xbmc.getCondVisibility.return_value = enabled + utils.start_thread.reset_mock() + playing_item[6] = media_type + + upnext.dispatch(playing_item) + + utils.start_thread.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_upnext_playback.py b/tests/test_upnext_playback.py new file mode 100644 index 000000000..3c8d27192 --- /dev/null +++ b/tests/test_upnext_playback.py @@ -0,0 +1,700 @@ +import importlib.util +import json +from pathlib import Path +import sys +import threading +import types +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] + + +class FakeQueue: + def __init__(self): + self.items = [] + + def get(self): + return self.items.pop(0) + + def getall(self): + items = self.items[:] + self.items.clear() + return items + + def put(self, item): + self.items.append(item) + + +class FakePlaylist: + def __init__(self): + self.items = [] + self.position = -1 + + def add(self, path, listitem=None, index=None): + if index is None: + self.items.append(path) + else: + self.items.insert(index, path) + + def clear(self): + self.items.clear() + + def getposition(self): + return self.position + + def size(self): + return len(self.items) + + +class TrackingServers(dict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.lookups = [] + + def __getitem__(self, key): + self.lookups.append(key) + return super().get(key, next(iter(self.values()))) + + +class FakeClient: + def __init__(self, request, on_close): + self.request = request.encode("utf-8") + self.on_close = on_close + self.sent = [] + + def settimeout(self, timeout): + pass + + def recv(self, size): + return self.request + + def send(self, data): + self.sent.append(data) + + def close(self): + self.on_close() + + +class PlaybackHarness: + def __init__(self): + self.playlists = [FakePlaylist(), FakePlaylist()] + self.json_calls = [] + self.server = mock.Mock() + self.server.ServerData = { + "ServerId": "server-1", + "ServerUrl": "https://emby.example", + "AccessToken": "secret-token", + } + self.server.EmbySession = [] + self.server.API = mock.Mock() + self.servers = TrackingServers({"server-1": self.server}) + self.db = mock.Mock() + self.db.get_KodiId_by_EmbyId.return_value = (202, "episode") + self.saved_modules = {} + self.saved_attributes = [] + self._install() + + def _module(self, name, **attributes): + module = types.ModuleType(name) + for key, value in attributes.items(): + setattr(module, key, value) + return module + + def _replace_module(self, name, module): + self.saved_modules[name] = sys.modules.get(name) + sys.modules[name] = module + + def _set_attribute(self, parent, name, value): + existed = hasattr(parent, name) + previous = getattr(parent, name, None) + self.saved_attributes.append((parent, name, existed, previous)) + setattr(parent, name, value) + + def _load(self, name, relative_path): + if name not in self.saved_modules: + self.saved_modules[name] = sys.modules.get(name) + spec = importlib.util.spec_from_file_location(name, ROOT / relative_path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + def _send_json(self, payload, get_result=False): + data = json.loads(payload) + self.json_calls.append(data) + method = data.get("method") + params = data.get("params", {}) + + if method == "Playlist.Insert": + item = params["item"] + media_type = next(iter(item)) + self.playlists[params["playlistid"]].items.insert( + params["position"], f"{media_type}:{item[media_type]}" + ) + elif method == "Playlist.Remove": + self.playlists[params["playlistid"]].items.pop(params["position"]) + + return {"result": {}} + + def _install(self): + import core + import database + import dialogs + import emby + import helper + import hooks + + xbmc = self._module( + "xbmc", + Player=lambda: mock.Mock(), + PlayList=lambda playlist_id: self.playlists[playlist_id], + Keyboard=lambda: mock.Mock(), + log=mock.Mock(), + executebuiltin=mock.Mock(), + ) + xbmcgui = self._module("xbmcgui", getCurrentWindowId=lambda: 12005) + queue = self._module("helper.queue", Queue=FakeQueue) + cache = self._module( + "helper.cache", + QueryCache={}, + update_querycache_userdata=mock.Mock(), + ) + utils = self._module( + "helper.utils", + DebugLog=False, + WebserviceWorkers=1, + SystemShutdown=False, + remotecontrol_client_control=False, + RemoteMode=False, + KodiTypeMapping={"episode": "episode"}, + Playlists=self.playlists, + EmbyServers=self.servers, + SendJson=self._send_json, + SafeLock=lambda lock: lock, + EmbyServerOnlineCondition=threading.Condition(), + ActivateWindow=mock.Mock(), + readFileBinary=lambda path: b"", + start_thread=mock.Mock(), + update_SyncPause=mock.Mock(), + unset_SyncLock=mock.Mock(), + closeall_ProgressBar=mock.Mock(), + currenttime=lambda: "now", + ItemSkipUpdate=[], + PauseSyncDuringPlayback=False, + PauseSyncDuringPlaybackStateChange=False, + skipintroembuarydesign=False, + CustomDialogParameters=(), + nodesreset=mock.Mock(), + image_overlay=mock.Mock(return_value=(b"overlay-data", "image/png", "png")), + enableCoverArt=False, + compressArt=False, + ArtworkLimitations=False, + ) + dbio = self._module( + "database.dbio", + DBOpenRO=lambda server_id, task: self.db, + DBCloseRO=mock.Mock(), + ) + listitem = self._module("emby.listitem") + artworkcache = self._module("helper.artworkcache") + websocket = self._module("hooks.websocket") + xbmcvfs = self._module("xbmcvfs") + common = self._module("core.common") + skipintrocredits = self._module( + "dialogs.skipintrocredits", + SkipIntro=lambda *args: mock.Mock(dialog_open=False), + ) + + modules = { + "xbmc": xbmc, + "xbmcgui": xbmcgui, + "helper.utils": utils, + "helper.queue": queue, + "helper.cache": cache, + "database.dbio": dbio, + "emby.listitem": listitem, + "helper.artworkcache": artworkcache, + "hooks.websocket": websocket, + "xbmcvfs": xbmcvfs, + "core.common": common, + "dialogs.skipintrocredits": skipintrocredits, + } + for name, module in modules.items(): + self._replace_module(name, module) + + for parent, name, module in ( + (helper, "utils", utils), + (helper, "queue", queue), + (helper, "cache", cache), + (database, "dbio", dbio), + (emby, "listitem", listitem), + (helper, "artworkcache", artworkcache), + (hooks, "websocket", websocket), + (core, "common", common), + (dialogs, "skipintrocredits", skipintrocredits), + ): + self._set_attribute(parent, name, module) + + self.playerops = self._load("helper.playerops", "helper/playerops.py") + self._set_attribute(helper, "playerops", self.playerops) + + upnext = self._module("helper.upnext", dispatch=mock.Mock()) + self._replace_module("helper.upnext", upnext) + self._set_attribute(helper, "upnext", upnext) + self.player = self._load("helper.player", "helper/player.py") + self._set_attribute(helper, "player", self.player) + + metadata = self._load("emby.metadata", "emby/metadata.py") + httpcache = self._module("emby.httpcache", get=lambda payload: None) + favorites = self._module("hooks.favorites") + context = self._module("helper.context") + pluginmenu = self._module("helper.pluginmenu") + xmls = self._module( + "helper.xmls", load_defaultvideosettings=lambda: {} + ) + for name, module in ( + ("emby.httpcache", httpcache), + ("hooks.favorites", favorites), + ("helper.context", context), + ("helper.pluginmenu", pluginmenu), + ("helper.xmls", xmls), + ): + self._replace_module(name, module) + + for parent, name, module in ( + (emby, "metadata", metadata), + (emby, "httpcache", httpcache), + (hooks, "favorites", favorites), + (helper, "context", context), + (helper, "pluginmenu", pluginmenu), + (helper, "xmls", xmls), + ): + self._set_attribute(parent, name, module) + + self.webservice = self._load("hooks.webservice", "hooks/webservice.py") + self._set_attribute(hooks, "webservice", self.webservice) + self.http = self._load("emby.http", "emby/http.py") + self._set_attribute(emby, "http", self.http) + self.api = self._load("emby.api", "emby/api.py") + self._set_attribute(emby, "api", self.api) + self.utils = utils + self.pluginmenu = pluginmenu + + def close(self): + for parent, name, existed, previous in reversed(self.saved_attributes): + if existed: + setattr(parent, name, previous) + else: + delattr(parent, name) + + for name, previous in self.saved_modules.items(): + if previous is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = previous + + def handoff_query(self, query): + request = "EVENT service;1;?" + query + client = FakeClient( + request, + lambda: setattr(self.webservice, "Running", False), + ) + self.webservice.WorkerQueue.items = [client] + self.webservice.Running = True + self.webservice.worker_Query(0) + return client + + def handoff(self, item_id="2"): + return self.handoff_query(f"mode=play&server=server-1&item={item_id}") + + def picture(self, path): + client = FakeClient( + f"GET {path} HTTP/1.1", + lambda: setattr(self.webservice, "Running", False), + ) + self.webservice.WorkerQueue.items = [client] + self.webservice.Running = True + self.webservice.worker_Query(0) + return client + + +class UpNextPlaybackIntegrationTests(unittest.TestCase): + def setUp(self): + self.harness = PlaybackHarness() + self.addCleanup(self.harness.close) + self.play_url = ( + "plugin://plugin.service.emby-next-gen/" + "?mode=play&server=server-1&item=2" + ) + + def test_automatic_advance_replaces_queued_url_without_losing_playlist_tail(self): + playlist = self.harness.playlists[1] + playlist.items = ["current", self.play_url, "remaining-a", "remaining-b"] + playlist.position = 1 + + client = self.harness.handoff() + + self.assertEqual(client.sent, [self.harness.webservice.sendOK]) + if self.harness.player.PlaylistRemoveItem != -1: + self.harness.playerops.RemovePlaylistItem( + 1, self.harness.player.PlaylistRemoveItem + ) + self.harness.player.PlaylistRemoveItem = -1 + self.assertEqual( + playlist.items, + ["current", "episodeid:202", "remaining-a", "remaining-b"], + ) + self.assertEqual(playlist.items.count("episodeid:202"), 1) + + def test_rejects_invalid_play_queries_before_sensitive_sinks(self): + invalid_queries = ( + "mode=play&server=server-1&item=2;mode=nodesreset", + "mode=play&server=server-1&item=2 attacker-suffix", + "mode=play&server=server-1&item=2 ", + "mode=play&server=server-1&item=2\tattacker-suffix", + "mode=play&server=server-1&item=2\x00attacker-suffix", + "mode=play&server=server-1&item=2%00attacker-suffix", + "mode=play&server=server-1&item=2%09attacker-suffix", + "mode=play&server=server-1&item=2%3Bmode%3Dnodesreset", + "mode=play&server=server-1&item=2%EF%BC%86mode%EF%BC%9Dnodesreset", + "mode=play&server=server-1&item=2\uff06mode\uff1dnodesreset", + "mode=play&mode=nodesreset&server=server-1&item=2", + "mode=nodesreset&mode=play&server=server-1&item=2", + "mode=play&server=server-1&server=other&item=2", + "mode=play&server=server-1&item=2&item=3", + "mode=play&item=2", + "mode=play&server=server-1", + "mode=play&server=server-1&item=2&unexpected=value", + "mode=play&server=server-1&item=2%26mode%3Dnodesreset", + "mode=play&server=server-1&item=2%3Dnodesreset", + "mode=play&server=server-1&item=2%25nodesreset", + "mode=play&server=server-1&item=2%3Fnodesreset", + "mode=play&server=server-1&item=2%2Fnodesreset", + "mode=play&server=server-1&item=2%5Cnodesreset", + "mode=play&server=server-1&item=2%0D%0Anodesreset", + "mode=play&server=server%26mode%3Dnodesreset&item=2", + "mode=play&server=server%3Dnodesreset&item=2", + "mode=play&server=server%25nodesreset&item=2", + "mode=play&server=server%3Fnodesreset&item=2", + "mode=play&server=server%2Fnodesreset&item=2", + "mode=play&server=server%5Cnodesreset&item=2", + "mode=play&server=server%0D%0Anodesreset&item=2", + ) + + for query in invalid_queries: + with self.subTest(query=query): + self.harness.servers.lookups.clear() + self.harness.db.reset_mock() + self.harness.utils.nodesreset.reset_mock() + self.harness.pluginmenu.databasereset = mock.Mock() + self.harness.player.PlaylistRemoveItem = 47 + + with mock.patch.object( + self.harness.playerops, "GetPlaylistPosition" + ) as get_position, mock.patch.object( + self.harness.playerops, "PlayEmby" + ) as play_emby: + client = self.harness.handoff_query(query) + + self.assertEqual(client.sent, [self.harness.webservice.sendNotFound]) + self.assertEqual(self.harness.servers.lookups, []) + self.assertEqual(self.harness.player.PlaylistRemoveItem, 47) + get_position.assert_not_called() + play_emby.assert_not_called() + self.harness.db.get_KodiId_by_EmbyId.assert_not_called() + self.harness.utils.nodesreset.assert_not_called() + self.harness.pluginmenu.databasereset.assert_not_called() + + def test_numeric_item_plays_with_guid_and_established_server_ids(self): + server_ids = ( + "2a38697ffc1b428b943aa1b6014e2263", + "2a38697f-fc1b-428b-943a-a1b6014e2263", + ) + + for server_id in server_ids: + with self.subTest(server_id=server_id): + self.harness.servers[server_id] = self.harness.server + self.harness.server.ServerData["ServerId"] = server_id + self.harness.playlists[1].position = 0 + + client = self.harness.handoff_query( + f"mode=play&server={server_id}&item=58574" + ) + + self.assertEqual(client.sent, [self.harness.webservice.sendOK]) + self.assertEqual( + self.harness.db.get_KodiId_by_EmbyId.call_args.args, + ("58574",), + ) + self.harness.db.reset_mock() + + def test_upnext_artwork_is_returned_locally_without_credentials(self): + artwork = ( + ("/picture/server-1/p-10-0-p-aabbcc01", "10", "Primary"), + ("/picture/server-1/p-58574-0-l-aabbcc02", "58574", "Logo"), + ("/picture/server-1/p-58574-0-B-aabbcc03", "58574", "Backdrop"), + ("/picture/server-1/p-58574-0-t-aabbcc04", "58574", "Thumb"), + ("/picture/server-1/p-58574-0-p-aabbcc05", "58574", "Primary"), + ) + self.harness.server.API.get_Image_Binary.return_value = ( + b"image-data", + "image/png", + "png", + ) + + for path, item_id, image_type in artwork: + with self.subTest(path=path): + self.harness.server.API.get_Image_Binary.reset_mock() + + client = self.harness.picture(path) + + self.assertEqual(len(client.sent), 1) + response = client.sent[0] + headers, body = response.split(b"\r\n\r\n", 1) + self.assertTrue(headers.startswith(b"HTTP/1.1 200 OK\r\n")) + self.assertIn(b"Content-Type: image/png", headers) + self.assertNotIn(b"Location:", headers) + self.assertEqual(body, b"image-data") + self.harness.server.API.get_Image_Binary.assert_called_once_with( + item_id, + image_type, + "0", + path.rsplit("-", 1)[-1], + False, + ) + self.assert_credential_free(response) + + def test_picture_failures_are_credential_free_not_found_responses(self): + path = "/picture/server-1/p-10-0-p-aabbcc01" + failures = ( + ("empty image", (b"", "image/png", "png")), + ("fetch exception", RuntimeError("secret-token https://emby.example")), + ) + + for label, result in failures: + with self.subTest(failure=label): + if isinstance(result, Exception): + self.harness.server.API.get_Image_Binary.side_effect = result + else: + self.harness.server.API.get_Image_Binary.side_effect = None + self.harness.server.API.get_Image_Binary.return_value = result + + client = self.harness.picture(path) + + self.assertEqual(client.sent, [self.harness.webservice.sendNotFound]) + self.assert_credential_free(client.sent[0]) + + self.harness.server.API.get_Image_Binary.side_effect = None + with mock.patch.object( + self.harness.webservice, "wait_for_Embyserver", return_value=True + ) as wait_for_server: + missing = self.harness.picture( + "/picture/missing-server/p-10-0-p-aabbcc01" + ) + self.assertEqual(missing.sent, [self.harness.webservice.sendNotFound]) + self.assert_credential_free(missing.sent[0]) + wait_for_server.assert_not_called() + + def test_authenticated_image_fetches_reject_redirects_before_second_request(self): + api = object.__new__(self.harness.api.API) + api.EmbyServer = types.SimpleNamespace(http=mock.Mock()) + api.EmbyServer.http.request.return_value = (302, {}, b"") + + api.get_Image_Binary("10", "Primary", "0", "aabbcc01", False) + + api.EmbyServer.http.request.assert_called_once_with( + "GET", + "Items/10/Images/Primary/0", + {"EnableImageEnhancers": False, "tag": "aabbcc01"}, + {}, + True, + "", + None, + "", + False, + ) + + redirect_locations = ( + "http://evil.example/collect", + "https://evil.example/collect", + "http://127.0.0.1:9999/private", + "http://169.254.169.254/latest/meta-data", + "https://emby.example/redirect-loop", + ) + credentials = { + "Authorization": 'Emby Client="test"', + "X-Emby-Token": "secret-token", + "Cookie": "session=secret-cookie", + } + + for location in redirect_locations: + with self.subTest(location=location): + http = object.__new__(self.harness.http.HTTP) + http.EmbyServer = self.harness.server + http.Connection = { + "MAIN": { + "Hostname": "emby.example", + "Port": 443, + "RequestHeader": credentials.copy(), + } + } + http.Response = {} + http.RequestBusy = {} + http.Requests_Counter = mock.Mock() + http.socket_open = mock.Mock(return_value=0) + http.socket_close = mock.Mock() + http.update_header = mock.Mock() + sent_headers = [] + + def socket_request(*args): + sent_headers.append(http.Connection["MAIN"]["RequestHeader"].copy()) + if len(sent_headers) == 1: + return 302, {"location": location}, b"" + return 200, {"content-type": "image/png"}, b"leaked" + + http.socket_request = mock.Mock(side_effect=socket_request) + http.send_request( + "GET", "Items/10/Images/Primary/0", {}, {}, True, + "", True, "MAIN", "REQUESTIMAGE", False + ) + + self.assertEqual(sent_headers, [credentials]) + self.assertEqual(http.socket_request.call_count, 1) + self.assertEqual(http.Response["REQUESTIMAGE"], (302, {}, b"")) + http.socket_close.assert_called_once_with("MAIN", True) + + http = object.__new__(self.harness.http.HTTP) + redirected_socket = mock.Mock() + http.Connection = { + "MAIN": { + "Socket": redirected_socket, + "SubUrl": "/emby/", + "Hostname": "emby.example", + "Port": 443, + } + } + http.socket_close("MAIN", True) + redirected_socket.send.assert_not_called() + redirected_socket.close.assert_called_once_with() + + def test_malformed_picture_paths_fail_closed_before_network_work(self): + invalid_paths = ( + "/picture/server-1/p-..-0-p-tag", + "/picture/server-1/p-http:%2F%2F127.0.0.1-0-p-tag", + "/picture/server-1/p-10", + "/picture/server-1/p-10-0-x-tag", + "/picture/server-1/p-10-0-p-tag/extra", + "/picture/server-1/extra/p-10-0-p-tag", + "/picture/server%2Fother/p-10-0-p-tag", + "/picture/server-1/p-10%2F11-0-p-tag", + "/picture/server-1/p-10-1%2F2-p-tag", + "/picture/server-1/p-10-\uff10-p-tag", + "/picture/server-1/p-10-0-p-..", + "/picture/server-1/p-10-0-p-http:%2F%2F127.0.0.1", + "/picture/server-1/p-10-0-p-tag%00", + "/picture/server-1/p-10--p-tag", + "/picture/server-1/x-10-0-p-tag", + "/picture/server-1/p-10-0-p-", + "/picture/server-1/p-10-0-p-tag?query=1", + "/picture/server-1/p-10-0-p-tag\x00suffix", + "/picture/server-1/p-10-0-p-t\uff41g", + ) + + for path in invalid_paths: + with self.subTest(path=path), mock.patch.object( + self.harness.webservice, "wait_for_Embyserver", return_value=True + ) as wait_for_server: + self.harness.server.API.get_Image_Binary.reset_mock() + self.harness.utils.image_overlay.reset_mock() + + try: + client = self.harness.picture(path) + except Exception as error: + self.fail(f"malformed picture path raised {type(error).__name__}: {error}") + + self.assertEqual(client.sent, [self.harness.webservice.sendNotFound]) + self.assert_credential_free(client.sent[0]) + wait_for_server.assert_not_called() + self.harness.server.API.get_Image_Binary.assert_not_called() + self.harness.utils.image_overlay.assert_not_called() + + def test_valid_picture_overlay_preserves_encoded_text_and_local_bytes(self): + client = self.harness.picture( + "/picture/server-1/p-10-0-p-aabbcc01-Label-One%0A%28Content%29" + ) + + self.assertEqual(len(client.sent), 1) + headers, body = client.sent[0].split(b"\r\n\r\n", 1) + self.assertTrue(headers.startswith(b"HTTP/1.1 200 OK\r\n")) + self.assertEqual(body, b"overlay-data") + self.harness.utils.image_overlay.assert_called_once_with( + "aabbcc01", "server-1", "10", "Primary", "0", "Label-One\n(Content)" + ) + self.harness.server.API.get_Image_Binary.assert_not_called() + self.assert_credential_free(client.sent[0]) + + def assert_credential_free(self, response): + for secret in ( + b"secret-token", + b"https://emby.example", + b"api_key", + b"AccessToken", + b"ServerUrl", + ): + self.assertNotIn(secret, response) + + def test_watch_now_ignores_delayed_stop_from_prior_episode(self): + playlist = self.harness.playlists[1] + playlist.items = ["current", self.play_url, "remaining"] + playlist.position = 1 + self.harness.handoff() + self.harness.playerops.RemovePlaylistItem( + 1, self.harness.player.PlaylistRemoveItem + ) + self.harness.player.PlaylistRemoveItem = -1 + + session = { + "ItemId": 2, + "PositionTicks": 0, + "RunTimeTicks": 1_800 * 10_000_000, + } + self.harness.player.PlayingItem = [ + session, + 0, + 0, + 0, + self.harness.server, + 1, + "episode", + "", + ] + self.harness.player.EmbyPlaying = True + self.harness.player.PlayItem = (202, "episode") + self.harness.player.PlayerEventsQueue.items = [ + ( + "stop", + json.dumps( + { + "end": True, + "item": {"id": 101, "type": "episode"}, + } + ), + ), + "QUIT", + ] + + self.harness.player.PlayerCommands() + + self.harness.server.API.session_stop.assert_not_called() + self.assertEqual(self.harness.player.PlayingItem[0]["ItemId"], 2) + self.assertEqual(self.harness.player.PlayItem, (202, "episode")) + self.assertEqual( + playlist.items, + ["current", "episodeid:202", "remaining"], + ) + + +if __name__ == "__main__": + unittest.main()