diff --git a/resources/lib/api.py b/resources/lib/api.py new file mode 100644 index 00000000..d15822bb --- /dev/null +++ b/resources/lib/api.py @@ -0,0 +1,378 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import clientinfo +import utils + +################################################################################################## + + +class API(): + + def __init__(self, item): + + self.item = item + self.clientinfo = clientinfo.ClientInfo() + self.addonName = self.clientinfo.getAddonName() + + def logMsg(self, msg, lvl=1): + + className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, className), msg, lvl) + + + def getUserData(self): + # Default + favorite = False + playcount = None + played = False + lastPlayedDate = None + resume = 0 + rating = 0 + + try: + userdata = self.item['UserData'] + + except KeyError: # No userdata found. + pass + + else: + favorite = userdata['IsFavorite'] + likes = userdata.get('Likes') + # Rating for album and songs + if favorite: + rating = 5 + elif likes: + rating = 3 + elif likes == False: + rating = 1 + else: + rating = 0 + + lastPlayedDate = userdata.get('LastPlayedDate') + if lastPlayedDate: + lastPlayedDate = lastPlayedDate.split('.')[0].replace('T', " ") + + if userdata['Played']: + # Playcount is tied to the watch status + played = True + playcount = userdata['PlayCount'] + if playcount == 0: + playcount = 1 + + if lastPlayedDate is None: + lastPlayedDate = self.getDateCreated() + + playbackPosition = userdata.get('PlaybackPositionTicks') + if playbackPosition: + resume = playbackPosition / 10000000.0 + + return { + + 'Favorite': favorite, + 'PlayCount': playcount, + 'Played': played, + 'LastPlayedDate': lastPlayedDate, + 'Resume': resume, + 'Rating': rating + } + + def getPeople(self): + # Process People + director = [] + writer = [] + cast = [] + + try: + people = self.item['People'] + + except KeyError: + pass + + else: + for person in people: + + type = person['Type'] + name = person['Name'] + + if "Director" in type: + director.append(name) + elif "Actor" in type: + cast.append(name) + elif type in ("Writing", "Writer"): + writer.append(name) + + return { + + 'Director': director, + 'Writer': writer, + 'Cast': cast + } + + def getMediaStreams(self): + item = self.item + videotracks = [] + audiotracks = [] + subtitlelanguages = [] + + try: + media_streams = item['MediaSources'][0]['MediaStreams'] + + except KeyError: + media_streams = item['MediaStreams'] + + for media_stream in media_streams: + # Sort through Video, Audio, Subtitle + stream_type = media_stream['Type'] + codec = media_stream.get('Codec', "").lower() + profile = media_stream.get('Profile', "").lower() + + if stream_type == "Video": + # Height, Width, Codec, AspectRatio, AspectFloat, 3D + track = { + + 'videocodec': codec, + 'height': media_stream.get('Height'), + 'width': media_stream.get('Width'), + 'video3DFormat': item.get('Video3DFormat'), + 'aspectratio': 1.85 + } + + try: + container = item['MediaSources'][0]['Container'].lower() + except: + container = "" + + # Sort codec vs container/profile + if "msmpeg4" in codec: + track['videocodec'] = "divx" + elif "mpeg4" in codec: + if "simple profile" in profile or not profile: + track['videocodec'] = "xvid" + elif "h264" in codec: + if container in ("mp4", "mov", "m4v"): + track['videocodec'] = "avc1" + + # Aspect ratio + if item.get('AspectRatio'): + # Metadata AR + aspectratio = item['AspectRatio'] + else: # File AR + aspectratio = media_stream.get('AspectRatio', "0") + + try: + aspectwidth, aspectheight = aspectratio.split(':') + track['aspectratio'] = round(float(aspectwidth) / float(aspectheight), 6) + + except ValueError: + width = track['width'] + height = track['height'] + + if width and height: + track['aspectratio'] = round(float(width / height), 6) + + videotracks.append(track) + + elif stream_type == "Audio": + # Codec, Channels, language + track = { + + 'audiocodec': codec, + 'channels': media_stream.get('Channels'), + 'audiolanguage': media_stream.get('Language') + } + + if "dca" in codec and "dts-hd ma" in profile: + track['audiocodec'] = "dtshd_ma" + + audiotracks.append(track) + + elif stream_type == "Subtitle": + # Language + subtitlelanguages.append(media_stream.get('Language', "Unknown")) + + return { + + 'video': videotracks, + 'audio': audiotracks, + 'subtitle': subtitlelanguages + } + + def getRuntime(self): + item = self.item + try: + runtime = item['RunTimeTicks'] / 10000000.0 + + except KeyError: + runtime = item.get('CumulativeRunTimeTicks', 0) / 10000000.0 + + return runtime + + def adjustResume(self, resume_seconds): + + resume = 0 + if resume_seconds: + resume = round(float(resume_seconds), 6) + jumpback = int(utils.settings('resumeJumpBack')) + if resume > jumpback: + # To avoid negative bookmark + resume = resume - jumpback + + return resume + + def getStudios(self): + # Process Studios + item = self.item + studios = [] + + try: + studio = item['SeriesStudio'] + studios.append(self.verifyStudio(studio)) + + except KeyError: + studioList = item['Studios'] + for studio in studioList: + + name = studio['Name'] + studios.append(self.verifyStudio(name)) + + return studios + + def verifyStudio(self, studioName): + # Convert studio for Kodi to properly detect them + studios = { + + 'abc (us)': "ABC", + 'fox (us)': "FOX", + 'mtv (us)': "MTV", + 'showcase (ca)': "Showcase", + 'wgn america': "WGN" + } + + return studios.get(studioName.lower(), studioName) + + def getChecksum(self): + # Use the etags checksum and userdata + item = self.item + userdata = item['UserData'] + + checksum = "%s%s%s%s%s%s" % ( + + item['Etag'], + userdata['Played'], + userdata['IsFavorite'], + userdata['PlaybackPositionTicks'], + userdata.get('UnplayedItemCount', ""), + userdata.get('LastPlayedDate', "") + ) + + return checksum + + def getGenres(self): + item = self.item + all_genres = "" + genres = item.get('Genres', item.get('SeriesGenres')) + + if genres: + all_genres = " / ".join(genres) + + return all_genres + + def getDateCreated(self): + + try: + dateadded = self.item['DateCreated'] + dateadded = dateadded.split('.')[0].replace('T', " ") + except KeyError: + dateadded = None + + return dateadded + + def getPremiereDate(self): + + try: + premiere = self.item['PremiereDate'] + premiere = premiere.split('.')[0].replace('T', " ") + except KeyError: + premiere = None + + return premiere + + def getOverview(self): + + try: + overview = self.item['Overview'] + overview = overview.replace("\"", "\'") + overview = overview.replace("\n", " ") + overview = overview.replace("\r", " ") + except KeyError: + overview = "" + + return overview + + def getTagline(self): + + try: + tagline = self.item['Taglines'][0] + except IndexError: + tagline = None + + return tagline + + def getProvider(self, providername): + + try: + provider = self.item['ProviderIds'][providername] + except KeyError: + provider = None + + return provider + + def getMpaa(self): + # Convert more complex cases + mpaa = self.item.get('OfficialRating', "") + + if mpaa in ("NR", "UR"): + # Kodi seems to not like NR, but will accept Not Rated + mpaa = "Not Rated" + + return mpaa + + def getCountry(self): + + try: + country = self.item['ProductionLocations'][0] + except IndexError: + country = None + + return country + + def getFilePath(self): + + item = self.item + try: + filepath = item['Path'] + + except KeyError: + filepath = "" + + else: + if "\\\\" in filepath: + # append smb protocol + filepath = filepath.replace("\\\\", "smb://") + filepath = filepath.replace("\\", "/") + + if item.get('VideoType'): + videotype = item['VideoType'] + # Specific format modification + if 'Dvd'in videotype: + filepath = "%s/VIDEO_TS/VIDEO_TS.IFO" % filepath + elif 'Bluray' in videotype: + filepath = "%s/BDMV/index.bdmv" % filepath + + if "\\" in filepath: + # Local path scenario, with special videotype + filepath = filepath.replace("/", "\\") + + return filepath \ No newline at end of file diff --git a/resources/lib/downloadutils.py b/resources/lib/downloadutils.py new file mode 100644 index 00000000..5c47144d --- /dev/null +++ b/resources/lib/downloadutils.py @@ -0,0 +1,401 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import json +import requests +import logging + +import xbmc +import xbmcgui + +import utils +import clientinfo + +################################################################################################## + +# Disable requests logging +from requests.packages.urllib3.exceptions import InsecureRequestWarning +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) +#logging.getLogger('requests').setLevel(logging.WARNING) + +################################################################################################## + + +class DownloadUtils(): + + # Borg - multiple instances, shared state + _shared_state = {} + clientInfo = clientinfo.ClientInfo() + addonName = clientInfo.getAddonName() + + # Requests session + s = None + timeout = 30 + + + def __init__(self): + + self.__dict__ = self._shared_state + + def logMsg(self, msg, lvl=1): + + className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, className), msg, lvl) + + + def setUsername(self, username): + # Reserved for userclient only + self.username = username + self.logMsg("Set username: %s" % username, 2) + + def setUserId(self, userId): + # Reserved for userclient only + self.userId = userId + self.logMsg("Set userId: %s" % userId, 2) + + def setServer(self, server): + # Reserved for userclient only + self.server = server + self.logMsg("Set server: %s" % server, 2) + + def setToken(self, token): + # Reserved for userclient only + self.token = token + self.logMsg("Set token: %s" % token, 2) + + def setSSL(self, ssl, sslclient): + # Reserved for userclient only + self.sslverify = ssl + self.sslclient = sslclient + self.logMsg("Verify SSL host certificate: %s" % ssl, 2) + self.logMsg("SSL client side certificate: %s" % sslclient, 2) + + + def postCapabilities(self, deviceId): + + # Post settings to session + url = "{server}/emby/Sessions/Capabilities/Full?format=json" + data = { + + 'PlayableMediaTypes': "Audio,Video", + 'SupportsMediaControl': True, + 'SupportedCommands': ( + + "MoveUp,MoveDown,MoveLeft,MoveRight,Select," + "Back,ToggleContextMenu,ToggleFullscreen,ToggleOsdMenu," + "GoHome,PageUp,NextLetter,GoToSearch," + "GoToSettings,PageDown,PreviousLetter,TakeScreenshot," + "VolumeUp,VolumeDown,ToggleMute,SendString,DisplayMessage," + "SetAudioStreamIndex,SetSubtitleStreamIndex," + + "Mute,Unmute,SetVolume," + "Play,Playstate,PlayNext" + ) + } + + self.logMsg("Capabilities URL: %s" % url, 2) + self.logMsg("Postdata: %s" % data, 2) + + self.downloadUrl(url, postBody=data, type="POST") + self.logMsg("Posted capabilities to %s" % self.server, 2) + + # Attempt at getting sessionId + url = "{server}/emby/Sessions?DeviceId=%s&format=json" % deviceId + result = self.downloadUrl(url) + try: + sessionId = result[0]['Id'] + + except (KeyError, TypeError): + self.logMsg("Failed to retrieve sessionId.", 1) + + else: + self.logMsg("Session: %s" % result, 2) + self.logMsg("SessionId: %s" % sessionId, 1) + utils.window('emby_sessionId', value=sessionId) + + # Post any permanent additional users + additionalUsers = utils.settings('additionalUsers') + if additionalUsers: + + additionalUsers = additionalUsers.split(',') + self.logMsg( + "List of permanent users added to the session: %s" + % additionalUsers, 1) + + # Get the user list from server to get the userId + url = "{server}/emby/Users?format=json" + result = self.downloadUrl(url) + + for additional in additionalUsers: + addUser = additional.decode('utf-8').lower() + + # Compare to server users to list of permanent additional users + for user in result: + username = user['Name'].lower() + + if username in addUser: + userId = user['Id'] + url = ( + "{server}/emby/Sessions/%s/Users/%s?format=json" + % (sessionId, userId) + ) + self.downloadUrl(url, postBody={}, type="POST") + + + def startSession(self): + + self.deviceId = self.clientInfo.getDeviceId() + + # User is identified from this point + # Attach authenticated header to the session + verify = None + cert = None + header = self.getHeader() + + # If user enabled host certificate verification + try: + verify = self.sslverify + cert = self.sslclient + except: + self.logMsg("Could not load SSL settings.", 1) + + # Start session + self.s = requests.Session() + self.s.headers = header + self.s.verify = verify + self.s.cert = cert + # Retry connections to the server + self.s.mount("http://", requests.adapters.HTTPAdapter(max_retries=1)) + self.s.mount("https://", requests.adapters.HTTPAdapter(max_retries=1)) + + self.logMsg("Requests session started on: %s" % self.server, 1) + + def stopSession(self): + try: + self.s.close() + except: + self.logMsg("Requests session could not be terminated.", 1) + + def getHeader(self, authenticate=True): + + clientInfo = self.clientInfo + + deviceName = clientInfo.getDeviceName() + deviceId = clientInfo.getDeviceId() + version = clientInfo.getVersion() + + if not authenticate: + # If user is not authenticated + auth = ( + 'MediaBrowser Client="Kodi", Device="%s", DeviceId="%s", Version="%s"' + % (deviceName, deviceId, version)) + header = { + + 'Content-type': 'application/json', + 'Accept-encoding': 'gzip', + 'Accept-Charset': 'UTF-8,*', + 'Authorization': auth + } + self.logMsg("Header: %s" % header, 2) + + else: + userId = self.userId + token = self.token + # Attached to the requests session + auth = ( + 'MediaBrowser UserId="%s", Client="Kodi", Device="%s", DeviceId="%s", Version="%s"' + % (userId, deviceName, deviceId, version)) + header = { + + 'Content-type': 'application/json', + 'Accept-encoding': 'gzip', + 'Accept-Charset': 'UTF-8,*', + 'Authorization': auth, + 'X-MediaBrowser-Token': token + } + self.logMsg("Header: %s" % header, 2) + + return header + + def downloadUrl(self, url, postBody=None, type="GET", parameters=None, authenticate=True): + + self.logMsg("=== ENTER downloadUrl ===", 2) + + timeout = self.timeout + default_link = "" + + try: + # If user is authenticated + if (authenticate): + # Get requests session + try: + s = self.s + # Replace for the real values + url = url.replace("{server}", self.server) + url = url.replace("{UserId}", self.userId) + + # Prepare request + if type == "GET": + r = s.get(url, json=postBody, params=parameters, timeout=timeout) + elif type == "POST": + r = s.post(url, json=postBody, timeout=timeout) + elif type == "DELETE": + r = s.delete(url, json=postBody, timeout=timeout) + + except AttributeError: + # request session does not exists + # Get user information + self.userId = utils.window('emby_currUser') + self.server = utils.window('emby_server%s' % self.userId) + self.token = utils.window('emby_accessToken%s' % self.userId) + header = self.getHeader() + verifyssl = False + cert = None + + # IF user enables ssl verification + if utils.settings('sslverify') == "true": + verifyssl = True + if utils.settings('sslcert') != "None": + cert = utils.settings('sslcert') + + # Replace for the real values + url = url.replace("{server}", self.server) + url = url.replace("{UserId}", self.userId) + + # Prepare request + if type == "GET": + r = requests.get(url, + json=postBody, + params=parameters, + headers=header, + timeout=timeout, + cert=cert, + verify=verifyssl) + + elif type == "POST": + r = requests.post(url, + json=postBody, + headers=header, + timeout=timeout, + cert=cert, + verify=verifyssl) + + elif type == "DELETE": + r = requests.delete(url, + json=postBody, + headers=header, + timeout=timeout, + cert=cert, + verify=verifyssl) + + # If user is not authenticated + elif not authenticate: + + header = self.getHeader(authenticate=False) + verifyssl = False + + # If user enables ssl verification + try: + verifyssl = self.sslverify + except AttributeError: + pass + + # Prepare request + if type == "GET": + r = requests.get(url, + json=postBody, + params=parameters, + headers=header, + timeout=timeout, + verify=verifyssl) + + elif type == "POST": + r = requests.post(url, + json=postBody, + headers=header, + timeout=timeout, + verify=verifyssl) + + ##### THE RESPONSE ##### + self.logMsg(r.url, 2) + if r.status_code == 204: + # No body in the response + self.logMsg("====== 204 Success ======", 2) + + elif r.status_code == requests.codes.ok: + + try: + # UTF-8 - JSON object + r = r.json() + self.logMsg("====== 200 Success ======", 2) + self.logMsg("Response: %s" % r, 2) + return r + + except: + if r.headers.get('content-type') != "text/html": + self.logMsg("Unable to convert the response for: %s" % url, 1) + else: + r.raise_for_status() + + ##### EXCEPTIONS ##### + + except requests.exceptions.ConnectionError as e: + # Make the addon aware of status + if utils.window('emby_online') != "false": + self.logMsg("Server unreachable at: %s" % url, 0) + self.logMsg(e, 2) + utils.window('emby_online', value="false") + + except requests.exceptions.ConnectTimeout as e: + self.logMsg("Server timeout at: %s" % url, 0) + self.logMsg(e, 1) + + except requests.exceptions.HTTPError as e: + + if r.status_code == 401: + # Unauthorized + status = utils.window('emby_serverStatus') + + if 'X-Application-Error-Code' in r.headers: + # Emby server errors + if r.headers['X-Application-Error-Code'] == "ParentalControl": + # Parental control - access restricted + utils.window('emby_serverStatus', value="restricted") + xbmcgui.Dialog().notification( + heading="Emby server", + message="Access restricted.", + icon=xbmcgui.NOTIFICATION_ERROR, + time=5000) + return False + + elif r.headers['X-Application-Error-Code'] == "UnauthorizedAccessException": + # User tried to do something his emby account doesn't allow + pass + + elif status not in ("401", "Auth"): + # Tell userclient token has been revoked. + utils.window('emby_serverStatus', value="401") + self.logMsg("HTTP Error: %s" % e, 0) + xbmcgui.Dialog().notification( + heading="Error connecting", + message="Unauthorized.", + icon=xbmcgui.NOTIFICATION_ERROR) + return 401 + + elif r.status_code in (301, 302): + # Redirects + pass + elif r.status_code == 400: + # Bad requests + pass + + except requests.exceptions.SSLError as e: + self.logMsg("Invalid SSL certificate for: %s" % url, 0) + self.logMsg(e, 1) + + except requests.exceptions.RequestException as e: + self.logMsg("Unknown error connecting to: %s" % url, 0) + self.logMsg(e, 1) + + return default_link \ No newline at end of file diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py new file mode 100644 index 00000000..588b8d3c --- /dev/null +++ b/resources/lib/entrypoint.py @@ -0,0 +1,842 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import json +import os +import sys +import urlparse + +import xbmc +import xbmcaddon +import xbmcgui +import xbmcvfs +import xbmcplugin + +import artwork +import utils +import clientinfo +import downloadutils +import read_embyserver as embyserver +import embydb_functions as embydb +import playlist +import playbackutils as pbutils +import playutils +import api + +################################################################################################# + + +def doPlayback(itemid, dbid): + + emby = embyserver.Read_EmbyServer() + item = emby.getItem(itemid) + pbutils.PlaybackUtils(item).play(itemid, dbid) + +##### DO RESET AUTH ##### +def resetAuth(): + # User tried login and failed too many times + resp = xbmcgui.Dialog().yesno( + heading="Warning", + line1=( + "Emby might lock your account if you fail to log in too many times. " + "Proceed anyway?")) + if resp == 1: + utils.logMsg("EMBY", "Reset login attempts.", 1) + utils.window('emby_serverStatus', value="Auth") + else: + xbmc.executebuiltin('Addon.OpenSettings(plugin.video.emby)') + +def addDirectoryItem(label, path, folder=True): + li = xbmcgui.ListItem(label, path=path) + li.setThumbnailImage("special://home/addons/plugin.video.emby/icon.png") + li.setArt({"fanart":"special://home/addons/plugin.video.emby/fanart.jpg"}) + li.setArt({"landscape":"special://home/addons/plugin.video.emby/fanart.jpg"}) + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=path, listitem=li, isFolder=folder) + +def doMainListing(): + + xbmcplugin.setContent(int(sys.argv[1]), 'files') + # Get emby nodes from the window props + embyprops = utils.window('Emby.nodes.total') + if embyprops: + totalnodes = int(embyprops) + for i in range(totalnodes): + path = utils.window('Emby.nodes.%s.index' % i) + if not path: + path = utils.window('Emby.nodes.%s.content' % i) + label = utils.window('Emby.nodes.%s.title' % i) + if path: + addDirectoryItem(label, path) + + # some extra entries for settings and stuff. TODO --> localize the labels + addDirectoryItem("Network credentials", "plugin://plugin.video.emby/?mode=passwords", False) + addDirectoryItem("Settings", "plugin://plugin.video.emby/?mode=settings", False) + addDirectoryItem("Add user to session", "plugin://plugin.video.emby/?mode=adduser", False) + #addDirectoryItem("Cache all images to Kodi texture cache (advanced)", "plugin://plugin.video.emby/?mode=texturecache") + addDirectoryItem("Perform manual sync", "plugin://plugin.video.emby/?mode=manualsync", False) + addDirectoryItem( + label="Repair local database (force update all content)", + path="plugin://plugin.video.emby/?mode=repair", + folder=False) + addDirectoryItem( + label="Perform local database reset (full resync)", + path="plugin://plugin.video.emby/?mode=reset", + folder=False) + addDirectoryItem( + label="Sync Emby Theme Media to Kodi", + path="plugin://plugin.video.emby/?mode=thememedia", + folder=False) + + xbmcplugin.endOfDirectory(int(sys.argv[1])) + +##### ADD ADDITIONAL USERS ##### +def addUser(): + + doUtils = downloadutils.DownloadUtils() + clientInfo = clientinfo.ClientInfo() + deviceId = clientInfo.getDeviceId() + deviceName = clientInfo.getDeviceName() + userid = utils.window('emby_currUser') + dialog = xbmcgui.Dialog() + + # Get session + url = "{server}/emby/Sessions?DeviceId=%s&format=json" % deviceId + result = doUtils.downloadUrl(url) + + try: + sessionId = result[0]['Id'] + additionalUsers = result[0]['AdditionalUsers'] + # Add user to session + userlist = {} + users = [] + url = "{server}/emby/Users?IsDisabled=false&IsHidden=false&format=json" + result = doUtils.downloadUrl(url) + + # pull the list of users + for user in result: + name = user['Name'] + userId = user['Id'] + if userid != userId: + userlist[name] = userId + users.append(name) + + # Display dialog if there's additional users + if additionalUsers: + + option = dialog.select("Add/Remove user from the session", ["Add user", "Remove user"]) + # Users currently in the session + additionalUserlist = {} + additionalUsername = [] + # Users currently in the session + for user in additionalUsers: + name = user['UserName'] + userId = user['UserId'] + additionalUserlist[name] = userId + additionalUsername.append(name) + + if option == 1: + # User selected Remove user + resp = dialog.select("Remove user from the session", additionalUsername) + if resp > -1: + selected = additionalUsername[resp] + selected_userId = additionalUserlist[selected] + url = "{server}/emby/Sessions/%s/Users/%s" % (sessionId, selected_userId) + doUtils.downloadUrl(url, postBody={}, type="DELETE") + dialog.notification( + heading="Success!", + message="%s removed from viewing session" % selected, + icon="special://home/addons/plugin.video.emby/icon.png", + time=1000) + + # clear picture + position = utils.window('EmbyAdditionalUserPosition.%s' % selected_userId) + utils.window('EmbyAdditionalUserImage.%s' % position, clear=True) + return + else: + return + + elif option == 0: + # User selected Add user + for adduser in additionalUsername: + try: # Remove from selected already added users. It is possible they are hidden. + users.remove(adduser) + except: pass + + elif option < 0: + # User cancelled + return + + # Subtract any additional users + utils.logMsg("EMBY", "Displaying list of users: %s" % users) + resp = dialog.select("Add user to the session", users) + # post additional user + if resp > -1: + selected = users[resp] + selected_userId = userlist[selected] + url = "{server}/emby/Sessions/%s/Users/%s" % (sessionId, selected_userId) + doUtils.downloadUrl(url, postBody={}, type="POST") + dialog.notification( + heading="Success!", + message="%s added to viewing session" % selected, + icon="special://home/addons/plugin.video.emby/icon.png", + time=1000) + + except: + utils.logMsg("EMBY", "Failed to add user to session.") + dialog.notification( + heading="Error", + message="Unable to add/remove user from the session.", + icon=xbmcgui.NOTIFICATION_ERROR) + + # Add additional user images + # always clear the individual items first + totalNodes = 10 + for i in range(totalNodes): + if not utils.window('EmbyAdditionalUserImage.%s' % i): + break + utils.window('EmbyAdditionalUserImage.%s' % i) + + url = "{server}/emby/Sessions?DeviceId=%s" % deviceId + result = doUtils.downloadUrl(url) + additionalUsers = result[0]['AdditionalUsers'] + count = 0 + for additionaluser in additionalUsers: + url = "{server}/emby/Users/%s?format=json" % additionaluser['UserId'] + result = doUtils.downloadUrl(url) + utils.window('EmbyAdditionalUserImage.%s' % count, + value=artwork.Artwork().getUserArtwork(result, 'Primary')) + utils.window('EmbyAdditionalUserPosition.%s' % additionaluser['UserId'], value=str(count)) + count +=1 + +##### THEME MUSIC/VIDEOS ##### +def getThemeMedia(): + + doUtils = downloadutils.DownloadUtils() + dialog = xbmcgui.Dialog() + playback = None + + # Choose playback method + resp = dialog.select("Playback method for your themes", ["Direct Play", "Direct Stream"]) + if resp == 0: + playback = "DirectPlay" + elif resp == 1: + playback = "DirectStream" + else: + return + + library = xbmc.translatePath( + "special://profile/addon_data/plugin.video.emby/library/").decode('utf-8') + # Create library directory + if not xbmcvfs.exists(library): + xbmcvfs.mkdir(library) + + # Set custom path for user + tvtunes_path = xbmc.translatePath( + "special://profile/addon_data/script.tvtunes/").decode('utf-8') + if xbmcvfs.exists(tvtunes_path): + tvtunes = xbmcaddon.Addon(id="script.tvtunes") + tvtunes.setSetting('custom_path_enable', "true") + tvtunes.setSetting('custom_path', library) + utils.logMsg("EMBY", "TV Tunes custom path is enabled and set.", 1) + else: + # if it does not exist this will not work so warn user + # often they need to edit the settings first for it to be created. + dialog.ok( + heading="Warning", + line1=( + "The settings file does not exist in tvtunes. ", + "Go to the tvtunes addon and change a setting, then come back and re-run.")) + xbmc.executebuiltin('Addon.OpenSettings(script.tvtunes)') + return + + # Get every user view Id + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + emby_db = embydb.Embydb_Functions(embycursor) + viewids = emby_db.getViews() + embycursor.close() + + # Get Ids with Theme Videos + itemIds = {} + for view in viewids: + url = "{server}/emby/Users/{UserId}/Items?HasThemeVideo=True&ParentId=%s&format=json" % view + result = doUtils.downloadUrl(url) + if result['TotalRecordCount'] != 0: + for item in result['Items']: + itemId = item['Id'] + folderName = item['Name'] + folderName = utils.normalize_string(folderName.encode('utf-8')) + itemIds[itemId] = folderName + + # Get paths for theme videos + for itemId in itemIds: + nfo_path = xbmc.translatePath( + "special://profile/addon_data/plugin.video.emby/library/%s/" % itemIds[itemId]) + # Create folders for each content + if not xbmcvfs.exists(nfo_path): + xbmcvfs.mkdir(nfo_path) + # Where to put the nfos + nfo_path = "%s%s" % (nfo_path, "tvtunes.nfo") + + url = "{server}/emby/Items/%s/ThemeVideos?format=json" % itemId + result = doUtils.downloadUrl(url) + + # Create nfo and write themes to it + nfo_file = open(nfo_path, 'w') + pathstowrite = "" + # May be more than one theme + for theme in result['Items']: + putils = playutils.PlayUtils(theme) + if playback == "DirectPlay": + playurl = putils.directPlay() + else: + playurl = putils.directStream() + pathstowrite += ('%s' % playurl.encode('utf-8')) + + # Check if the item has theme songs and add them + url = "{server}/emby/Items/%s/ThemeSongs?format=json" % itemId + result = doUtils.downloadUrl(url) + + # May be more than one theme + for theme in result['Items']: + putils = playutils.PlayUtils(theme) + if playback == "DirectPlay": + playurl = putils.directPlay() + else: + playurl = putils.directStream() + pathstowrite += ('%s' % playurl.encode('utf-8')) + + nfo_file.write( + '%s' % pathstowrite + ) + # Close nfo file + nfo_file.close() + + # Get Ids with Theme songs + musicitemIds = {} + for view in viewids: + url = "{server}/emby/Users/{UserId}/Items?HasThemeSong=True&ParentId=%s&format=json" % view + result = doUtils.downloadUrl(url) + if result['TotalRecordCount'] != 0: + for item in result['Items']: + itemId = item['Id'] + folderName = item['Name'] + folderName = utils.normalize_string(folderName.encode('utf-8')) + musicitemIds[itemId] = folderName + + # Get paths + for itemId in musicitemIds: + + # if the item was already processed with video themes back out + if itemId in itemIds: + continue + + nfo_path = xbmc.translatePath( + "special://profile/addon_data/plugin.video.emby/library/%s/" % musicitemIds[itemId]) + # Create folders for each content + if not xbmcvfs.exists(nfo_path): + xbmcvfs.mkdir(nfo_path) + # Where to put the nfos + nfo_path = "%s%s" % (nfo_path, "tvtunes.nfo") + + url = "{server}/emby/Items/%s/ThemeSongs?format=json" % itemId + result = doUtils.downloadUrl(url) + + # Create nfo and write themes to it + nfo_file = open(nfo_path, 'w') + pathstowrite = "" + # May be more than one theme + for theme in result['Items']: + putils = playutils.PlayUtils(theme) + if playback == "DirectPlay": + playurl = putils.directPlay() + else: + playurl = putils.directStream() + pathstowrite += ('%s' % playurl.encode('utf-8')) + + nfo_file.write( + '%s' % pathstowrite + ) + # Close nfo file + nfo_file.close() + + dialog.notification( + heading="Emby for Kodi", + message="Themes added!", + icon="special://home/addons/plugin.video.emby/icon.png", + time=1000, + sound=False) + +##### BROWSE EMBY CHANNELS ##### +def BrowseChannels(itemid, folderid=None): + + _addon_id = int(sys.argv[1]) + _addon_url = sys.argv[0] + doUtils = downloadutils.DownloadUtils() + art = artwork.Artwork() + + xbmcplugin.setContent(int(sys.argv[1]), 'files') + if folderid: + url = ( + "{server}/emby/Channels/%s/Items?userid={UserId}&folderid=%s&format=json" + % (itemid, folderid)) + elif itemid == "0": + # id 0 is the root channels folder + url = "{server}/emby/Channels?{UserId}&format=json" + else: + url = "{server}/emby/Channels/%s/Items?UserId={UserId}&format=json" % itemid + + result = doUtils.downloadUrl(url) + try: + channels = result['Items'] + except TypeError: + pass + else: + for item in channels: + + API = api.API(item) + itemid = item['Id'] + itemtype = item['Type'] + title = item.get('Name', "Missing Title") + li = xbmcgui.ListItem(title) + + if itemtype == "ChannelFolderItem": + isFolder = True + else: + isFolder = False + + channelId = item.get('ChannelId', "") + channelName = item.get('ChannelName', "") + + premieredate = API.getPremiereDate() + # Process Genres + genre = API.getGenres() + # Process UserData + overlay = 0 + + userdata = API.getUserData() + seektime = userdata['Resume'] + played = userdata['Played'] + if played: + overlay = 7 + else: + overlay = 6 + + favorite = userdata['Favorite'] + if favorite: + overlay = 5 + + playcount = userdata['PlayCount'] + if playcount is None: + playcount = 0 + + # Populate the details list + details = { + + 'title': title, + 'channelname': channelName, + 'plot': API.getOverview(), + 'Overlay': str(overlay), + 'playcount': str(playcount) + } + + if itemtype == "ChannelVideoItem": + xbmcplugin.setContent(_addon_id, 'movies') + elif itemtype == "ChannelAudioItem": + xbmcplugin.setContent(_addon_id, 'songs') + + # Populate the extradata list and artwork + pbutils.PlaybackUtils(item).setArtwork(li) + extradata = { + + 'id': itemid, + 'rating': item.get('CommunityRating'), + 'year': item.get('ProductionYear'), + 'premieredate': premieredate, + 'genre': genre, + 'playcount': str(playcount), + 'itemtype': itemtype + } + li.setInfo('video', infoLabels=extradata) + li.setThumbnailImage(art.getAllArtwork(item)['Primary']) + li.setIconImage('DefaultTVShows.png') + + if itemtype == "Channel": + path = "%s?id=%s&mode=channels" % (_addon_url, itemid) + xbmcplugin.addDirectoryItem(handle=_addon_id, url=path, listitem=li, isFolder=True) + + elif isFolder: + path = "%s?id=%s&mode=channelsfolder&folderid=%s" % (_addon_url, channelId, itemid) + xbmcplugin.addDirectoryItem(handle=_addon_id, url=path, listitem=li, isFolder=True) + else: + path = "%s?id=%s&mode=play" % (_addon_url, itemid) + li.setProperty('IsPlayable', 'true') + xbmcplugin.addDirectoryItem(handle=_addon_id, url=path, listitem=li) + + xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) + +##### LISTITEM SETUP FOR VIDEONODES ##### +def createListItem(item): + + title = item['title'] + li = xbmcgui.ListItem(title) + li.setProperty('IsPlayable', "true") + + metadata = { + + 'Title': title, + 'duration': str(item['runtime']/60), + 'Plot': item['plot'], + 'Playcount': item['playcount'] + } + + if "episode" in item: + episode = item['episode'] + metadata['Episode'] = episode + + if "season" in item: + season = item['season'] + metadata['Season'] = season + + if season and episode: + li.setProperty('episodeno', "s%.2de%.2d" % (season, episode)) + + if "firstaired" in item: + metadata['Premiered'] = item['firstaired'] + + if "showtitle" in item: + metadata['TVshowTitle'] = item['showtitle'] + + if "rating" in item: + metadata['Rating'] = str(round(float(item['rating']),1)) + + if "director" in item: + metadata['Director'] = " / ".join(item['director']) + + if "writer" in item: + metadata['Writer'] = " / ".join(item['writer']) + + if "cast" in item: + cast = [] + castandrole = [] + for person in item['cast']: + name = person['name'] + cast.append(name) + castandrole.append((name, person['role'])) + metadata['Cast'] = cast + metadata['CastAndRole'] = castandrole + + li.setInfo(type="Video", infoLabels=metadata) + li.setProperty('resumetime', str(item['resume']['position'])) + li.setProperty('totaltime', str(item['resume']['total'])) + li.setArt(item['art']) + li.setThumbnailImage(item['art'].get('thumb','')) + li.setIconImage('DefaultTVShows.png') + li.setProperty('dbid', str(item['episodeid'])) + li.setProperty('fanart_image', item['art'].get('tvshow.fanart','')) + for key, value in item['streamdetails'].iteritems(): + for stream in value: + li.addStreamInfo(key, stream) + + return li + +##### GET NEXTUP EPISODES FOR TAGNAME ##### +def getNextUpEpisodes(tagname, limit): + + count = 0 + # if the addon is called with nextup parameter, + # we return the nextepisodes list of the given tagname + xbmcplugin.setContent(int(sys.argv[1]), 'episodes') + # First we get a list of all the TV shows - filtered by tag + query = { + + 'jsonrpc': "2.0", + 'id': "libTvShows", + 'method': "VideoLibrary.GetTVShows", + 'params': { + + 'sort': {'order': "descending", 'method': "lastplayed"}, + 'filter': { + 'and': [ + {'operator': "true", 'field': "inprogress", 'value': ""}, + {'operator': "contains", 'field': "tag", 'value': "%s" % tagname} + ]}, + 'properties': ['title', 'studio', 'mpaa', 'file', 'art'] + } + } + result = xbmc.executeJSONRPC(json.dumps(query)) + result = json.loads(result) + # If we found any, find the oldest unwatched show for each one. + try: + items = result['result']['tvshows'] + except (KeyError, TypeError): + pass + else: + for item in items: + if utils.settings('ignoreSpecialsNextEpisodes') == "true": + query = { + + 'jsonrpc': "2.0", + 'id': 1, + 'method': "VideoLibrary.GetEpisodes", + 'params': { + + 'tvshowid': item['tvshowid'], + 'sort': {'method': "episode"}, + 'filter': { + 'and': [ + {'operator': "lessthan", 'field': "playcount", 'value': "1"}, + {'operator': "greaterthan", 'field': "season", 'value': "0"} + ]}, + 'properties': [ + "title", "playcount", "season", "episode", "showtitle", + "plot", "file", "rating", "resume", "tvshowid", "art", + "streamdetails", "firstaired", "runtime", "writer", + "dateadded", "lastplayed" + ], + 'limits': {"end": 1} + } + } + else: + query = { + + 'jsonrpc': "2.0", + 'id': 1, + 'method': "VideoLibrary.GetEpisodes", + 'params': { + + 'tvshowid': item['tvshowid'], + 'sort': {'method': "episode"}, + 'filter': {'operator': "lessthan", 'field': "playcount", 'value': "1"}, + 'properties': [ + "title", "playcount", "season", "episode", "showtitle", + "plot", "file", "rating", "resume", "tvshowid", "art", + "streamdetails", "firstaired", "runtime", "writer", + "dateadded", "lastplayed" + ], + 'limits': {"end": 1} + } + } + + result = xbmc.executeJSONRPC(json.dumps(query)) + result = json.loads(result) + try: + episodes = result['result']['episodes'] + except (KeyError, TypeError): + pass + else: + for episode in episodes: + li = createListItem(episode) + xbmcplugin.addDirectoryItem( + handle=int(sys.argv[1]), + url=item['file'], + listitem=li) + count += 1 + + if count == limit: + break + + xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) + +##### GET INPROGRESS EPISODES FOR TAGNAME ##### +def getInProgressEpisodes(tagname, limit): + + count = 0 + # if the addon is called with inprogressepisodes parameter, + # we return the inprogressepisodes list of the given tagname + xbmcplugin.setContent(int(sys.argv[1]), 'episodes') + # First we get a list of all the in-progress TV shows - filtered by tag + query = { + + 'jsonrpc': "2.0", + 'id': "libTvShows", + 'method': "VideoLibrary.GetTVShows", + 'params': { + + 'sort': {'order': "descending", 'method': "lastplayed"}, + 'filter': { + 'and': [ + {'operator': "true", 'field': "inprogress", 'value': ""}, + {'operator': "contains", 'field': "tag", 'value': "%s" % tagname} + ]}, + 'properties': ['title', 'studio', 'mpaa', 'file', 'art'] + } + } + result = xbmc.executeJSONRPC(json.dumps(query)) + result = json.loads(result) + # If we found any, find the oldest unwatched show for each one. + try: + items = result['result']['tvshows'] + except (KeyError, TypeError): + pass + else: + for item in items: + query = { + + 'jsonrpc': "2.0", + 'id': 1, + 'method': "VideoLibrary.GetEpisodes", + 'params': { + + 'tvshowid': item['tvshowid'], + 'sort': {'method': "episode"}, + 'filter': {'operator': "true", 'field': "inprogress", 'value': ""}, + 'properties': [ + "title", "playcount", "season", "episode", "showtitle", "plot", + "file", "rating", "resume", "tvshowid", "art", "cast", + "streamdetails", "firstaired", "runtime", "writer", + "dateadded", "lastplayed" + ] + } + } + result = xbmc.executeJSONRPC(json.dumps(query)) + result = json.loads(result) + try: + episodes = result['result']['episodes'] + except (KeyError, TypeError): + pass + else: + for episode in episodes: + li = createListItem(episode) + xbmcplugin.addDirectoryItem( + handle=int(sys.argv[1]), + url=item['file'], + listitem=li) + count += 1 + + if count == limit: + break + + xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) + +##### GET RECENT EPISODES FOR TAGNAME ##### +def getRecentEpisodes(tagname, limit): + + count = 0 + # if the addon is called with recentepisodes parameter, + # we return the recentepisodes list of the given tagname + xbmcplugin.setContent(int(sys.argv[1]), 'episodes') + # First we get a list of all the TV shows - filtered by tag + query = { + + 'jsonrpc': "2.0", + 'id': "libTvShows", + 'method': "VideoLibrary.GetTVShows", + 'params': { + + 'sort': {'order': "descending", 'method': "dateadded"}, + 'filter': {'operator': "contains", 'field': "tag", 'value': "%s" % tagname}, + 'properties': ["title","sorttitle"] + } + } + result = xbmc.executeJSONRPC(json.dumps(query)) + result = json.loads(result) + # If we found any, find the oldest unwatched show for each one. + try: + items = result['result']['tvshows'] + except (KeyError, TypeError): + pass + else: + allshowsIds = set() + for item in items: + allshowsIds.add(item['tvshowid']) + + query = { + + 'jsonrpc': "2.0", + 'id': 1, + 'method': "VideoLibrary.GetEpisodes", + 'params': { + + 'sort': {'order': "descending", 'method': "dateadded"}, + 'filter': {'operator': "lessthan", 'field': "playcount", 'value': "1"}, + 'properties': [ + "title", "playcount", "season", "episode", "showtitle", "plot", + "file", "rating", "resume", "tvshowid", "art", "streamdetails", + "firstaired", "runtime", "cast", "writer", "dateadded", "lastplayed" + ], + "limits": {"end": limit} + } + } + result = xbmc.executeJSONRPC(json.dumps(query)) + result = json.loads(result) + try: + episodes = result['result']['episodes'] + except (KeyError, TypeError): + pass + else: + for episode in episodes: + if episode['tvshowid'] in allshowsIds: + li = createListItem(episode) + xbmcplugin.addDirectoryItem( + handle=int(sys.argv[1]), + url=item['file'], + listitem=li) + count += 1 + + if count == limit: + break + + xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) + +##### GET EXTRAFANART FOR LISTITEM ##### +def getExtraFanArt(): + + emby = embyserver.Read_EmbyServer() + art = artwork.Artwork() + + # Get extrafanart for listitem + # this will only be used for skins that actually call the listitem's path + fanart dir... + try: + # Only do this if the listitem has actually changed + itemPath = xbmc.getInfoLabel("ListItem.FileNameAndPath") + + if not itemPath: + itemPath = xbmc.getInfoLabel("ListItem.Path") + + if any([x in itemPath for x in ['tvshows', 'musicvideos', 'movies']]): + params = urlparse.parse_qs(itemPath) + embyId = params['id'][0] + + utils.logMsg("EMBY", "Requesting extrafanart for Id: %s" % embyId, 1) + + # We need to store the images locally for this to work + # because of the caching system in xbmc + fanartDir = xbmc.translatePath("special://thumbnails/emby/%s/" % embyId).decode('utf-8') + + if not xbmcvfs.exists(fanartDir): + # Download the images to the cache directory + xbmcvfs.mkdirs(fanartDir) + item = emby.getItem(embyId) + if item: + backdrops = art.getAllArtwork(item)['Backdrop'] + tags = item['BackdropImageTags'] + count = 0 + for backdrop in backdrops: + # Same ordering as in artwork + tag = tags[count] + fanartFile = os.path.join(fanartDir, "fanart%s.jpg" % tag) + li = xbmcgui.ListItem(tag, path=fanartFile) + xbmcplugin.addDirectoryItem( + handle=int(sys.argv[1]), + url=fanartFile, + listitem=li) + xbmcvfs.copy(backdrop, fanartFile) + count += 1 + else: + utils.logMsg("EMBY", "Found cached backdrop.", 2) + # Use existing cached images + dirs, files = xbmcvfs.listdir(fanartDir) + for file in files: + fanartFile = os.path.join(fanartDir, file) + li = xbmcgui.ListItem(file, path=fanartFile) + xbmcplugin.addDirectoryItem( + handle=int(sys.argv[1]), + url=fanartFile, + listitem=li) + except Exception as e: + utils.logMsg("EMBY", "Error getting extrafanart: %s" % e, 1) + + # Always do endofdirectory to prevent errors in the logs + xbmcplugin.endOfDirectory(int(sys.argv[1])) \ No newline at end of file diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py new file mode 100644 index 00000000..1b3f7862 --- /dev/null +++ b/resources/lib/kodimonitor.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import json + +import xbmc +import xbmcgui + +import clientinfo +import downloadutils +import embydb_functions as embydb +import playbackutils as pbutils +import utils + +################################################################################################# + + +class KodiMonitor(xbmc.Monitor): + + + def __init__(self): + + self.clientInfo = clientinfo.ClientInfo() + self.addonName = self.clientInfo.getAddonName() + self.doUtils = downloadutils.DownloadUtils() + + self.logMsg("Kodi monitor started.", 1) + + def logMsg(self, msg, lvl=1): + + self.className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, self.className), msg, lvl) + + + def onScanStarted(self, library): + self.logMsg("Kodi library scan %s running." % library, 2) + if library == "video": + utils.window('emby_kodiScan', value="true") + + def onScanFinished(self, library): + self.logMsg("Kodi library scan %s finished." % library, 2) + if library == "video": + utils.window('emby_kodiScan', clear=True) + + def onNotification(self, sender, method, data): + + doUtils = self.doUtils + if method not in ("Playlist.OnAdd"): + self.logMsg("Method: %s Data: %s" % (method, data), 1) + + if data: + data = json.loads(data) + + + if method == "Player.OnPlay": + # Set up report progress for emby playback + item = data.get('item') + try: + kodiid = item['id'] + type = item['type'] + except (KeyError, TypeError): + self.logMsg("Properties already set for item.", 1) + else: + if ((utils.settings('useDirectPaths') == "1" and not type == "song") or + (type == "song" and utils.settings('disableMusic') == "false")): + # Set up properties for player + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + emby_db = embydb.Embydb_Functions(embycursor) + emby_dbitem = emby_db.getItem_byKodiId(kodiid, type) + try: + itemid = emby_dbitem[0] + except TypeError: + self.logMsg("No kodiid returned.", 1) + else: + url = "{server}/emby/Users/{UserId}/Items/%s?format=json" % itemid + result = doUtils.downloadUrl(url) + self.logMsg("Item: %s" % result, 2) + + playurl = None + count = 0 + while not playurl and count < 2: + try: + playurl = xbmc.Player().getPlayingFile() + except RuntimeError: + count += 1 + xbmc.sleep(200) + else: + listItem = xbmcgui.ListItem() + playback = pbutils.PlaybackUtils(result) + + if type == "song" and utils.settings('streamMusic') == "true": + utils.window('emby_%s.playmethod' % playurl, + value="DirectStream") + else: + utils.window('emby_%s.playmethod' % playurl, + value="DirectPlay") + # Set properties for player.py + playback.setProperties(playurl, listItem) + finally: + embycursor.close() + + + elif method == "VideoLibrary.OnUpdate": + # Manually marking as watched/unwatched + playcount = data.get('playcount') + item = data.get('item') + try: + kodiid = item['id'] + type = item['type'] + except (KeyError, TypeError): + self.logMsg("Item is invalid for playstate update.", 1) + else: + # Send notification to the server. + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + emby_db = embydb.Embydb_Functions(embycursor) + emby_dbitem = emby_db.getItem_byKodiId(kodiid, type) + try: + itemid = emby_dbitem[0] + except TypeError: + self.logMsg("Could not find itemid in emby database.", 1) + else: + # Stop from manually marking as watched unwatched, with actual playback. + if utils.window('emby_skipWatched%s' % itemid) == "true": + # property is set in player.py + utils.window('emby_skipWatched%s' % itemid, clear=True) + else: + # notify the server + url = "{server}/emby/Users/{UserId}/PlayedItems/%s?format=json" % itemid + if playcount != 0: + doUtils.downloadUrl(url, type="POST") + self.logMsg("Mark as watched for itemid: %s" % itemid, 1) + else: + doUtils.downloadUrl(url, type="DELETE") + self.logMsg("Mark as unwatched for itemid: %s" % itemid, 1) + finally: + embycursor.close() + + + elif method == "VideoLibrary.OnRemove": + + try: + kodiid = data['id'] + type = data['type'] + except (KeyError, TypeError): + self.logMsg("Item is invalid for emby deletion.", 1) + else: + # Send the delete action to the server. + offerDelete = False + + if type == "episode" and utils.settings('deleteTV') == "true": + offerDelete = True + elif type == "movie" and utils.settings('deleteMovies') == "true": + offerDelete = True + + if utils.settings('offerDelete') != "true": + # Delete could be disabled, even if the subsetting is enabled. + offerDelete = False + + if offerDelete: + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + emby_db = embydb.Embydb_Functions(embycursor) + emby_dbitem = emby_db.getItem_byKodiId(kodiid, type) + try: + itemid = emby_dbitem[0] + except TypeError: + self.logMsg("Could not find itemid in emby database.", 1) + else: + if utils.settings('skipConfirmDelete') != "true": + resp = xbmcgui.Dialog().yesno( + heading="Confirm delete", + line1="Delete file on Emby Server?") + if not resp: + self.logMsg("User skipped deletion.", 1) + embycursor.close() + return + url = "{server}/emby/Items/%s?format=json" % itemid + self.logMsg("Deleting request: %s" % itemid) + doUtils.downloadUrl(url, type="DELETE") + finally: + embycursor.close() + + + elif method == "System.OnWake": + # Allow network to wake up + xbmc.sleep(10000) + utils.window('emby_onWake', value="true") + + elif method == "Playlist.OnClear": + utils.window('emby_customPlaylist', clear=True, windowid=10101) + #xbmcgui.Window(10101).clearProperties() + self.logMsg("Clear playlist properties.") \ No newline at end of file diff --git a/resources/lib/librarysync.py b/resources/lib/librarysync.py new file mode 100644 index 00000000..d6d15d8e --- /dev/null +++ b/resources/lib/librarysync.py @@ -0,0 +1,1338 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import sqlite3 +import threading +from datetime import datetime, timedelta, time + +import xbmc +import xbmcgui +import xbmcvfs + +import api +import utils +import clientinfo +import downloadutils +import itemtypes +import embydb_functions as embydb +import kodidb_functions as kodidb +import read_embyserver as embyserver +import userclient +import videonodes + +################################################################################################## + + +class LibrarySync(threading.Thread): + + _shared_state = {} + + stop_thread = False + suspend_thread = False + + # Track websocketclient updates + addedItems = [] + updateItems = [] + userdataItems = [] + removeItems = [] + forceLibraryUpdate = False + refresh_views = False + + + def __init__(self): + + self.__dict__ = self._shared_state + self.monitor = xbmc.Monitor() + + self.clientInfo = clientinfo.ClientInfo() + self.addonName = self.clientInfo.getAddonName() + self.doUtils = downloadutils.DownloadUtils() + self.user = userclient.UserClient() + self.emby = embyserver.Read_EmbyServer() + self.vnodes = videonodes.VideoNodes() + + threading.Thread.__init__(self) + + def logMsg(self, msg, lvl=1): + + className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, className), msg, lvl) + + + def progressDialog(self, title, forced=False): + + dialog = None + + if utils.settings('dbSyncIndicator') == "true" or forced: + dialog = xbmcgui.DialogProgressBG() + dialog.create("Emby for Kodi", title) + self.logMsg("Show progress dialog: %s" % title, 2) + + return dialog + + def startSync(self): + # Run at start up - optional to use the server plugin + if utils.settings('SyncInstallRunDone') == "true": + + # Validate views + self.refreshViews() + completed = False + # Verify if server plugin is installed. + if utils.settings('serverSync') == "true": + # Try to use fast start up + url = "{server}/emby/Plugins?format=json" + result = self.doUtils.downloadUrl(url) + + for plugin in result: + if plugin['Name'] == "Emby.Kodi Sync Queue": + self.logMsg("Found server plugin.", 2) + completed = self.fastSync() + + if not completed: + # Fast sync failed or server plugin is not found + completed = self.fullSync(manualrun=True) + else: + # Install sync is not completed + completed = self.fullSync() + + return completed + + def fastSync(self): + + lastSync = utils.settings('LastIncrementalSync') + if not lastSync: + lastSync = "2010-01-01T00:00:00Z" + self.logMsg("Last sync run: %s" % lastSync, 1) + + url = "{server}/emby/Emby.Kodi.SyncQueue/{UserId}/GetItems?format=json" + params = {'LastUpdateDT': lastSync} + result = self.doUtils.downloadUrl(url, parameters=params) + + try: + processlist = { + + 'added': result['ItemsAdded'], + 'update': result['ItemsUpdated'], + 'userdata': result['UserDataChanged'], + 'remove': result['ItemsRemoved'] + } + + except (KeyError, TypeError): + self.logMsg("Failed to retrieve latest updates using fast sync.", 1) + return False + + else: + self.logMsg("Fast sync changes: %s" % result, 1) + for action in processlist: + self.triage_items(action, processlist[action]) + + return True + + def saveLastSync(self): + # Save last sync time + overlap = 2 + + url = "{server}/Emby.Kodi.SyncQueue/GetServerDateTime?format=json" + result = self.doUtils.downloadUrl(url) + try: # datetime fails when used more than once, TypeError + server_time = result['ServerDateTime'] + server_time = datetime.strptime(server_time, "%Y-%m-%dT%H:%M:%SZ") + + except Exception as e: + # If the server plugin is not installed or an error happened. + self.logMsg("An exception occurred: %s" % e, 1) + time_now = datetime.utcnow()-timedelta(minutes=overlap) + lastSync = time_now.strftime('%Y-%m-%dT%H:%M:%SZ') + self.logMsg("New sync time: client time -%s min: %s" % (overlap, lastSync), 1) + + else: + lastSync = (server_time - timedelta(minutes=overlap)).strftime('%Y-%m-%dT%H:%M:%SZ') + self.logMsg("New sync time: server time -%s min: %s" % (overlap, lastSync), 1) + + finally: + utils.settings('LastIncrementalSync', value=lastSync) + + def shouldStop(self): + # Checkpoint during the syncing process + if self.monitor.abortRequested(): + return True + elif utils.window('emby_shouldStop') == "true": + return True + else: # Keep going + return False + + def dbCommit(self, connection): + # Central commit, verifies if Kodi database update is running + kodidb_scan = utils.window('emby_kodiScan') == "true" + + while kodidb_scan: + + self.logMsg("Kodi scan is running. Waiting...", 1) + kodidb_scan = utils.window('emby_kodiScan') == "true" + + if self.shouldStop(): + self.logMsg("Commit unsuccessful. Sync terminated.", 1) + break + + if self.monitor.waitForAbort(1): + # Abort was requested while waiting. We should exit + self.logMsg("Commit unsuccessful.", 1) + break + else: + connection.commit() + self.logMsg("Commit successful.", 1) + + def fullSync(self, manualrun=False, repair=False): + # Only run once when first setting up. Can be run manually. + emby = self.emby + music_enabled = utils.settings('enableMusic') == "true" + + utils.window('emby_dbScan', value="true") + # Add sources + utils.sourcesXML() + + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + # Create the tables for the emby database + # emby, view, version + embycursor.execute( + """CREATE TABLE IF NOT EXISTS emby( + emby_id TEXT UNIQUE, media_folder TEXT, emby_type TEXT, media_type TEXT, kodi_id INTEGER, + kodi_fileid INTEGER, kodi_pathid INTEGER, parent_id INTEGER, checksum INTEGER)""") + embycursor.execute( + """CREATE TABLE IF NOT EXISTS view( + view_id TEXT UNIQUE, view_name TEXT, media_type TEXT, kodi_tagid INTEGER)""") + embycursor.execute("CREATE TABLE IF NOT EXISTS version(idVersion TEXT)") + embyconn.commit() + + # content sync: movies, tvshows, musicvideos, music + kodiconn = utils.kodiSQL('video') + kodicursor = kodiconn.cursor() + + if manualrun: + message = "Manual sync" + elif repair: + message = "Repair sync" + else: + message = "Initial sync" + + pDialog = self.progressDialog("%s" % message, forced=True) + starttotal = datetime.now() + + # Set views + self.maintainViews(embycursor, kodicursor) + embyconn.commit() + + # Sync video library + process = { + + 'movies': self.movies, + 'musicvideos': self.musicvideos, + 'tvshows': self.tvshows, + 'homevideos': self.homevideos + } + for itemtype in process: + startTime = datetime.now() + completed = process[itemtype](embycursor, kodicursor, pDialog, compare=manualrun) + if not completed: + + utils.window('emby_dbScan', clear=True) + if pDialog: + pDialog.close() + + embycursor.close() + kodicursor.close() + return False + else: + self.dbCommit(kodiconn) + embyconn.commit() + elapsedTime = datetime.now() - startTime + self.logMsg( + "SyncDatabase (finished %s in: %s)" + % (itemtype, str(elapsedTime).split('.')[0]), 1) + + # sync music + if music_enabled: + + musicconn = utils.kodiSQL('music') + musiccursor = musicconn.cursor() + + startTime = datetime.now() + completed = self.music(embycursor, musiccursor, pDialog, compare=manualrun) + if not completed: + + utils.window('emby_dbScan', clear=True) + if pDialog: + pDialog.close() + + embycursor.close() + musiccursor.close() + return False + else: + musicconn.commit() + embyconn.commit() + elapsedTime = datetime.now() - startTime + self.logMsg( + "SyncDatabase (finished music in: %s)" + % (str(elapsedTime).split('.')[0]), 1) + musiccursor.close() + + if pDialog: + pDialog.close() + + embycursor.close() + kodicursor.close() + + utils.settings('SyncInstallRunDone', value="true") + utils.settings("dbCreatedWithVersion", self.clientInfo.getVersion()) + self.saveLastSync() + # tell any widgets to refresh because the content has changed + utils.window('widgetreload', value=datetime.now().strftime('%Y-%m-%d %H:%M:%S')) + xbmc.executebuiltin('UpdateLibrary(video)') + elapsedtotal = datetime.now() - starttotal + + utils.window('emby_dbScan', clear=True) + xbmcgui.Dialog().notification( + heading="Emby for Kodi", + message="%s completed in: %s!" % + (message, str(elapsedtotal).split('.')[0]), + icon="special://home/addons/plugin.video.emby/icon.png", + sound=False) + return True + + + def refreshViews(self): + + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + kodiconn = utils.kodiSQL('video') + kodicursor = kodiconn.cursor() + + # Compare views, assign correct tags to items + self.maintainViews(embycursor, kodicursor) + + self.dbCommit(kodiconn) + kodicursor.close() + + embyconn.commit() + embycursor.close() + + def maintainViews(self, embycursor, kodicursor): + # Compare the views to emby + emby_db = embydb.Embydb_Functions(embycursor) + kodi_db = kodidb.Kodidb_Functions(kodicursor) + doUtils = self.doUtils + vnodes = self.vnodes + + # Get views + url = "{server}/emby/Users/{UserId}/Views?format=json" + result = doUtils.downloadUrl(url) + grouped_views = result['Items'] + + try: + groupedFolders = self.user.userSettings['Configuration']['GroupedFolders'] + except TypeError: + url = "{server}/emby/Users/{UserId}?format=json" + result = doUtils.downloadUrl(url) + groupedFolders = result['Configuration']['GroupedFolders'] + + # total nodes for window properties + vnodes.clearProperties() + totalnodes = 0 + + # Set views for supported media type + mediatypes = ['movies', 'tvshows', 'musicvideos', 'homevideos', 'music'] + for mediatype in mediatypes: + + # Get media folders from server + folders = self.emby.getViews(mediatype, root=True) + for folder in folders: + + folderid = folder['id'] + foldername = folder['name'] + viewtype = folder['type'] + + if folderid in groupedFolders: + # Media folders are grouped into userview + for grouped_view in grouped_views: + if (grouped_view['Type'] == "UserView" and + grouped_view['CollectionType'] == mediatype): + # Take the name of the userview + foldername = grouped_view['Name'] + break + + # Get current media folders from emby database + view = emby_db.getView_byId(folderid) + try: + current_viewname = view[0] + current_viewtype = view[1] + current_tagid = view[2] + + except TypeError: + self.logMsg("Creating viewid: %s in Emby database." % folderid, 1) + tagid = kodi_db.createTag(foldername) + # Create playlist for the video library + if mediatype != "music": + utils.playlistXSP(mediatype, foldername, viewtype) + # Create the video node + if mediatype != "musicvideos": + vnodes.viewNode(totalnodes, foldername, mediatype, viewtype) + totalnodes += 1 + # Add view to emby database + emby_db.addView(folderid, foldername, viewtype, tagid) + + else: + self.logMsg(' '.join(( + + "Found viewid: %s" % folderid, + "viewname: %s" % current_viewname, + "viewtype: %s" % current_viewtype, + "tagid: %s" % current_tagid)), 2) + + # View was modified, update with latest info + if current_viewname != foldername: + self.logMsg("viewid: %s new viewname: %s" % (folderid, foldername), 1) + tagid = kodi_db.createTag(foldername) + + # Update view with new info + emby_db.updateView(foldername, tagid, folderid) + + if mediatype != "music": + if emby_db.getView_byName(current_viewname) is None: + # The tag could be a combined view. Ensure there's no other tags + # with the same name before deleting playlist. + utils.playlistXSP( + mediatype, current_viewname, current_viewtype, True) + # Delete video node + if mediatype != "musicvideos": + vnodes.viewNode( + indexnumber=totalnodes, + tagname=current_viewname, + mediatype=mediatype, + viewtype=current_viewtype, + delete=True) + # Added new playlist + utils.playlistXSP(mediatype, foldername, viewtype) + # Add new video node + if mediatype != "musicvideos": + vnodes.viewNode(totalnodes, foldername, mediatype, viewtype) + totalnodes += 1 + + # Update items with new tag + items = emby_db.getItem_byView(folderid) + for item in items: + # Remove the "s" from viewtype for tags + kodi_db.updateTag( + current_tagid, tagid, item[0], current_viewtype[:-1]) + else: + if mediatype != "music": + # Validate the playlist exists or recreate it + utils.playlistXSP(mediatype, foldername, viewtype) + # Create the video node if not already exists + if mediatype != "musicvideos": + vnodes.viewNode(totalnodes, foldername, mediatype, viewtype) + totalnodes += 1 + else: + # Add video nodes listings + vnodes.singleNode(totalnodes, "Favorite movies", "movies", "favourites") + totalnodes += 1 + vnodes.singleNode(totalnodes, "Favorite tvshows", "tvshows", "favourites") + totalnodes += 1 + vnodes.singleNode(totalnodes, "channels", "movies", "channels") + totalnodes += 1 + # Save total + utils.window('Emby.nodes.total', str(totalnodes)) + + + def movies(self, embycursor, kodicursor, pdialog, compare=False): + # Get movies from emby + emby = self.emby + emby_db = embydb.Embydb_Functions(embycursor) + movies = itemtypes.Movies(embycursor, kodicursor) + + views = emby_db.getView_byType('movies') + views += emby_db.getView_byType('mixed') + self.logMsg("Media folders: %s" % views, 1) + + if compare: + # Pull the list of movies and boxsets in Kodi + try: + all_kodimovies = dict(emby_db.getChecksum('Movie')) + except ValueError: + all_kodimovies = {} + + try: + all_kodisets = dict(emby_db.getChecksum('BoxSet')) + except ValueError: + all_kodisets = {} + + all_embymoviesIds = set() + all_embyboxsetsIds = set() + updatelist = [] + + ##### PROCESS MOVIES ##### + for view in views: + + if self.shouldStop(): + return False + + # Get items per view + viewId = view['id'] + viewName = view['name'] + + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Gathering movies from view: %s..." % viewName) + + if compare: + # Manual sync + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Comparing movies from view: %s..." % viewName) + + all_embymovies = emby.getMovies(viewId, basic=True) + for embymovie in all_embymovies['Items']: + + if self.shouldStop(): + return False + + API = api.API(embymovie) + itemid = embymovie['Id'] + all_embymoviesIds.add(itemid) + + + if all_kodimovies.get(itemid) != API.getChecksum(): + # Only update if movie is not in Kodi or checksum is different + updatelist.append(itemid) + + self.logMsg("Movies to update for %s: %s" % (viewName, updatelist), 1) + embymovies = emby.getFullItems(updatelist) + total = len(updatelist) + del updatelist[:] + else: + # Initial or repair sync + all_embymovies = emby.getMovies(viewId) + total = all_embymovies['TotalRecordCount'] + embymovies = all_embymovies['Items'] + + + if pdialog: + pdialog.update(heading="Processing %s / %s items" % (viewName, total)) + + count = 0 + for embymovie in embymovies: + # Process individual movies + if self.shouldStop(): + return False + + title = embymovie['Name'] + if pdialog: + percentage = int((float(count) / float(total))*100) + pdialog.update(percentage, message=title) + count += 1 + movies.add_update(embymovie, viewName, viewId) + else: + self.logMsg("Movies finished.", 2) + + + ##### PROCESS BOXSETS ##### + if pdialog: + pdialog.update(heading="Emby for Kodi", message="Gathering boxsets from server...") + + boxsets = emby.getBoxset() + + if compare: + # Manual sync + embyboxsets = [] + + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Comparing boxsets...") + + for boxset in boxsets['Items']: + + if self.shouldStop(): + return False + + # Boxset has no real userdata, so using etag to compare + checksum = boxset['Etag'] + itemid = boxset['Id'] + all_embyboxsetsIds.add(itemid) + + if all_kodisets.get(itemid) != checksum: + # Only update if boxset is not in Kodi or checksum is different + updatelist.append(itemid) + embyboxsets.append(boxset) + + self.logMsg("Boxsets to update: %s" % updatelist, 1) + total = len(updatelist) + else: + total = boxsets['TotalRecordCount'] + embyboxsets = boxsets['Items'] + + + if pdialog: + pdialog.update(heading="Processing Boxsets / %s items" % total) + + count = 0 + for boxset in embyboxsets: + # Process individual boxset + if self.shouldStop(): + return False + + title = boxset['Name'] + if pdialog: + percentage = int((float(count) / float(total))*100) + pdialog.update(percentage, message=title) + count += 1 + movies.add_updateBoxset(boxset) + else: + self.logMsg("Boxsets finished.", 2) + + + ##### PROCESS DELETES ##### + if compare: + # Manual sync, process deletes + for kodimovie in all_kodimovies: + if kodimovie not in all_embymoviesIds: + movies.remove(kodimovie) + else: + self.logMsg("Movies compare finished.", 1) + + for boxset in all_kodisets: + if boxset not in all_embyboxsetsIds: + movies.remove(boxset) + else: + self.logMsg("Boxsets compare finished.", 1) + + return True + + def musicvideos(self, embycursor, kodicursor, pdialog, compare=False): + # Get musicvideos from emby + emby = self.emby + emby_db = embydb.Embydb_Functions(embycursor) + mvideos = itemtypes.MusicVideos(embycursor, kodicursor) + + views = emby_db.getView_byType('musicvideos') + self.logMsg("Media folders: %s" % views, 1) + + if compare: + # Pull the list of musicvideos in Kodi + try: + all_kodimvideos = dict(emby_db.getChecksum('MusicVideo')) + except ValueError: + all_kodimvideos = {} + + all_embymvideosIds = set() + updatelist = [] + + for view in views: + + if self.shouldStop(): + return False + + # Get items per view + viewId = view['id'] + viewName = view['name'] + + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Gathering musicvideos from view: %s..." % viewName) + + if compare: + # Manual sync + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Comparing musicvideos from view: %s..." % viewName) + + all_embymvideos = emby.getMusicVideos(viewId, basic=True) + for embymvideo in all_embymvideos['Items']: + + if self.shouldStop(): + return False + + API = api.API(embymvideo) + itemid = embymvideo['Id'] + all_embymvideosIds.add(itemid) + + + if all_kodimvideos.get(itemid) != API.getChecksum(): + # Only update if musicvideo is not in Kodi or checksum is different + updatelist.append(itemid) + + self.logMsg("MusicVideos to update for %s: %s" % (viewName, updatelist), 1) + embymvideos = emby.getFullItems(updatelist) + total = len(updatelist) + del updatelist[:] + else: + # Initial or repair sync + all_embymvideos = emby.getMusicVideos(viewId) + total = all_embymvideos['TotalRecordCount'] + embymvideos = all_embymvideos['Items'] + + + if pdialog: + pdialog.update(heading="Processing %s / %s items" % (viewName, total)) + + count = 0 + for embymvideo in embymvideos: + # Process individual musicvideo + if self.shouldStop(): + return False + + title = embymvideo['Name'] + if pdialog: + percentage = int((float(count) / float(total))*100) + pdialog.update(percentage, message=title) + count += 1 + mvideos.add_update(embymvideo, viewName, viewId) + else: + self.logMsg("MusicVideos finished.", 2) + + ##### PROCESS DELETES ##### + if compare: + # Manual sync, process deletes + for kodimvideo in all_kodimvideos: + if kodimvideo not in all_embymvideosIds: + mvideos.remove(kodimvideo) + else: + self.logMsg("MusicVideos compare finished.", 1) + + return True + + def homevideos(self, embycursor, kodicursor, pdialog, compare=False): + # Get homevideos from emby + emby = self.emby + emby_db = embydb.Embydb_Functions(embycursor) + hvideos = itemtypes.HomeVideos(embycursor, kodicursor) + + views = emby_db.getView_byType('homevideos') + self.logMsg("Media folders: %s" % views, 1) + + if compare: + # Pull the list of homevideos in Kodi + try: + all_kodihvideos = dict(emby_db.getChecksum('Video')) + except ValueError: + all_kodihvideos = {} + + all_embyhvideosIds = set() + updatelist = [] + + for view in views: + + if self.shouldStop(): + return False + + # Get items per view + viewId = view['id'] + viewName = view['name'] + + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Gathering homevideos from view: %s..." % viewName) + + all_embyhvideos = emby.getHomeVideos(viewId) + + if compare: + # Manual sync + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Comparing homevideos from view: %s..." % viewName) + + for embyhvideo in all_embyhvideos['Items']: + + if self.shouldStop(): + return False + + API = api.API(embyhvideo) + itemid = embyhvideo['Id'] + all_embyhvideosIds.add(itemid) + + + if all_kodihvideos.get(itemid) != API.getChecksum(): + # Only update if homemovie is not in Kodi or checksum is different + updatelist.append(itemid) + + self.logMsg("HomeVideos to update for %s: %s" % (viewName, updatelist), 1) + embyhvideos = emby.getFullItems(updatelist) + total = len(updatelist) + del updatelist[:] + else: + total = all_embyhvideos['TotalRecordCount'] + embyhvideos = all_embyhvideos['Items'] + + if pdialog: + pdialog.update(heading="Processing %s / %s items" % (viewName, total)) + + count = 0 + for embyhvideo in embyhvideos: + # Process individual homemovies + if self.shouldStop(): + return False + + title = embyhvideo['Name'] + if pdialog: + percentage = int((float(count) / float(total))*100) + pdialog.update(percentage, message=title) + count += 1 + hvideos.add_update(embyhvideo, viewName, viewId) + else: + self.logMsg("HomeVideos finished.", 2) + + ##### PROCESS DELETES ##### + if compare: + # Manual sync, process deletes + for kodihvideo in all_kodihvideos: + if kodihvideo not in all_embyhvideosIds: + hvideos.remove(kodihvideo) + else: + self.logMsg("HomeVideos compare finished.", 1) + + return True + + def tvshows(self, embycursor, kodicursor, pdialog, compare=False): + # Get shows from emby + emby = self.emby + emby_db = embydb.Embydb_Functions(embycursor) + tvshows = itemtypes.TVShows(embycursor, kodicursor) + + views = emby_db.getView_byType('tvshows') + views += emby_db.getView_byType('mixed') + self.logMsg("Media folders: %s" % views, 1) + + if compare: + # Pull the list of movies and boxsets in Kodi + try: + all_koditvshows = dict(emby_db.getChecksum('Series')) + except ValueError: + all_koditvshows = {} + + try: + all_kodiepisodes = dict(emby_db.getChecksum('Episode')) + except ValueError: + all_kodiepisodes = {} + + all_embytvshowsIds = set() + all_embyepisodesIds = set() + updatelist = [] + + + for view in views: + + if self.shouldStop(): + return False + + # Get items per view + viewId = view['id'] + viewName = view['name'] + + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Gathering tvshows from view: %s..." % viewName) + + if compare: + # Manual sync + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Comparing tvshows from view: %s..." % viewName) + + all_embytvshows = emby.getShows(viewId, basic=True) + for embytvshow in all_embytvshows['Items']: + + if self.shouldStop(): + return False + + API = api.API(embytvshow) + itemid = embytvshow['Id'] + all_embytvshowsIds.add(itemid) + + + if all_koditvshows.get(itemid) != API.getChecksum(): + # Only update if movie is not in Kodi or checksum is different + updatelist.append(itemid) + + self.logMsg("TVShows to update for %s: %s" % (viewName, updatelist), 1) + embytvshows = emby.getFullItems(updatelist) + total = len(updatelist) + del updatelist[:] + else: + all_embytvshows = emby.getShows(viewId) + total = all_embytvshows['TotalRecordCount'] + embytvshows = all_embytvshows['Items'] + + + if pdialog: + pdialog.update(heading="Processing %s / %s items" % (viewName, total)) + + count = 0 + for embytvshow in embytvshows: + # Process individual show + if self.shouldStop(): + return False + + itemid = embytvshow['Id'] + title = embytvshow['Name'] + if pdialog: + percentage = int((float(count) / float(total))*100) + pdialog.update(percentage, message=title) + count += 1 + tvshows.add_update(embytvshow, viewName, viewId) + + if not compare: + # Process episodes + all_episodes = emby.getEpisodesbyShow(itemid) + for episode in all_episodes['Items']: + + # Process individual show + if self.shouldStop(): + return False + + episodetitle = episode['Name'] + if pdialog: + pdialog.update(percentage, message="%s - %s" % (title, episodetitle)) + tvshows.add_updateEpisode(episode) + else: + if compare: + # Get all episodes in view + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Comparing episodes from view: %s..." % viewName) + + all_embyepisodes = emby.getEpisodes(viewId, basic=True) + for embyepisode in all_embyepisodes['Items']: + + if self.shouldStop(): + return False + + API = api.API(embyepisode) + itemid = embyepisode['Id'] + all_embyepisodesIds.add(itemid) + + if all_kodiepisodes.get(itemid) != API.getChecksum(): + # Only update if movie is not in Kodi or checksum is different + updatelist.append(itemid) + + self.logMsg("Episodes to update for %s: %s" % (viewName, updatelist), 1) + embyepisodes = emby.getFullItems(updatelist) + total = len(updatelist) + del updatelist[:] + + for episode in embyepisodes: + + # Process individual episode + if self.shouldStop(): + return False + + title = episode['SeriesName'] + episodetitle = episode['Name'] + if pdialog: + pdialog.update(percentage, message="%s - %s" % (title, episodetitle)) + tvshows.add_updateEpisode(episode) + else: + self.logMsg("TVShows finished.", 2) + + ##### PROCESS DELETES ##### + if compare: + # Manual sync, process deletes + for koditvshow in all_koditvshows: + if koditvshow not in all_embytvshowsIds: + tvshows.remove(koditvshow) + else: + self.logMsg("TVShows compare finished.", 1) + + for kodiepisode in all_kodiepisodes: + if kodiepisode not in all_embyepisodesIds: + tvshows.remove(kodiepisode) + else: + self.logMsg("Episodes compare finished.", 1) + + return True + + def music(self, embycursor, kodicursor, pdialog, compare=False): + # Get music from emby + emby = self.emby + emby_db = embydb.Embydb_Functions(embycursor) + music = itemtypes.Music(embycursor, kodicursor) + + if compare: + # Pull the list of movies and boxsets in Kodi + try: + all_kodiartists = dict(emby_db.getChecksum('MusicArtist')) + except ValueError: + all_kodiartists = {} + + try: + all_kodialbums = dict(emby_db.getChecksum('MusicAlbum')) + except ValueError: + all_kodialbums = {} + + try: + all_kodisongs = dict(emby_db.getChecksum('Audio')) + except ValueError: + all_kodisongs = {} + + all_embyartistsIds = set() + all_embyalbumsIds = set() + all_embysongsIds = set() + updatelist = [] + + process = { + + 'artists': [emby.getArtists, music.add_updateArtist], + 'albums': [emby.getAlbums, music.add_updateAlbum], + 'songs': [emby.getSongs, music.add_updateSong] + } + types = ['artists', 'albums', 'songs'] + for type in types: + + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Gathering %s..." % type) + + if compare: + # Manual Sync + if pdialog: + pdialog.update( + heading="Emby for Kodi", + message="Comparing %s..." % type) + + if type != "artists": + all_embyitems = process[type][0](basic=True) + else: + all_embyitems = process[type][0]() + for embyitem in all_embyitems['Items']: + + if self.shouldStop(): + return False + + API = api.API(embyitem) + itemid = embyitem['Id'] + if type == "artists": + all_embyartistsIds.add(itemid) + if all_kodiartists.get(itemid) != API.getChecksum(): + # Only update if artist is not in Kodi or checksum is different + updatelist.append(itemid) + elif type == "albums": + all_embyalbumsIds.add(itemid) + if all_kodialbums.get(itemid) != API.getChecksum(): + # Only update if album is not in Kodi or checksum is different + updatelist.append(itemid) + else: + all_embysongsIds.add(itemid) + if all_kodisongs.get(itemid) != API.getChecksum(): + # Only update if songs is not in Kodi or checksum is different + updatelist.append(itemid) + + self.logMsg("%s to update: %s" % (type, updatelist), 1) + embyitems = emby.getFullItems(updatelist) + total = len(updatelist) + del updatelist[:] + else: + all_embyitems = process[type][0]() + total = all_embyitems['TotalRecordCount'] + embyitems = all_embyitems['Items'] + + if pdialog: + pdialog.update(heading="Processing %s / %s items" % (type, total)) + + count = 0 + for embyitem in embyitems: + # Process individual item + if self.shouldStop(): + return False + + title = embyitem['Name'] + if pdialog: + percentage = int((float(count) / float(total))*100) + pdialog.update(percentage, message=title) + count += 1 + + process[type][1](embyitem) + else: + self.logMsg("%s finished." % type, 2) + + ##### PROCESS DELETES ##### + if compare: + # Manual sync, process deletes + for kodiartist in all_kodiartists: + if kodiartist not in all_embyartistsIds and all_kodiartists[kodiartist] is not None: + music.remove(kodiartist) + else: + self.logMsg("Artist compare finished.", 1) + + for kodialbum in all_kodialbums: + if kodialbum not in all_embyalbumsIds: + music.remove(kodialbum) + else: + self.logMsg("Albums compare finished.", 1) + + for kodisong in all_kodisongs: + if kodisong not in all_embysongsIds: + music.remove(kodisong) + else: + self.logMsg("Songs compare finished.", 1) + + return True + + # Reserved for websocket_client.py and fast start + def triage_items(self, process, items): + + processlist = { + + 'added': self.addedItems, + 'update': self.updateItems, + 'userdata': self.userdataItems, + 'remove': self.removeItems + } + if items: + if process == "userdata": + itemids = [] + for item in items: + itemids.append(item['ItemId']) + items = itemids + + self.logMsg("Queue %s: %s" % (process, items), 1) + processlist[process].extend(items) + + def incrementalSync(self): + + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + kodiconn = utils.kodiSQL('video') + kodicursor = kodiconn.cursor() + emby = self.emby + emby_db = embydb.Embydb_Functions(embycursor) + pDialog = None + + if self.refresh_views: + # Received userconfig update + self.refresh_views = False + self.maintainViews(embycursor, kodicursor) + self.forceLibraryUpdate = True + + if self.addedItems or self.updateItems or self.userdataItems or self.removeItems: + # Only present dialog if we are going to process items + pDialog = self.progressDialog('Incremental sync') + + + process = { + + 'added': self.addedItems, + 'update': self.updateItems, + 'userdata': self.userdataItems, + 'remove': self.removeItems + } + types = ['added', 'update', 'userdata', 'remove'] + for type in types: + + if process[type] and utils.window('emby_kodiScan') != "true": + + listItems = list(process[type]) + del process[type][:] # Reset class list + + items_process = itemtypes.Items(embycursor, kodicursor) + update = False + + # Prepare items according to process type + if type == "added": + items = emby.sortby_mediatype(listItems) + + elif type in ("userdata", "remove"): + items = emby_db.sortby_mediaType(listItems, unsorted=False) + + else: + items = emby_db.sortby_mediaType(listItems) + if items.get('Unsorted'): + sorted_items = emby.sortby_mediatype(items['Unsorted']) + doupdate = items_process.itemsbyId(sorted_items, "added", pDialog) + if doupdate: + update = True + del items['Unsorted'] + + doupdate = items_process.itemsbyId(items, type, pDialog) + if doupdate: + update = True + + if update: + self.forceLibraryUpdate = True + + + if self.forceLibraryUpdate: + # Force update the Kodi library + self.forceLibraryUpdate = False + self.dbCommit(kodiconn) + embyconn.commit() + self.saveLastSync() + + # tell any widgets to refresh because the content has changed + utils.window('widgetreload', value=datetime.now().strftime('%Y-%m-%d %H:%M:%S')) + + self.logMsg("Updating video library.", 1) + utils.window('emby_kodiScan', value="true") + xbmc.executebuiltin('UpdateLibrary(video)') + + if pDialog: + pDialog.close() + + kodicursor.close() + embycursor.close() + + + def compareDBVersion(self, current, minimum): + # It returns True is database is up to date. False otherwise. + self.logMsg("current: %s minimum: %s" % (current, minimum), 1) + currMajor, currMinor, currPatch = current.split(".") + minMajor, minMinor, minPatch = minimum.split(".") + + if currMajor > minMajor: + return True + elif currMajor == minMajor and (currMinor > minMinor or + (currMinor == minMinor and currPatch >= minPatch)): + return True + else: + # Database out of date. + return False + + def run(self): + + try: + self.run_internal() + except Exception as e: + xbmcgui.Dialog().ok( + heading="Emby for Kodi", + line1=( + "Library sync thread has exited! " + "You should restart Kodi now. " + "Please report this on the forum.")) + raise + + def run_internal(self): + + startupComplete = False + monitor = self.monitor + + self.logMsg("---===### Starting LibrarySync ###===---", 0) + + while not monitor.abortRequested(): + + # In the event the server goes offline + while self.suspend_thread: + # Set in service.py + if monitor.waitForAbort(5): + # Abort was requested while waiting. We should exit + break + + if (utils.window('emby_dbCheck') != "true" and + utils.settings('SyncInstallRunDone') == "true"): + + # Verify the validity of the database + currentVersion = utils.settings('dbCreatedWithVersion') + minVersion = utils.window('emby_minDBVersion') + uptoDate = self.compareDBVersion(currentVersion, minVersion) + + if not uptoDate: + self.logMsg( + "Db version out of date: %s minimum version required: %s" + % (currentVersion, minVersion), 0) + + resp = xbmcgui.Dialog().yesno( + heading="Db Version", + line1=( + "Detected the database needs to be " + "recreated for this version of Emby for Kodi. " + "Proceed?")) + if not resp: + self.logMsg("Db version out of date! USER IGNORED!", 0) + xbmcgui.Dialog().ok( + heading="Emby for Kodi", + line1=( + "Emby for Kodi may not work correctly " + "until the database is reset.")) + else: + utils.reset() + + utils.window('emby_dbCheck', value="true") + + + if not startupComplete: + # Verify the video database can be found + videoDb = utils.getKodiVideoDBPath() + if not xbmcvfs.exists(videoDb): + # Database does not exists + self.logMsg( + "The current Kodi version is incompatible " + "with the Emby for Kodi add-on. Please visit " + "https://github.com/MediaBrowser/Emby.Kodi/wiki " + "to know which Kodi versions are supported.", 0) + + xbmcgui.Dialog().ok( + heading="Emby Warning", + line1=( + "Cancelling the database syncing process. " + "Current Kodi versoin: %s is unsupported. " + "Please verify your logs for more info." + % xbmc.getInfoLabel('System.BuildVersion'))) + break + + # Run start up sync + self.logMsg("Db version: %s" % utils.settings('dbCreatedWithVersion'), 0) + self.logMsg("SyncDatabase (started)", 1) + startTime = datetime.now() + librarySync = self.startSync() + elapsedTime = datetime.now() - startTime + self.logMsg( + "SyncDatabase (finished in: %s) %s" + % (str(elapsedTime).split('.')[0], librarySync), 1) + # Only try the initial sync once per kodi session regardless + # This will prevent an infinite loop in case something goes wrong. + startupComplete = True + + # Process updates + if utils.window('emby_dbScan') != "true": + self.incrementalSync() + + if (utils.window('emby_onWake') == "true" and + utils.window('emby_online') == "true"): + # Kodi is waking up + # Set in kodimonitor.py + utils.window('emby_onWake', clear=True) + if utils.window('emby_syncRunning') != "true": + self.logMsg("SyncDatabase onWake (started)", 0) + librarySync = self.startSync() + self.logMsg("SyncDatabase onWake (finished) %s", librarySync, 0) + + if self.stop_thread: + # Set in service.py + self.logMsg("Service terminated thread.", 2) + break + + if monitor.waitForAbort(1): + # Abort was requested while waiting. We should exit + break + + self.logMsg("###===--- LibrarySync Stopped ---===###", 0) + + def stopThread(self): + self.stop_thread = True + self.logMsg("Ending thread...", 2) + + def suspendThread(self): + self.suspend_thread = True + self.logMsg("Pausing thread...", 0) + + def resumeThread(self): + self.suspend_thread = False + self.logMsg("Resuming thread...", 0) \ No newline at end of file diff --git a/resources/lib/playbackutils.py b/resources/lib/playbackutils.py new file mode 100644 index 00000000..affa2b81 --- /dev/null +++ b/resources/lib/playbackutils.py @@ -0,0 +1,353 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import json +import sys + +import xbmc +import xbmcgui +import xbmcplugin + +import api +import artwork +import clientinfo +import downloadutils +import playutils as putils +import playlist +import read_embyserver as embyserver +import utils + +################################################################################################# + + +class PlaybackUtils(): + + + def __init__(self, item): + + self.item = item + self.API = api.API(self.item) + + self.clientInfo = clientinfo.ClientInfo() + self.addonName = self.clientInfo.getAddonName() + self.doUtils = downloadutils.DownloadUtils() + + self.userid = utils.window('emby_currUser') + self.server = utils.window('emby_server%s' % self.userid) + + self.artwork = artwork.Artwork() + self.emby = embyserver.Read_EmbyServer() + self.pl = playlist.Playlist() + + def logMsg(self, msg, lvl=1): + + self.className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, self.className), msg, lvl) + + + def play(self, itemid, dbid=None): + + self.logMsg("Play called.", 1) + + doUtils = self.doUtils + item = self.item + API = self.API + listitem = xbmcgui.ListItem() + playutils = putils.PlayUtils(item) + + playurl = playutils.getPlayUrl() + if not playurl: + return xbmcplugin.setResolvedUrl(int(sys.argv[1]), False, listitem) + + if dbid is None: + # Item is not in Kodi database + listitem.setPath(playurl) + self.setProperties(playurl, listitem) + return xbmcplugin.setResolvedUrl(int(sys.argv[1]), True, listitem) + + ############### ORGANIZE CURRENT PLAYLIST ################ + + homeScreen = xbmc.getCondVisibility('Window.IsActive(home)') + playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + startPos = max(playlist.getposition(), 0) # Can return -1 + sizePlaylist = playlist.size() + currentPosition = startPos + + propertiesPlayback = utils.window('emby_playbackProps', windowid=10101) == "true" + introsPlaylist = False + dummyPlaylist = False + + self.logMsg("Playlist start position: %s" % startPos, 1) + self.logMsg("Playlist plugin position: %s" % currentPosition, 1) + self.logMsg("Playlist size: %s" % sizePlaylist, 1) + + ############### RESUME POINT ################ + + userdata = API.getUserData() + seektime = API.adjustResume(userdata['Resume']) + + # We need to ensure we add the intro and additional parts only once. + # Otherwise we get a loop. + if not propertiesPlayback: + + utils.window('emby_playbackProps', value="true", windowid=10101) + self.logMsg("Setting up properties in playlist.", 1) + + if (not homeScreen and not seektime and + utils.window('emby_customPlaylist', windowid=10101) != "true"): + + self.logMsg("Adding dummy file to playlist.", 2) + dummyPlaylist = True + playlist.add(playurl, listitem, index=startPos) + # Remove the original item from playlist + self.pl.removefromPlaylist(startPos+1) + # Readd the original item to playlist - via jsonrpc so we have full metadata + self.pl.insertintoPlaylist(currentPosition+1, dbid, item['Type'].lower()) + currentPosition += 1 + + ############### -- CHECK FOR INTROS ################ + + if utils.settings('enableCinema') == "true" and not seektime: + # if we have any play them when the movie/show is not being resumed + url = "{server}/emby/Users/{UserId}/Items/%s/Intros?format=json" % itemid + intros = doUtils.downloadUrl(url) + + if intros['TotalRecordCount'] != 0: + getTrailers = True + + if utils.settings('askCinema') == "true": + resp = xbmcgui.Dialog().yesno("Emby Cinema Mode", "Play trailers?") + if not resp: + # User selected to not play trailers + getTrailers = False + self.logMsg("Skip trailers.", 1) + + if getTrailers: + for intro in intros['Items']: + # The server randomly returns intros, process them. + introListItem = xbmcgui.ListItem() + introPlayurl = putils.PlayUtils(intro).getPlayUrl() + self.logMsg("Adding Intro: %s" % introPlayurl, 1) + + # Set listitem and properties for intros + pbutils = PlaybackUtils(intro) + pbutils.setProperties(introPlayurl, introListItem) + + self.pl.insertintoPlaylist(currentPosition, url=introPlayurl) + introsPlaylist = True + currentPosition += 1 + + + ############### -- ADD MAIN ITEM ONLY FOR HOMESCREEN ############### + + if homeScreen and not sizePlaylist: + # Extend our current playlist with the actual item to play + # only if there's no playlist first + self.logMsg("Adding main item to playlist.", 1) + self.pl.addtoPlaylist(dbid, item['Type'].lower()) + + # Ensure that additional parts are played after the main item + currentPosition += 1 + + ############### -- CHECK FOR ADDITIONAL PARTS ################ + + if item.get('PartCount'): + # Only add to the playlist after intros have played + partcount = item['PartCount'] + url = "{server}/emby/Videos/%s/AdditionalParts?format=json" % itemid + parts = doUtils.downloadUrl(url) + for part in parts['Items']: + + additionalListItem = xbmcgui.ListItem() + additionalPlayurl = putils.PlayUtils(part).getPlayUrl() + self.logMsg("Adding additional part: %s" % partcount, 1) + + # Set listitem and properties for each additional parts + pbutils = PlaybackUtils(part) + pbutils.setProperties(additionalPlayurl, additionalListItem) + pbutils.setArtwork(additionalListItem) + + playlist.add(additionalPlayurl, additionalListItem, index=currentPosition) + self.pl.verifyPlaylist() + currentPosition += 1 + + if dummyPlaylist: + # Added a dummy file to the playlist, + # because the first item is going to fail automatically. + self.logMsg("Processed as a playlist. First item is skipped.", 1) + return xbmcplugin.setResolvedUrl(int(sys.argv[1]), False, listitem) + + + # We just skipped adding properties. Reset flag for next time. + elif propertiesPlayback: + self.logMsg("Resetting properties playback flag.", 2) + utils.window('emby_playbackProps', clear=True, windowid=10101) + + #self.pl.verifyPlaylist() + ########## SETUP MAIN ITEM ########## + + # For transcoding only, ask for audio/subs pref + if utils.window('emby_%s.playmethod' % playurl) == "Transcode": + playurl = playutils.audioSubsPref(playurl) + utils.window('emby_%s.playmethod' % playurl, value="Transcode") + + listitem.setPath(playurl) + self.setProperties(playurl, listitem) + + ############### PLAYBACK ################ + + if homeScreen and seektime: + self.logMsg("Play as a widget item.", 1) + self.setListItem(listitem) + xbmcplugin.setResolvedUrl(int(sys.argv[1]), True, listitem) + + elif ((introsPlaylist and utils.window('emby_customPlaylist', windowid=10101) == "true") or + (homeScreen and not sizePlaylist)): + # Playlist was created just now, play it. + self.logMsg("Play playlist.", 1) + xbmc.Player().play(playlist, startpos=startPos) + + else: + self.logMsg("Play as a regular item.", 1) + xbmcplugin.setResolvedUrl(int(sys.argv[1]), True, listitem) + + def setProperties(self, playurl, listitem): + # Set all properties necessary for plugin path playback + item = self.item + itemid = item['Id'] + itemtype = item['Type'] + + embyitem = "emby_%s" % playurl + utils.window('%s.runtime' % embyitem, value=str(item.get('RunTimeTicks'))) + utils.window('%s.type' % embyitem, value=itemtype) + utils.window('%s.itemid' % embyitem, value=itemid) + + if itemtype == "Episode": + utils.window('%s.refreshid' % embyitem, value=item.get('SeriesId')) + else: + utils.window('%s.refreshid' % embyitem, value=itemid) + + # Append external subtitles to stream + playmethod = utils.window('%s.playmethod' % embyitem) + # Only for direct play and direct stream + subtitles = self.externalSubs(playurl) + if playmethod in ("DirectStream", "Transcode"): + # Direct play automatically appends external + listitem.setSubtitles(subtitles) + + self.setArtwork(listitem) + + def externalSubs(self, playurl): + + externalsubs = [] + mapping = {} + + item = self.item + itemid = item['Id'] + try: + mediastreams = item['MediaSources'][0]['MediaStreams'] + except (TypeError, KeyError, IndexError): + return + + kodiindex = 0 + for stream in mediastreams: + + index = stream['Index'] + # Since Emby returns all possible tracks together, have to pull only external subtitles. + # IsTextSubtitleStream if true, is available to download from emby. + if (stream['Type'] == "Subtitle" and + stream['IsExternal'] and stream['IsTextSubtitleStream']): + + # Direct stream + url = ("%s/Videos/%s/%s/Subtitles/%s/Stream.srt" + % (self.server, itemid, itemid, index)) + + # map external subtitles for mapping + mapping[kodiindex] = index + externalsubs.append(url) + kodiindex += 1 + + mapping = json.dumps(mapping) + utils.window('emby_%s.indexMapping' % playurl, value=mapping) + + return externalsubs + + def setArtwork(self, listItem): + # Set up item and item info + item = self.item + artwork = self.artwork + + allartwork = artwork.getAllArtwork(item, parentInfo=True) + # Set artwork for listitem + arttypes = { + + 'poster': "Primary", + 'tvshow.poster': "Primary", + 'clearart': "Art", + 'tvshow.clearart': "Art", + 'clearlogo': "Logo", + 'tvshow.clearlogo': "Logo", + 'discart': "Disc", + 'fanart_image': "Backdrop", + 'landscape': "Thumb" + } + for arttype in arttypes: + + art = arttypes[arttype] + if art == "Backdrop": + try: # Backdrop is a list, grab the first backdrop + self.setArtProp(listItem, arttype, allartwork[art][0]) + except: pass + else: + self.setArtProp(listItem, arttype, allartwork[art]) + + def setArtProp(self, listItem, arttype, path): + + if arttype in ( + 'thumb', 'fanart_image', 'small_poster', 'tiny_poster', + 'medium_landscape', 'medium_poster', 'small_fanartimage', + 'medium_fanartimage', 'fanart_noindicators'): + + listItem.setProperty(arttype, path) + else: + listItem.setArt({arttype: path}) + + def setListItem(self, listItem): + + item = self.item + type = item['Type'] + API = self.API + people = API.getPeople() + studios = API.getStudios() + + metadata = { + + 'title': item.get('Name', "Missing name"), + 'year': item.get('ProductionYear'), + 'plot': API.getOverview(), + 'director': people.get('Director'), + 'writer': people.get('Writer'), + 'mpaa': API.getMpaa(), + 'genre': " / ".join(item['Genres']), + 'studio': " / ".join(studios), + 'aired': API.getPremiereDate(), + 'rating': item.get('CommunityRating'), + 'votes': item.get('VoteCount') + } + + if "Episode" in type: + # Only for tv shows + thumbId = item.get('SeriesId') + season = item.get('ParentIndexNumber', -1) + episode = item.get('IndexNumber', -1) + show = item.get('SeriesName', "") + + metadata['TVShowTitle'] = show + metadata['season'] = season + metadata['episode'] = episode + + listItem.setProperty('IsPlayable', 'true') + listItem.setProperty('IsFolder', 'false') + listItem.setLabel(metadata['title']) + listItem.setInfo('video', infoLabels=metadata) \ No newline at end of file diff --git a/resources/lib/player.py b/resources/lib/player.py new file mode 100644 index 00000000..b6d25e19 --- /dev/null +++ b/resources/lib/player.py @@ -0,0 +1,502 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import json + +import xbmc +import xbmcgui + +import utils +import clientinfo +import downloadutils +import kodidb_functions as kodidb +import websocket_client as wsc + +################################################################################################# + + +class Player(xbmc.Player): + + # Borg - multiple instances, shared state + _shared_state = {} + + played_info = {} + playStats = {} + currentFile = None + + + def __init__(self): + + self.__dict__ = self._shared_state + + self.clientInfo = clientinfo.ClientInfo() + self.addonName = self.clientInfo.getAddonName() + self.doUtils = downloadutils.DownloadUtils() + self.ws = wsc.WebSocket_Client() + self.xbmcplayer = xbmc.Player() + + self.logMsg("Starting playback monitor.", 2) + + def logMsg(self, msg, lvl=1): + + self.className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, self.className), msg, lvl) + + + def GetPlayStats(self): + return self.playStats + + def onPlayBackStarted( self ): + # Will be called when xbmc starts playing a file + xbmcplayer = self.xbmcplayer + self.stopAll() + + # Get current file + try: + currentFile = xbmcplayer.getPlayingFile() + xbmc.sleep(300) + except: + currentFile = "" + count = 0 + while not currentFile: + xbmc.sleep(100) + try: + currentFile = xbmcplayer.getPlayingFile() + except: pass + + if count == 5: # try 5 times + self.logMsg("Cancelling playback report...", 1) + break + else: count += 1 + + + if currentFile: + + self.currentFile = currentFile + + # We may need to wait for info to be set in kodi monitor + itemId = utils.window("emby_%s.itemid" % currentFile) + tryCount = 0 + while not itemId: + + xbmc.sleep(200) + itemId = utils.window("emby_%s.itemid" % currentFile) + if tryCount == 20: # try 20 times or about 10 seconds + self.logMsg("Could not find itemId, cancelling playback report...", 1) + break + else: tryCount += 1 + + else: + self.logMsg("ONPLAYBACK_STARTED: %s itemid: %s" % (currentFile, itemId), 0) + + # Only proceed if an itemId was found. + embyitem = "emby_%s" % currentFile + runtime = utils.window("%s.runtime" % embyitem) + refresh_id = utils.window("%s.refreshid" % embyitem) + playMethod = utils.window("%s.playmethod" % embyitem) + itemType = utils.window("%s.type" % embyitem) + utils.window('emby_skipWatched%s' % itemId, value="true") + + seekTime = xbmcplayer.getTime() + + # Get playback volume + volume_query = { + + "jsonrpc": "2.0", + "id": 1, + "method": "Application.GetProperties", + "params": { + + "properties": ["volume", "muted"] + } + } + result = xbmc.executeJSONRPC(json.dumps(volume_query)) + result = json.loads(result) + result = result.get('result') + + volume = result.get('volume') + muted = result.get('muted') + + # Postdata structure to send to Emby server + url = "{server}/emby/Sessions/Playing" + postdata = { + + 'QueueableMediaTypes': "Video", + 'CanSeek': True, + 'ItemId': itemId, + 'MediaSourceId': itemId, + 'PlayMethod': playMethod, + 'VolumeLevel': volume, + 'PositionTicks': int(seekTime * 10000000), + 'IsMuted': muted + } + + # Get the current audio track and subtitles + if playMethod == "Transcode": + # property set in PlayUtils.py + postdata['AudioStreamIndex'] = utils.window("%sAudioStreamIndex" % currentFile) + postdata['SubtitleStreamIndex'] = utils.window("%sSubtitleStreamIndex" + % currentFile) + else: + # Get the current kodi audio and subtitles and convert to Emby equivalent + tracks_query = { + + "jsonrpc": "2.0", + "id": 1, + "method": "Player.GetProperties", + "params": { + + "playerid": 1, + "properties": ["currentsubtitle","currentaudiostream","subtitleenabled"] + } + } + result = xbmc.executeJSONRPC(json.dumps(tracks_query)) + result = json.loads(result) + result = result.get('result') + + try: # Audio tracks + indexAudio = result['currentaudiostream']['index'] + except (KeyError, TypeError): + indexAudio = 0 + + try: # Subtitles tracks + indexSubs = result['currentsubtitle']['index'] + except (KeyError, TypeError): + indexSubs = 0 + + try: # If subtitles are enabled + subsEnabled = result['subtitleenabled'] + except (KeyError, TypeError): + subsEnabled = "" + + # Postdata for the audio + postdata['AudioStreamIndex'] = indexAudio + 1 + + # Postdata for the subtitles + if subsEnabled and len(xbmc.Player().getAvailableSubtitleStreams()) > 0: + + # Number of audiotracks to help get Emby Index + audioTracks = len(xbmc.Player().getAvailableAudioStreams()) + mapping = utils.window("%s.indexMapping" % embyitem) + + if mapping: # Set in playbackutils.py + + self.logMsg("Mapping for external subtitles index: %s" % mapping, 2) + externalIndex = json.loads(mapping) + + if externalIndex.get(str(indexSubs)): + # If the current subtitle is in the mapping + postdata['SubtitleStreamIndex'] = externalIndex[str(indexSubs)] + else: + # Internal subtitle currently selected + subindex = indexSubs - len(externalIndex) + audioTracks + 1 + postdata['SubtitleStreamIndex'] = subindex + + else: # Direct paths enabled scenario or no external subtitles set + postdata['SubtitleStreamIndex'] = indexSubs + audioTracks + 1 + else: + postdata['SubtitleStreamIndex'] = "" + + + # Post playback to server + self.logMsg("Sending POST play started: %s." % postdata, 2) + self.doUtils.downloadUrl(url, postBody=postdata, type="POST") + + # Ensure we do have a runtime + try: + runtime = int(runtime) + except ValueError: + runtime = xbmcplayer.getTotalTime() + self.logMsg("Runtime is missing, Kodi runtime: %s" % runtime, 1) + + # Save data map for updates and position calls + data = { + + 'runtime': runtime, + 'item_id': itemId, + 'refresh_id': refresh_id, + 'currentfile': currentFile, + 'AudioStreamIndex': postdata['AudioStreamIndex'], + 'SubtitleStreamIndex': postdata['SubtitleStreamIndex'], + 'playmethod': playMethod, + 'Type': itemType, + 'currentPosition': int(seekTime) + } + + self.played_info[currentFile] = data + self.logMsg("ADDING_FILE: %s" % self.played_info, 1) + + # log some playback stats + '''if(itemType != None): + if(self.playStats.get(itemType) != None): + count = self.playStats.get(itemType) + 1 + self.playStats[itemType] = count + else: + self.playStats[itemType] = 1 + + if(playMethod != None): + if(self.playStats.get(playMethod) != None): + count = self.playStats.get(playMethod) + 1 + self.playStats[playMethod] = count + else: + self.playStats[playMethod] = 1''' + + def reportPlayback(self): + + self.logMsg("reportPlayback Called", 2) + xbmcplayer = self.xbmcplayer + + # Get current file + currentFile = self.currentFile + data = self.played_info.get(currentFile) + + # only report playback if emby has initiated the playback (item_id has value) + if data: + # Get playback information + itemId = data['item_id'] + audioindex = data['AudioStreamIndex'] + subtitleindex = data['SubtitleStreamIndex'] + playTime = data['currentPosition'] + playMethod = data['playmethod'] + paused = data.get('paused', False) + + + # Get playback volume + volume_query = { + + "jsonrpc": "2.0", + "id": 1, + "method": "Application.GetProperties", + "params": { + + "properties": ["volume", "muted"] + } + } + result = xbmc.executeJSONRPC(json.dumps(volume_query)) + result = json.loads(result) + result = result.get('result') + + volume = result.get('volume') + muted = result.get('muted') + + # Postdata for the websocketclient report + postdata = { + + 'QueueableMediaTypes': "Video", + 'CanSeek': True, + 'ItemId': itemId, + 'MediaSourceId': itemId, + 'PlayMethod': playMethod, + 'PositionTicks': int(playTime * 10000000), + 'IsPaused': paused, + 'VolumeLevel': volume, + 'IsMuted': muted + } + + if playMethod == "Transcode": + # Track can't be changed, keep reporting the same index + postdata['AudioStreamIndex'] = audioindex + postdata['AudioStreamIndex'] = subtitleindex + + else: + # Get current audio and subtitles track + tracks_query = { + + "jsonrpc": "2.0", + "id": 1, + "method": "Player.GetProperties", + "params": { + + "playerid": 1, + "properties": ["currentsubtitle","currentaudiostream","subtitleenabled"] + } + } + result = xbmc.executeJSONRPC(json.dumps(tracks_query)) + result = json.loads(result) + result = result.get('result') + + try: # Audio tracks + indexAudio = result['currentaudiostream']['index'] + except (KeyError, TypeError): + indexAudio = 0 + + try: # Subtitles tracks + indexSubs = result['currentsubtitle']['index'] + except (KeyError, TypeError): + indexSubs = 0 + + try: # If subtitles are enabled + subsEnabled = result['subtitleenabled'] + except (KeyError, TypeError): + subsEnabled = "" + + # Postdata for the audio + data['AudioStreamIndex'], postdata['AudioStreamIndex'] = [indexAudio + 1] * 2 + + # Postdata for the subtitles + if subsEnabled and len(xbmc.Player().getAvailableSubtitleStreams()) > 0: + + # Number of audiotracks to help get Emby Index + audioTracks = len(xbmc.Player().getAvailableAudioStreams()) + mapping = utils.window("emby_%s.indexMapping" % currentFile) + + if mapping: # Set in PlaybackUtils.py + + self.logMsg("Mapping for external subtitles index: %s" % mapping, 2) + externalIndex = json.loads(mapping) + + if externalIndex.get(str(indexSubs)): + # If the current subtitle is in the mapping + subindex = [externalIndex[str(indexSubs)]] * 2 + data['SubtitleStreamIndex'], postdata['SubtitleStreamIndex'] = subindex + else: + # Internal subtitle currently selected + subindex = [indexSubs - len(externalIndex) + audioTracks + 1] * 2 + data['SubtitleStreamIndex'], postdata['SubtitleStreamIndex'] = subindex + + else: # Direct paths enabled scenario or no external subtitles set + subindex = [indexSubs + audioTracks + 1] * 2 + data['SubtitleStreamIndex'], postdata['SubtitleStreamIndex'] = subindex + else: + data['SubtitleStreamIndex'], postdata['SubtitleStreamIndex'] = [""] * 2 + + # Report progress via websocketclient + postdata = json.dumps(postdata) + self.logMsg("Report: %s" % postdata, 2) + self.ws.sendProgressUpdate(postdata) + + def onPlayBackPaused( self ): + + currentFile = self.currentFile + self.logMsg("PLAYBACK_PAUSED: %s" % currentFile, 2) + + if self.played_info.get(currentFile): + self.played_info[currentFile]['paused'] = True + + self.reportPlayback() + + def onPlayBackResumed( self ): + + currentFile = self.currentFile + self.logMsg("PLAYBACK_RESUMED: %s" % currentFile, 2) + + if self.played_info.get(currentFile): + self.played_info[currentFile]['paused'] = False + + self.reportPlayback() + + def onPlayBackSeek( self, time, seekOffset ): + # Make position when seeking a bit more accurate + currentFile = self.currentFile + self.logMsg("PLAYBACK_SEEK: %s" % currentFile, 2) + + if self.played_info.get(currentFile): + position = self.xbmcplayer.getTime() + self.played_info[currentFile]['currentPosition'] = position + + self.reportPlayback() + + def onPlayBackStopped( self ): + # Will be called when user stops xbmc playing a file + self.logMsg("ONPLAYBACK_STOPPED", 2) + xbmcgui.Window(10101).clearProperties() + self.logMsg("Clear playlist properties.") + self.stopAll() + + def onPlayBackEnded( self ): + # Will be called when xbmc stops playing a file + self.logMsg("ONPLAYBACK_ENDED", 2) + self.stopAll() + + def stopAll(self): + + doUtils = self.doUtils + + if not self.played_info: + return + + self.logMsg("Played_information: %s" % self.played_info, 1) + # Process each items + for item in self.played_info: + + data = self.played_info.get(item) + if data: + + self.logMsg("Item path: %s" % item, 2) + self.logMsg("Item data: %s" % data, 2) + + runtime = data['runtime'] + currentPosition = data['currentPosition'] + itemid = data['item_id'] + refresh_id = data['refresh_id'] + currentFile = data['currentfile'] + type = data['Type'] + playMethod = data['playmethod'] + + if currentPosition and runtime: + try: + percentComplete = (currentPosition * 10000000) / int(runtime) + except ZeroDivisionError: + # Runtime is 0. + percentComplete = 0 + + markPlayedAt = float(utils.settings('markPlayed')) / 100 + self.logMsg( + "Percent complete: %s Mark played at: %s" + % (percentComplete, markPlayedAt), 1) + + # Prevent manually mark as watched in Kodi monitor + utils.window('emby_skipWatched%s' % itemid, value="true") + + self.stopPlayback(data) + # Stop transcoding + if playMethod == "Transcode": + self.logMsg("Transcoding for %s terminated." % itemid, 1) + deviceId = self.clientInfo.getDeviceId() + url = "{server}/emby/Videos/ActiveEncodings?DeviceId=%s" % deviceId + doUtils.downloadUrl(url, type="DELETE") + + # Send the delete action to the server. + offerDelete = False + + if type == "Episode" and utils.settings('deleteTV') == "true": + offerDelete = True + elif type == "Movie" and utils.settings('deleteMovies') == "true": + offerDelete = True + + if utils.settings('offerDelete') != "true": + # Delete could be disabled, even if the subsetting is enabled. + offerDelete = False + + if percentComplete >= markPlayedAt and offerDelete: + if utils.settings('skipConfirmDelete') != "true": + resp = xbmcgui.Dialog().yesno( + heading="Confirm delete", + line1="Delete file on Emby Server?") + if not resp: + self.logMsg("User skipped deletion.", 1) + continue + + url = "{server}/emby/Items/%s?format=json" % itemid + self.logMsg("Deleting request: %s" % itemid) + doUtils.downloadUrl(url, type="DELETE") + + self.played_info.clear() + + def stopPlayback(self, data): + + self.logMsg("stopPlayback called", 2) + + itemId = data['item_id'] + currentPosition = data['currentPosition'] + positionTicks = int(currentPosition * 10000000) + + url = "{server}/emby/Sessions/Playing/Stopped" + postdata = { + + 'ItemId': itemId, + 'MediaSourceId': itemId, + 'PositionTicks': positionTicks + } + self.doUtils.downloadUrl(url, postBody=postdata, type="POST") \ No newline at end of file diff --git a/resources/lib/playutils.py b/resources/lib/playutils.py new file mode 100644 index 00000000..0a74690b --- /dev/null +++ b/resources/lib/playutils.py @@ -0,0 +1,397 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import xbmc +import xbmcgui +import xbmcvfs + +import clientinfo +import utils + +################################################################################################# + + +class PlayUtils(): + + + def __init__(self, item): + + self.item = item + + self.clientInfo = clientinfo.ClientInfo() + self.addonName = self.clientInfo.getAddonName() + + self.userid = utils.window('emby_currUser') + self.server = utils.window('emby_server%s' % self.userid) + + def logMsg(self, msg, lvl=1): + + self.className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, self.className), msg, lvl) + + + def getPlayUrl(self): + + item = self.item + playurl = None + + if item['MediaSources'][0]['Protocol'] == "Http": + # Only play as http + self.logMsg("File protocol is http.", 1) + playurl = self.httpPlay() + utils.window('emby_%s.playmethod' % playurl, value="DirectStream") + + elif self.isDirectPlay(): + + self.logMsg("File is direct playing.", 1) + playurl = self.directPlay() + playurl = playurl.encode('utf-8') + # Set playmethod property + utils.window('emby_%s.playmethod' % playurl, value="DirectPlay") + + elif self.isDirectStream(): + + self.logMsg("File is direct streaming.", 1) + playurl = self.directStream() + # Set playmethod property + utils.window('emby_%s.playmethod' % playurl, value="DirectStream") + + elif self.isTranscoding(): + + self.logMsg("File is transcoding.", 1) + playurl = self.transcoding() + # Set playmethod property + utils.window('emby_%s.playmethod' % playurl, value="Transcode") + + return playurl + + def httpPlay(self): + # Audio, Video, Photo + item = self.item + server = self.server + + itemid = item['Id'] + mediatype = item['MediaType'] + + if type == "Audio": + playurl = "%s/emby/Audio/%s/stream" % (server, itemid) + else: + playurl = "%s/emby/Videos/%s/stream?static=true" % (server, itemid) + + return playurl + + def isDirectPlay(self): + + item = self.item + + # Requirement: Filesystem, Accessible path + if utils.settings('playFromStream') == "true": + # User forcing to play via HTTP + self.logMsg("Can't direct play, play from HTTP enabled.", 1) + return False + + if (utils.settings('transcodeH265') == "true" and + result['MediaSources'][0]['Name'].startswith("1080P/H265")): + # Avoid H265 1080p + self.logMsg("Option to transcode 1080P/H265 enabled.", 1) + return False + + canDirectPlay = item['MediaSources'][0]['SupportsDirectPlay'] + # Make sure direct play is supported by the server + if not canDirectPlay: + self.logMsg("Can't direct play, server doesn't allow/support it.", 1) + return False + + location = item['LocationType'] + if location == "FileSystem": + # Verify the path + if not self.fileExists(): + self.logMsg("Unable to direct play.") + try: + count = int(utils.settings('failCount')) + except ValueError: + count = 0 + self.logMsg("Direct play failed: %s times." % count, 1) + + if count < 2: + # Let the user know that direct play failed + utils.settings('failCount', value=str(count+1)) + xbmcgui.Dialog().notification( + heading="Emby server", + message="Unable to direct play.", + icon="special://home/addons/plugin.video.emby/icon.png", + sound=False) + elif utils.settings('playFromStream') != "true": + # Permanently set direct stream as true + utils.settings('playFromStream', value="true") + utils.settings('failCount', value="0") + xbmcgui.Dialog().notification( + heading="Emby server", + message=("Direct play failed 3 times. Enabled play " + "from HTTP in the add-on settings."), + icon="special://home/addons/plugin.video.emby/icon.png", + sound=False) + return False + + return True + + def directPlay(self): + + item = self.item + + try: + playurl = item['MediaSources'][0]['Path'] + except (IndexError, KeyError): + playurl = item['Path'] + + if item.get('VideoType'): + # Specific format modification + type = item['VideoType'] + + if type == "Dvd": + playurl = "%s/VIDEO_TS/VIDEO_TS.IFO" % playurl + elif type == "Bluray": + playurl = "%s/BDMV/index.bdmv" % playurl + + # Assign network protocol + if playurl.startswith('\\\\'): + playurl = playurl.replace("\\\\", "smb://") + playurl = playurl.replace("\\", "/") + + if "apple.com" in playurl: + USER_AGENT = "QuickTime/7.7.4" + playurl += "?|User-Agent=%s" % USER_AGENT + + return playurl + + def fileExists(self): + + if 'Path' not in self.item: + # File has no path defined in server + return False + + # Convert path to direct play + path = self.directPlay() + self.logMsg("Verifying path: %s" % path, 1) + + if xbmcvfs.exists(path): + self.logMsg("Path exists.", 1) + return True + + elif ":" not in path: + self.logMsg("Can't verify path, assumed linux. Still try to direct play.", 1) + return True + + else: + self.logMsg("Failed to find file.") + return False + + def isDirectStream(self): + + item = self.item + + if (utils.settings('transcodeH265') == "true" and + result['MediaSources'][0]['Name'].startswith("1080P/H265")): + # Avoid H265 1080p + self.logMsg("Option to transcode 1080P/H265 enabled.", 1) + return False + + # Requirement: BitRate, supported encoding + canDirectStream = item['MediaSources'][0]['SupportsDirectStream'] + # Make sure the server supports it + if not canDirectStream: + return False + + # Verify the bitrate + if not self.isNetworkSufficient(): + self.logMsg("The network speed is insufficient to direct stream file.", 1) + return False + + return True + + def directStream(self): + + item = self.item + server = self.server + + itemid = item['Id'] + type = item['Type'] + + if 'Path' in item and item['Path'].endswith('.strm'): + # Allow strm loading when direct streaming + playurl = self.directPlay() + elif type == "Audio": + playurl = "%s/emby/Audio/%s/stream.mp3" % (server, itemid) + else: + playurl = "%s/emby/Videos/%s/stream?static=true" % (server, itemid) + + return playurl + + def isNetworkSufficient(self): + + settings = self.getBitrate()*1000 + + try: + sourceBitrate = int(self.item['MediaSources'][0]['Bitrate']) + except (KeyError, TypeError): + self.logMsg("Bitrate value is missing.", 1) + else: + self.logMsg("The add-on settings bitrate is: %s, the video bitrate required is: %s" + % (settings, sourceBitrate), 1) + if settings < sourceBitrate: + return False + + return True + + def isTranscoding(self): + + item = self.item + + canTranscode = item['MediaSources'][0]['SupportsTranscoding'] + # Make sure the server supports it + if not canTranscode: + return False + + return True + + def transcoding(self): + + item = self.item + + if 'Path' in item and item['Path'].endswith('.strm'): + # Allow strm loading when transcoding + playurl = self.directPlay() + else: + itemid = item['Id'] + deviceId = self.clientInfo.getDeviceId() + playurl = ( + "%s/emby/Videos/%s/master.m3u8?MediaSourceId=%s" + % (self.server, itemid, itemid) + ) + playurl = ( + "%s&VideoCodec=h264&AudioCodec=ac3&MaxAudioChannels=6&deviceId=%s&VideoBitrate=%s" + % (playurl, deviceId, self.getBitrate()*1000)) + + return playurl + + def getBitrate(self): + + # get the addon video quality + videoQuality = utils.settings('videoBitrate') + bitrate = { + + '0': 664, + '1': 996, + '2': 1320, + '3': 2000, + '4': 3200, + '5': 4700, + '6': 6200, + '7': 7700, + '8': 9200, + '9': 10700, + '10': 12200, + '11': 13700, + '12': 15200, + '13': 16700, + '14': 18200, + '15': 20000, + '16': 40000, + '17': 100000, + '18': 1000000 + } + + # max bit rate supported by server (max signed 32bit integer) + return bitrate.get(videoQuality, 2147483) + + def audioSubsPref(self, url): + # For transcoding only + # Present the list of audio to select from + audioStreamsList = {} + audioStreams = [] + audioStreamsChannelsList = {} + subtitleStreamsList = {} + subtitleStreams = ['No subtitles'] + selectAudioIndex = "" + selectSubsIndex = "" + playurlprefs = "%s" % url + + item = self.item + try: + mediasources = item['MediaSources'][0] + mediastreams = mediasources['MediaStreams'] + except (TypeError, KeyError, IndexError): + return + + for stream in mediastreams: + # Since Emby returns all possible tracks together, have to sort them. + index = stream['Index'] + type = stream['Type'] + + if 'Audio' in type: + codec = stream['Codec'] + channelLayout = stream.get('ChannelLayout', "") + + try: + track = "%s - %s - %s %s" % (index, stream['Language'], codec, channelLayout) + except: + track = "%s - %s %s" % (index, codec, channelLayout) + + audioStreamsChannelsList[index] = stream['Channels'] + audioStreamsList[track] = index + audioStreams.append(track) + + elif 'Subtitle' in type: + if stream['IsExternal']: + continue + try: + track = "%s - %s" % (index, stream['Language']) + except: + track = "%s - %s" % (index, stream['Codec']) + + default = stream['IsDefault'] + forced = stream['IsForced'] + if default: + track = "%s - Default" % track + if forced: + track = "%s - Forced" % track + + subtitleStreamsList[track] = index + subtitleStreams.append(track) + + + if len(audioStreams) > 1: + resp = xbmcgui.Dialog().select("Choose the audio stream", audioStreams) + if resp > -1: + # User selected audio + selected = audioStreams[resp] + selectAudioIndex = audioStreamsList[selected] + playurlprefs += "&AudioStreamIndex=%s" % selectAudioIndex + else: # User backed out of selection + playurlprefs += "&AudioStreamIndex=%s" % mediasources['DefaultAudioStreamIndex'] + else: # There's only one audiotrack. + selectAudioIndex = audioStreamsList[audioStreams[0]] + playurlprefs += "&AudioStreamIndex=%s" % selectAudioIndex + + if len(subtitleStreams) > 1: + resp = xbmcgui.Dialog().select("Choose the subtitle stream", subtitleStreams) + if resp == 0: + # User selected no subtitles + pass + elif resp > -1: + # User selected subtitles + selected = subtitleStreams[resp] + selectSubsIndex = subtitleStreamsList[selected] + playurlprefs += "&SubtitleStreamIndex=%s" % selectSubsIndex + else: # User backed out of selection + playurlprefs += "&SubtitleStreamIndex=%s" % mediasources.get('DefaultSubtitleStreamIndex', "") + + # Get number of channels for selected audio track + audioChannels = audioStreamsChannelsList.get(selectAudioIndex, 0) + if audioChannels > 2: + playurlprefs += "&AudioBitrate=384000" + else: + playurlprefs += "&AudioBitrate=192000" + + return playurlprefs \ No newline at end of file diff --git a/resources/lib/userclient.py b/resources/lib/userclient.py new file mode 100644 index 00000000..a6562a53 --- /dev/null +++ b/resources/lib/userclient.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import hashlib +import threading + +import xbmc +import xbmcgui +import xbmcaddon +import xbmcvfs + +import artwork +import utils +import clientinfo +import downloadutils + +################################################################################################## + + +class UserClient(threading.Thread): + + # Borg - multiple instances, shared state + _shared_state = {} + + stopClient = False + auth = True + retry = 0 + + currUser = None + currUserId = None + currServer = None + currToken = None + HasAccess = True + AdditionalUser = [] + + userSettings = None + + + def __init__(self): + + self.__dict__ = self._shared_state + self.addon = xbmcaddon.Addon() + + self.addonName = clientinfo.ClientInfo().getAddonName() + self.doUtils = downloadutils.DownloadUtils() + self.logLevel = int(utils.settings('logLevel')) + + threading.Thread.__init__(self) + + def logMsg(self, msg, lvl=1): + + className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, className), msg, lvl) + + + def getAdditionalUsers(self): + + additionalUsers = utils.settings('additionalUsers') + + if additionalUsers: + self.AdditionalUser = additionalUsers.split(',') + + def getUsername(self): + + username = utils.settings('username') + + if not username: + self.logMsg("No username saved.", 2) + return "" + + return username + + def getLogLevel(self): + + try: + logLevel = int(utils.settings('logLevel')) + except ValueError: + logLevel = 0 + + return logLevel + + def getUserId(self): + + username = self.getUsername() + w_userId = utils.window('emby_userId%s' % username) + s_userId = utils.settings('userId%s' % username) + + # Verify the window property + if w_userId: + if not s_userId: + # Save access token if it's missing from settings + utils.settings('userId%s' % username, value=w_userId) + self.logMsg( + "Returning userId from WINDOW for username: %s UserId: %s" + % (username, w_userId), 2) + return w_userId + # Verify the settings + elif s_userId: + self.logMsg( + "Returning userId from SETTINGS for username: %s userId: %s" + % (username, s_userId), 2) + return s_userId + # No userId found + else: + self.logMsg("No userId saved for username: %s." % username, 1) + + def getServer(self, prefix=True): + + alternate = utils.settings('altip') == "true" + if alternate: + # Alternate host + HTTPS = utils.settings('secondhttps') == "true" + host = utils.settings('secondipaddress') + port = utils.settings('secondport') + else: + # Original host + HTTPS = utils.settings('https') == "true" + host = utils.settings('ipaddress') + port = utils.settings('port') + + server = host + ":" + port + + if not host: + self.logMsg("No server information saved.", 2) + return False + + # If https is true + if prefix and HTTPS: + server = "https://%s" % server + return server + # If https is false + elif prefix and not HTTPS: + server = "http://%s" % server + return server + # If only the host:port is required + elif not prefix: + return server + + def getToken(self): + + username = self.getUsername() + w_token = utils.window('emby_accessToken%s' % username) + s_token = utils.settings('accessToken') + + # Verify the window property + if w_token: + if not s_token: + # Save access token if it's missing from settings + utils.settings('accessToken', value=w_token) + self.logMsg( + "Returning accessToken from WINDOW for username: %s accessToken: %s" + % (username, w_token), 2) + return w_token + # Verify the settings + elif s_token: + self.logMsg( + "Returning accessToken from SETTINGS for username: %s accessToken: %s" + % (username, s_token), 2) + utils.window('emby_accessToken%s' % username, value=s_token) + return s_token + else: + self.logMsg("No token found.", 1) + return "" + + def getSSLverify(self): + # Verify host certificate + s_sslverify = utils.settings('sslverify') + if utils.settings('altip') == "true": + s_sslverify = utils.settings('secondsslverify') + + if s_sslverify == "true": + return True + else: + return False + + def getSSL(self): + # Client side certificate + s_cert = utils.settings('sslcert') + if utils.settings('altip') == "true": + s_cert = utils.settings('secondsslcert') + + if s_cert == "None": + return None + else: + return s_cert + + def setUserPref(self): + + doUtils = self.doUtils + + url = "{server}/emby/Users/{UserId}?format=json" + result = doUtils.downloadUrl(url) + self.userSettings = result + # Set user image for skin display + if result.get('PrimaryImageTag'): + utils.window('EmbyUserImage', value=artwork.Artwork().getUserArtwork(result, 'Primary')) + + # Set resume point max + url = "{server}/emby/System/Configuration?format=json" + result = doUtils.downloadUrl(url) + + utils.settings('markPlayed', value=str(result['MaxResumePct'])) + + def getPublicUsers(self): + + server = self.getServer() + + # Get public Users + url = "%s/emby/Users/Public?format=json" % server + result = self.doUtils.downloadUrl(url, authenticate=False) + + if result != "": + return result + else: + # Server connection failed + return False + + def hasAccess(self): + # hasAccess is verified in service.py + url = "{server}/emby/Users?format=json" + result = self.doUtils.downloadUrl(url) + + if result == False: + # Access is restricted, set in downloadutils.py via exception + self.logMsg("Access is restricted.", 1) + self.HasAccess = False + + elif utils.window('emby_online') != "true": + # Server connection failed + pass + + elif utils.window('emby_serverStatus') == "restricted": + self.logMsg("Access is granted.", 1) + self.HasAccess = True + utils.window('emby_serverStatus', clear=True) + xbmcgui.Dialog().notification("Emby server", "Access is enabled.") + + def loadCurrUser(self, authenticated=False): + + doUtils = self.doUtils + username = self.getUsername() + userId = self.getUserId() + + # Only to be used if token exists + self.currUserId = userId + self.currServer = self.getServer() + self.currToken = self.getToken() + self.ssl = self.getSSLverify() + self.sslcert = self.getSSL() + + # Test the validity of current token + if authenticated == False: + url = "%s/emby/Users/%s?format=json" % (self.currServer, userId) + utils.window('emby_currUser', value=userId) + utils.window('emby_accessToken%s' % userId, value=self.currToken) + result = doUtils.downloadUrl(url) + + if result == 401: + # Token is no longer valid + self.resetClient() + return False + + # Set to windows property + utils.window('emby_currUser', value=userId) + utils.window('emby_accessToken%s' % userId, value=self.currToken) + utils.window('emby_server%s' % userId, value=self.currServer) + utils.window('emby_server_%s' % userId, value=self.getServer(prefix=False)) + + # Set DownloadUtils values + doUtils.setUsername(username) + doUtils.setUserId(self.currUserId) + doUtils.setServer(self.currServer) + doUtils.setToken(self.currToken) + doUtils.setSSL(self.ssl, self.sslcert) + # parental control - let's verify if access is restricted + self.hasAccess() + # Start DownloadUtils session + doUtils.startSession() + self.getAdditionalUsers() + # Set user preferences in settings + self.currUser = username + self.setUserPref() + + + def authenticate(self): + # Get /profile/addon_data + addondir = xbmc.translatePath(self.addon.getAddonInfo('profile')).decode('utf-8') + hasSettings = xbmcvfs.exists("%ssettings.xml" % addondir) + + username = self.getUsername() + server = self.getServer() + + # If there's no settings.xml + if not hasSettings: + self.logMsg("No settings.xml found.", 1) + self.auth = False + return + # If no user information + elif not server or not username: + self.logMsg("Missing server information.", 1) + self.auth = False + return + # If there's a token, load the user + elif self.getToken(): + result = self.loadCurrUser() + + if result == False: + pass + else: + self.logMsg("Current user: %s" % self.currUser, 1) + self.logMsg("Current userId: %s" % self.currUserId, 1) + self.logMsg("Current accessToken: %s" % self.currToken, 2) + return + + ##### AUTHENTICATE USER ##### + + users = self.getPublicUsers() + password = "" + + # Find user in list + for user in users: + name = user['Name'] + + if username.decode('utf-8') in name: + # If user has password + if user['HasPassword'] == True: + password = xbmcgui.Dialog().input( + heading="Enter password for user: %s" % username, + option=xbmcgui.ALPHANUM_HIDE_INPUT) + # If password dialog is cancelled + if not password: + self.logMsg("No password entered.", 0) + utils.window('emby_serverStatus', value="Stop") + self.auth = False + return + break + else: + # Manual login, user is hidden + password = xbmcgui.Dialog().input( + heading="Enter password for user: %s" % username, + option=xbmcgui.ALPHANUM_HIDE_INPUT) + sha1 = hashlib.sha1(password) + sha1 = sha1.hexdigest() + + # Authenticate username and password + url = "%s/emby/Users/AuthenticateByName?format=json" % server + data = {'username': username, 'password': sha1} + self.logMsg(data, 2) + + result = self.doUtils.downloadUrl(url, postBody=data, type="POST", authenticate=False) + + try: + self.logMsg("Auth response: %s" % result, 1) + accessToken = result['AccessToken'] + + except (KeyError, TypeError): + self.logMsg("Failed to retrieve the api key.", 1) + accessToken = None + + if accessToken is not None: + self.currUser = username + xbmcgui.Dialog().notification("Emby server", "Welcome %s!" % self.currUser) + userId = result['User']['Id'] + utils.settings('accessToken', value=accessToken) + utils.settings('userId%s' % username, value=userId) + self.logMsg("User Authenticated: %s" % accessToken, 1) + self.loadCurrUser(authenticated=True) + utils.window('emby_serverStatus', clear=True) + self.retry = 0 + else: + self.logMsg("User authentication failed.", 1) + utils.settings('accessToken', value="") + utils.settings('userId%s' % username, value="") + xbmcgui.Dialog().ok("Error connecting", "Invalid username or password.") + + # Give two attempts at entering password + if self.retry == 2: + self.logMsg( + """Too many retries. You can retry by resetting + attempts in the addon settings.""", 1) + utils.window('emby_serverStatus', value="Stop") + xbmcgui.Dialog().ok( + heading="Error connecting", + line1="Failed to authenticate too many times.", + line2="You can retry by resetting attempts in the addon settings.") + + self.retry += 1 + self.auth = False + + def resetClient(self): + + self.logMsg("Reset UserClient authentication.", 1) + username = self.getUsername() + + if self.currToken is not None: + # In case of 401, removed saved token + utils.settings('accessToken', value="") + utils.window('emby_accessToken%s' % username, clear=True) + self.currToken = None + self.logMsg("User token has been removed.", 1) + + self.auth = True + self.currUser = None + + def run(self): + + monitor = xbmc.Monitor() + self.logMsg("----===## Starting UserClient ##===----", 0) + + while not monitor.abortRequested(): + + # Verify the log level + currLogLevel = self.getLogLevel() + if self.logLevel != currLogLevel: + # Set new log level + self.logLevel = currLogLevel + utils.window('emby_logLevel', value=str(currLogLevel)) + self.logMsg("New Log Level: %s" % currLogLevel, 0) + + + status = utils.window('emby_serverStatus') + if status: + # Verify the connection status to server + if status == "restricted": + # Parental control is restricting access + self.HasAccess = False + + elif status == "401": + # Unauthorized access, revoke token + utils.window('emby_serverStatus', value="Auth") + self.resetClient() + + if self.auth and (self.currUser is None): + # Try to authenticate user + status = utils.window('emby_serverStatus') + if not status or status == "Auth": + # Set auth flag because we no longer need + # to authenticate the user + self.auth = False + self.authenticate() + + + if not self.auth and (self.currUser is None): + # If authenticate failed. + server = self.getServer() + username = self.getUsername() + status = utils.window('emby_serverStatus') + + # The status Stop is for when user cancelled password dialog. + if server and username and status != "Stop": + # Only if there's information found to login + self.logMsg("Server found: %s" % server, 2) + self.logMsg("Username found: %s" % username, 2) + self.auth = True + + + if self.stopClient == True: + # If stopping the client didn't work + break + + if monitor.waitForAbort(1): + # Abort was requested while waiting. We should exit + break + + self.doUtils.stopSession() + self.logMsg("##===---- UserClient Stopped ----===##", 0) + + def stopClient(self): + # When emby for kodi terminates + self.stopClient = True \ No newline at end of file diff --git a/resources/lib/utils.py b/resources/lib/utils.py new file mode 100644 index 00000000..83e73e1d --- /dev/null +++ b/resources/lib/utils.py @@ -0,0 +1,487 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import cProfile +import inspect +import pstats +import sqlite3 +import time +import unicodedata +import xml.etree.ElementTree as etree + +import xbmc +import xbmcaddon +import xbmcgui +import xbmcvfs + +################################################################################################# + + +def logMsg(title, msg, level=1): + + # Get the logLevel set in UserClient + try: + logLevel = int(window('emby_logLevel')) + except ValueError: + logLevel = 0 + + if logLevel >= level: + + if logLevel == 2: # inspect.stack() is expensive + try: + xbmc.log("%s -> %s : %s" % (title, inspect.stack()[1][3], msg)) + except UnicodeEncodeError: + xbmc.log("%s -> %s : %s" % (title, inspect.stack()[1][3], msg.encode('utf-8'))) + else: + try: + xbmc.log("%s -> %s" % (title, msg)) + except UnicodeEncodeError: + xbmc.log("%s -> %s" % (title, msg.encode('utf-8'))) + +def window(property, value=None, clear=False, windowid=10000): + # Get or set window property + WINDOW = xbmcgui.Window(windowid) + + if clear: + WINDOW.clearProperty(property) + elif value is not None: + WINDOW.setProperty(property, value) + else: + return WINDOW.getProperty(property) + +def settings(setting, value=None): + # Get or add addon setting + addon = xbmcaddon.Addon(id='plugin.video.emby') + + if value is not None: + addon.setSetting(setting, value) + else: + return addon.getSetting(setting) + +def language(stringid): + # Central string retrieval + addon = xbmcaddon.Addon(id='plugin.video.emby') + string = addon.getLocalizedString(stringid) + + return string + +def kodiSQL(type="video"): + + if type == "emby": + dbPath = xbmc.translatePath("special://database/emby.db").decode('utf-8') + elif type == "music": + dbPath = getKodiMusicDBPath() + elif type == "texture": + dbPath = xbmc.translatePath("special://database/Textures13.db").decode('utf-8') + else: + dbPath = getKodiVideoDBPath() + + connection = sqlite3.connect(dbPath) + return connection + +def getKodiVideoDBPath(): + + kodibuild = xbmc.getInfoLabel('System.BuildVersion')[:2] + dbVersion = { + + "13": 78, # Gotham + "14": 90, # Helix + "15": 93, # Isengard + "16": 99 # Jarvis + } + + dbPath = xbmc.translatePath( + "special://database/MyVideos%s.db" + % dbVersion.get(kodibuild, "")).decode('utf-8') + return dbPath + +def getKodiMusicDBPath(): + + kodibuild = xbmc.getInfoLabel('System.BuildVersion')[:2] + dbVersion = { + + "13": 46, # Gotham + "14": 48, # Helix + "15": 52, # Isengard + "16": 56 # Jarvis + } + + dbPath = xbmc.translatePath( + "special://database/MyMusic%s.db" + % dbVersion.get(kodibuild, "")).decode('utf-8') + return dbPath + +def reset(): + + dialog = xbmcgui.Dialog() + + resp = dialog.yesno("Warning", "Are you sure you want to reset your local Kodi database?") + if resp == 0: + return + + # first stop any db sync + window('emby_shouldStop', value="true") + count = 10 + while window('emby_dbScan') == "true": + logMsg("EMBY", "Sync is running, will retry: %s..." % count) + count -= 1 + if count == 0: + dialog.ok("Warning", "Could not stop the database from running. Try again.") + return + xbmc.sleep(1000) + + # Clean up the playlists + path = xbmc.translatePath("special://profile/playlists/video/").decode('utf-8') + dirs, files = xbmcvfs.listdir(path) + for file in files: + if file.startswith('Emby'): + xbmcvfs.delete("%s%s" % (path, file)) + + # Clean up the video nodes + import shutil + path = xbmc.translatePath("special://profile/library/video/").decode('utf-8') + dirs, files = xbmcvfs.listdir(path) + for dir in dirs: + if dir.startswith('Emby'): + shutil.rmtree("%s%s" % (path, dir)) + for file in files: + if file.startswith('emby'): + xbmcvfs.delete("%s%s" % (path, file)) + + # Wipe the kodi databases + logMsg("EMBY", "Resetting the Kodi video database.") + connection = kodiSQL('video') + cursor = connection.cursor() + cursor.execute('SELECT tbl_name FROM sqlite_master WHERE type="table"') + rows = cursor.fetchall() + for row in rows: + tablename = row[0] + if tablename != "version": + cursor.execute("DELETE FROM " + tablename) + connection.commit() + cursor.close() + + if settings('disableMusic') != "true": + logMsg("EMBY", "Resetting the Kodi music database.") + connection = kodiSQL('music') + cursor = connection.cursor() + cursor.execute('SELECT tbl_name FROM sqlite_master WHERE type="table"') + rows = cursor.fetchall() + for row in rows: + tablename = row[0] + if tablename != "version": + cursor.execute("DELETE FROM " + tablename) + connection.commit() + cursor.close() + + # Wipe the emby database + logMsg("EMBY", "Resetting the Emby database.") + connection = kodiSQL('emby') + cursor = connection.cursor() + cursor.execute('SELECT tbl_name FROM sqlite_master WHERE type="table"') + rows = cursor.fetchall() + for row in rows: + tablename = row[0] + if tablename != "version": + cursor.execute("DELETE FROM " + tablename) + connection.commit() + cursor.close() + + # reset the install run flag + settings('SyncInstallRunDone', value="false") + + # Remove emby info + resp = dialog.yesno("Warning", "Reset all Emby Addon settings?") + if resp == 1: + # Delete the settings + addon = xbmcaddon.Addon() + addondir = xbmc.translatePath(addon.getAddonInfo('profile')).decode('utf-8') + dataPath = "%ssettings.xml" % addondir + xbmcvfs.delete(dataPath) + logMsg("EMBY", "Deleting: settings.xml", 1) + + dialog.ok( + heading="Emby for Kodi", + line1="Database reset has completed, Kodi will now restart to apply the changes.") + xbmc.executebuiltin('RestartApp') + +def startProfiling(): + + pr = cProfile.Profile() + pr.enable() + + return pr + +def stopProfiling(pr, profileName): + + pr.disable() + ps = pstats.Stats(pr) + + profiles = xbmc.translatePath("%sprofiles/" + % xbmcaddon.Addon().getAddonInfo('profile')).decode('utf-8') + + if not xbmcvfs.exists(profiles): + # Create the profiles folder + xbmcvfs.mkdir(profiles) + + timestamp = time.strftime("%Y-%m-%d %H-%M-%S") + profile = "%s%s_profile_(%s).tab" % (profiles, profileName, timestamp) + + f = open(profile, 'wb') + f.write("NumbCalls\tTotalTime\tCumulativeTime\tFunctionName\tFileName\r\n") + for (key, value) in ps.stats.items(): + (filename, count, func_name) = key + (ccalls, ncalls, total_time, cumulative_time, callers) = value + try: + f.write( + "%s\t%s\t%s\t%s\t%s\r\n" + % (ncalls, "{:10.4f}".format(total_time), + "{:10.4f}".format(cumulative_time), func_name, filename)) + except ValueError: + f.write( + "%s\t%s\t%s\t%s\t%s\r\n" + % (ncalls, "{0}".format(total_time), + "{0}".format(cumulative_time), func_name, filename)) + f.close() + +def normalize_nodes(text): + # For video nodes + text = text.replace(":", "") + text = text.replace("/", "-") + text = text.replace("\\", "-") + text = text.replace("<", "") + text = text.replace(">", "") + text = text.replace("*", "") + text = text.replace("?", "") + text = text.replace('|', "") + text = text.replace('(', "") + text = text.replace(')', "") + text = text.strip() + # Remove dots from the last character as windows can not have directories + # with dots at the end + text = text.rstrip('.') + text = unicodedata.normalize('NFKD', unicode(text, 'utf-8')).encode('ascii', 'ignore') + + return text + +def normalize_string(text): + # For theme media, do not modify unless + # modified in TV Tunes + text = text.replace(":", "") + text = text.replace("/", "-") + text = text.replace("\\", "-") + text = text.replace("<", "") + text = text.replace(">", "") + text = text.replace("*", "") + text = text.replace("?", "") + text = text.replace('|', "") + text = text.strip() + # Remove dots from the last character as windows can not have directories + # with dots at the end + text = text.rstrip('.') + text = unicodedata.normalize('NFKD', unicode(text, 'utf-8')).encode('ascii', 'ignore') + + return text + +def indent(elem, level=0): + # Prettify xml trees + i = "\n" + level*" " + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + " " + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + indent(elem, level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + +def sourcesXML(): + # To make Master lock compatible + path = xbmc.translatePath("special://profile/").decode('utf-8') + xmlpath = "%ssources.xml" % path + + try: + xmlparse = etree.parse(xmlpath) + except: # Document is blank or missing + root = etree.Element('sources') + else: + root = xmlparse.getroot() + + + video = root.find('video') + if video is None: + video = etree.SubElement(root, 'video') + etree.SubElement(video, 'default', attrib={'pathversion': "1"}) + + # Add elements + for i in range(1, 3): + + for source in root.findall('.//path'): + if source.text == "smb://embydummy/dummypath%s/" % i: + # Already there, skip + break + else: + source = etree.SubElement(video, 'source') + etree.SubElement(source, 'name').text = "Emby" + etree.SubElement(source, 'path', attrib={'pathversion': "1"}).text = ( + + "smb://embydummy/dummypath%s/" % i + ) + etree.SubElement(source, 'allowsharing').text = "true" + # Prettify and write to file + try: + indent(root) + except: pass + etree.ElementTree(root).write(xmlpath) + +def passwordsXML(): + + # To add network credentials + path = xbmc.translatePath("special://userdata/").decode('utf-8') + xmlpath = "%spasswords.xml" % path + + try: + xmlparse = etree.parse(xmlpath) + except: # Document is blank or missing + root = etree.Element('passwords') + else: + root = xmlparse.getroot() + + dialog = xbmcgui.Dialog() + credentials = settings('networkCreds') + if credentials: + # Present user with options + option = dialog.select("Modify/Remove network credentials", ["Modify", "Remove"]) + + if option < 0: + # User cancelled dialog + return + + elif option == 1: + # User selected remove + iterator = root.getiterator('passwords') + + for paths in iterator: + for path in paths: + if path.find('.//from').text == "smb://%s/" % credentials: + paths.remove(path) + logMsg("EMBY", "Successfully removed credentials for: %s" + % credentials, 1) + etree.ElementTree(root).write(xmlpath) + break + else: + logMsg("EMBY", "Failed to find saved server: %s in passwords.xml" % credentials, 1) + + settings('networkCreds', value="") + xbmcgui.Dialog().notification( + heading="Emby for Kodi", + message="%s removed from passwords.xml!" % credentials, + icon="special://home/addons/plugin.video.emby/icon.png", + time=1000, + sound=False) + return + + elif option == 0: + # User selected to modify + server = dialog.input("Modify the computer name or ip address", credentials) + if not server: + return + else: + # No credentials added + dialog.ok( + heading="Network credentials", + line1= ( + "Input the server name or IP address as indicated in your emby library paths. " + 'For example, the server name: \\\\SERVER-PC\\path\\ is "SERVER-PC".')) + server = dialog.input("Enter the server name or IP address", settings('ipaddress')) + if not server: + return + + # Network username + user = dialog.input("Enter the network username") + if not user: + return + # Network password + password = dialog.input( + heading="Enter the network password", + option=xbmcgui.ALPHANUM_HIDE_INPUT) + if not password: + return + + # Add elements + for path in root.findall('.//path'): + if path.find('.//from').text.lower() == "smb://%s/" % server.lower(): + # Found the server, rewrite credentials + path.find('.//to').text = "smb://%s:%s@%s/" % (user, password, server) + break + else: + # Server not found, add it. + path = etree.SubElement(root, 'path') + etree.SubElement(path, 'from', attrib={'pathversion': "1"}).text = "smb://%s/" % server + topath = "smb://%s:%s@%s/" % (user, password, server) + etree.SubElement(path, 'to', attrib={'pathversion': "1"}).text = topath + # Force Kodi to see the credentials without restarting + xbmcvfs.exists(topath) + + # Add credentials + settings('networkCreds', value="%s" % server) + logMsg("EMBY", "Added server: %s to passwords.xml" % server, 1) + # Prettify and write to file + try: + indent(root) + except: pass + etree.ElementTree(root).write(xmlpath) + + dialog.notification( + heading="Emby for Kodi", + message="%s added to passwords.xml!" % server, + icon="special://home/addons/plugin.video.emby/icon.png", + time=1000, + sound=False) + +def playlistXSP(mediatype, tagname, viewtype="", delete=False): + # Tagname is in unicode - actions: add or delete + tagname = tagname.encode('utf-8') + cleantagname = normalize_nodes(tagname) + path = xbmc.translatePath("special://profile/playlists/video/").decode('utf-8') + if viewtype == "mixed": + plname = "%s - %s" % (tagname, mediatype) + xsppath = "%sEmby %s - %s.xsp" % (path, cleantagname, mediatype) + else: + plname = tagname + xsppath = "%sEmby %s.xsp" % (path, cleantagname) + + # Create the playlist directory + if not xbmcvfs.exists(path): + xbmcvfs.mkdirs(path) + + # Only add the playlist if it doesn't already exists + if xbmcvfs.exists(xsppath): + + if delete: + xbmcvfs.delete(xsppath) + logMsg("EMBY", "Successfully removed playlist: %s." % tagname, 1) + + return + + # Using write process since there's no guarantee the xml declaration works with etree + itemtypes = { + 'homevideos': "movies" + } + f = open(xsppath, 'w') + f.write( + '\n' + '\n\t' + 'Emby %s\n\t' + 'all\n\t' + '\n\t\t' + '%s\n\t' + '' + % (itemtypes.get(mediatype, mediatype), plname, tagname)) + f.close() + logMsg("EMBY", "Successfully added playlist: %s" % tagname) \ No newline at end of file diff --git a/resources/lib/videonodes.py b/resources/lib/videonodes.py new file mode 100644 index 00000000..1dc9d5a6 --- /dev/null +++ b/resources/lib/videonodes.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import shutil +import xml.etree.ElementTree as etree + +import xbmc +import xbmcaddon +import xbmcvfs + +import clientinfo +import utils + +################################################################################################# + + +class VideoNodes(object): + + + def __init__(self): + + clientInfo = clientinfo.ClientInfo() + self.addonName = clientInfo.getAddonName() + + self.kodiversion = int(xbmc.getInfoLabel("System.BuildVersion")[:2]) + + def logMsg(self, msg, lvl=1): + + className = self.__class__.__name__ + utils.logMsg("%s %s" % (self.addonName, className), msg, lvl) + + + def commonRoot(self, order, label, tagname, roottype=1): + + if roottype == 0: + # Index + root = etree.Element('node', attrib={'order': "%s" % order}) + elif roottype == 1: + # Filter + root = etree.Element('node', attrib={'order': "%s" % order, 'type': "filter"}) + etree.SubElement(root, 'match').text = "all" + # Add tag rule + rule = etree.SubElement(root, 'rule', attrib={'field': "tag", 'operator': "is"}) + etree.SubElement(rule, 'value').text = tagname + else: + # Folder + root = etree.Element('node', attrib={'order': "%s" % order, 'type': "folder"}) + + etree.SubElement(root, 'label').text = label + etree.SubElement(root, 'icon').text = "special://home/addons/plugin.video.emby/icon.png" + + return root + + def viewNode(self, indexnumber, tagname, mediatype, viewtype, delete=False): + + kodiversion = self.kodiversion + + if mediatype == "homevideos": + # Treat homevideos as movies + mediatype = "movies" + + tagname = tagname.encode('utf-8') + cleantagname = utils.normalize_nodes(tagname) + if viewtype == "mixed": + dirname = "%s - %s" % (cleantagname, mediatype) + else: + dirname = cleantagname + + path = xbmc.translatePath("special://profile/library/video/").decode('utf-8') + nodepath = xbmc.translatePath( + "special://profile/library/video/Emby - %s/" % dirname).decode('utf-8') + + # Verify the video directory + if not xbmcvfs.exists(path): + shutil.copytree( + src=xbmc.translatePath("special://xbmc/system/library/video/").decode('utf-8'), + dst=xbmc.translatePath("special://profile/library/video/").decode('utf-8')) + xbmcvfs.exists(path) + + # Create the node directory + if not xbmcvfs.exists(nodepath): + # We need to copy over the default items + xbmcvfs.mkdirs(nodepath) + else: + if delete: + dirs, files = xbmcvfs.listdir(nodepath) + for file in files: + xbmcvfs.delete(nodepath + file) + + self.logMsg("Sucessfully removed videonode: %s." % tagname, 1) + return + + # Create index entry + nodeXML = "%sindex.xml" % nodepath + # Set windows property + path = "library://video/Emby - %s/" % dirname + for i in range(1, indexnumber): + # Verify to make sure we don't create duplicates + if utils.window('Emby.nodes.%s.index' % i) == path: + return + + utils.window('Emby.nodes.%s.index' % indexnumber, value=path) + # Root + root = self.commonRoot(order=0, label=dirname, tagname=tagname, roottype=0) + try: + utils.indent(root) + except: pass + etree.ElementTree(root).write(nodeXML) + + + nodetypes = { + + '1': "all", + '2': "recent", + '3': "recentepisodes", + '4': "inprogress", + '5': "inprogressepisodes", + '6': "unwatched", + '7': "nextupepisodes", + '8': "sets", + '9': "genres", + '10': "random", + '11': "recommended" + } + mediatypes = { + # label according to nodetype per mediatype + 'movies': { + '1': tagname, + '2': 30174, + '4': 30177, + '6': 30189, + '8': 20434, + '9': 135, + '10': 30229, + '11': 30230}, + + 'tvshows': { + '1': tagname, + '2': 30170, + '3': 30175, + '4': 30171, + '5': 30178, + '7': 30179, + '9': 135, + '10': 30229, + '11': 30230}, + } + + nodes = mediatypes[mediatype] + for node in nodes: + + nodetype = nodetypes[node] + nodeXML = "%s%s_%s.xml" % (nodepath, cleantagname, nodetype) + # Get label + stringid = nodes[node] + if node != '1': + label = utils.language(stringid) + if not label: + label = xbmc.getLocalizedString(stringid) + else: + label = stringid + + # Set window properties + if nodetype == "nextupepisodes": + # Custom query + path = "plugin://plugin.video.emby/?id=%s&mode=nextup&limit=25" % tagname + elif kodiversion == 14 and nodetype == "recentepisodes": + # Custom query + path = "plugin://plugin.video.emby/?id=%s&mode=recentepisodes&limit=25" % tagname + elif kodiversion == 14 and nodetype == "inprogressepisodes": + # Custom query + path = "plugin://plugin.video.emby/?id=%s&mode=inprogressepisodes&limit=25"% tagname + else: + path = "library://video/Emby - %s/%s_%s.xml" % (dirname, cleantagname, nodetype) + windowpath = "ActivateWindow(Video, %s, return)" % path + + if nodetype == "all": + + if viewtype == "mixed": + templabel = dirname + else: + templabel = label + + embynode = "Emby.nodes.%s" % indexnumber + utils.window('%s.title' % embynode, value=templabel) + utils.window('%s.path' % embynode, value=windowpath) + utils.window('%s.content' % embynode, value=path) + utils.window('%s.type' % embynode, value=mediatype) + else: + embynode = "Emby.nodes.%s.%s" % (indexnumber, nodetype) + utils.window('%s.title' % embynode, value=label) + utils.window('%s.path' % embynode, value=windowpath) + utils.window('%s.content' % embynode, value=path) + + if xbmcvfs.exists(nodeXML): + # Don't recreate xml if already exists + continue + + + # Create the root + if nodetype == "nextupepisodes" or (kodiversion == 14 and + nodetype in ('recentepisodes', 'inprogressepisodes')): + # Folder type with plugin path + root = self.commonRoot(order=node, label=label, tagname=tagname, roottype=2) + etree.SubElement(root, 'path').text = path + etree.SubElement(root, 'content').text = "episodes" + else: + root = self.commonRoot(order=node, label=label, tagname=tagname) + if nodetype in ('recentepisodes', 'inprogressepisodes'): + etree.SubElement(root, 'content').text = "episodes" + else: + etree.SubElement(root, 'content').text = mediatype + + limit = "25" + # Elements per nodetype + if nodetype == "all": + etree.SubElement(root, 'order', {'direction': "ascending"}).text = "sorttitle" + + elif nodetype == "recent": + etree.SubElement(root, 'order', {'direction': "descending"}).text = "dateadded" + etree.SubElement(root, 'limit').text = limit + rule = etree.SubElement(root, 'rule', {'field': "playcount", 'operator': "is"}) + etree.SubElement(rule, 'value').text = "0" + + elif nodetype == "inprogress": + etree.SubElement(root, 'rule', {'field': "inprogress", 'operator': "true"}) + etree.SubElement(root, 'limit').text = limit + + elif nodetype == "genres": + etree.SubElement(root, 'order', {'direction': "ascending"}).text = "sorttitle" + etree.SubElement(root, 'group').text = "genres" + + elif nodetype == "unwatched": + etree.SubElement(root, 'order', {'direction': "ascending"}).text = "sorttitle" + rule = etree.SubElement(root, "rule", {'field': "playcount", 'operator': "is"}) + etree.SubElement(rule, 'value').text = "0" + + elif nodetype == "sets": + etree.SubElement(root, 'order', {'direction': "ascending"}).text = "sorttitle" + etree.SubElement(root, 'group').text = "sets" + + elif nodetype == "random": + etree.SubElement(root, 'order', {'direction': "ascending"}).text = "random" + etree.SubElement(root, 'limit').text = limit + + elif nodetype == "recommended": + etree.SubElement(root, 'order', {'direction': "descending"}).text = "rating" + etree.SubElement(root, 'limit').text = limit + rule = etree.SubElement(root, 'rule', {'field': "playcount", 'operator': "is"}) + etree.SubElement(rule, 'value').text = "0" + rule2 = etree.SubElement(root, 'rule', + attrib={'field': "rating", 'operator': "greaterthan"}) + etree.SubElement(rule2, 'value').text = "7" + + elif nodetype == "recentepisodes": + # Kodi Isengard, Jarvis + etree.SubElement(root, 'order', {'direction': "descending"}).text = "dateadded" + etree.SubElement(root, 'limit').text = limit + rule = etree.SubElement(root, 'rule', {'field': "playcount", 'operator': "is"}) + etree.SubElement(rule, 'value').text = "0" + + elif nodetype == "inprogressepisodes": + # Kodi Isengard, Jarvis + etree.SubElement(root, 'limit').text = "25" + rule = etree.SubElement(root, 'rule', + attrib={'field': "inprogress", 'operator':"true"}) + + try: + utils.indent(root) + except: pass + etree.ElementTree(root).write(nodeXML) + + def singleNode(self, indexnumber, tagname, mediatype, itemtype): + + tagname = tagname.encode('utf-8') + cleantagname = utils.normalize_nodes(tagname) + nodepath = xbmc.translatePath("special://profile/library/video/").decode('utf-8') + nodeXML = "%semby_%s.xml" % (nodepath, cleantagname) + path = "library://video/emby_%s.xml" % (cleantagname) + windowpath = "ActivateWindow(Video, %s, return)" % path + + # Create the video node directory + if not xbmcvfs.exists(nodepath): + # We need to copy over the default items + shutil.copytree( + src=xbmc.translatePath("special://xbmc/system/library/video").decode('utf-8'), + dst=xbmc.translatePath("special://profile/library/video").decode('utf-8')) + xbmcvfs.exists(path) + + labels = { + + 'Favorite movies': 30180, + 'Favorite tvshows': 30181, + 'channels': 30173 + } + label = utils.language(labels[tagname]) + embynode = "Emby.nodes.%s" % indexnumber + utils.window('%s.title' % embynode, value=label) + utils.window('%s.path' % embynode, value=windowpath) + utils.window('%s.content' % embynode, value=path) + utils.window('%s.type' % embynode, value=itemtype) + + if xbmcvfs.exists(nodeXML): + # Don't recreate xml if already exists + return + + if itemtype == "channels": + root = self.commonRoot(order=1, label=label, tagname=tagname, roottype=2) + etree.SubElement(root, 'path').text = "plugin://plugin.video.emby/?id=0&mode=channels" + else: + root = self.commonRoot(order=1, label=label, tagname=tagname) + etree.SubElement(root, 'order', {'direction': "ascending"}).text = "sorttitle" + + etree.SubElement(root, 'content').text = mediatype + + try: + utils.indent(root) + except: pass + etree.ElementTree(root).write(nodeXML) + + def clearProperties(self): + + self.logMsg("Clearing nodes properties.", 1) + embyprops = utils.window('Emby.nodes.total') + propnames = [ + + "index","path","title","content", + "inprogress.content","inprogress.title", + "inprogress.content","inprogress.path", + "nextepisodes.title","nextepisodes.content", + "nextepisodes.path","unwatched.title", + "unwatched.content","unwatched.path", + "recent.title","recent.content","recent.path", + "recentepisodes.title","recentepisodes.content", + "recentepisodes.path","inprogressepisodes.title", + "inprogressepisodes.content","inprogressepisodes.path" + ] + + if embyprops: + totalnodes = int(embyprops) + for i in range(totalnodes): + for prop in propnames: + utils.window('Emby.nodes.%s.%s' % (str(i), prop), clear=True) \ No newline at end of file