From caf810d82e26791d1b15428b234c2f2c348f7b74 Mon Sep 17 00:00:00 2001 From: realcopacetic Date: Fri, 31 May 2024 02:02:44 +0100 Subject: [PATCH] [script.copacetic.helper] 1.1.0 Squashing commits: [script.copacetic.helper] 1.1.0 [script.copacetic.helper] 1.0.18 --- script.copacetic.helper/README.md | 11 +- script.copacetic.helper/addon.xml | 2 +- .../resources/lib/script/actions.py | 22 ++ .../resources/lib/service/art.py | 277 ++++++++++-------- .../resources/lib/service/monitor.py | 7 +- .../resources/lib/utilities.py | 6 +- 6 files changed, 188 insertions(+), 137 deletions(-) diff --git a/script.copacetic.helper/README.md b/script.copacetic.helper/README.md index f8200b593..93afb3754 100644 --- a/script.copacetic.helper/README.md +++ b/script.copacetic.helper/README.md @@ -13,6 +13,15 @@ All code contained in this project is licensed under GPL 3.0. * __jurialmunkey__ for all the best-practice code examples from [plugin.video.themoviedb.helper](https://github.com/jurialmunkey/plugin.video.themoviedb.helper) and forum support. ### Changelog +**.1.1.0** +- Cropper automatically disabled if animation transitions are disabled in Copacetic skin. +- Clearlogo cropper will resize larger crops to 1600x620 max, this is 2x the Kodi standard clearlogo requirement https://kodi.wiki/view/Artwork_types#clearlogo +- SlideshowMonitor() will now check for cropped clearlogos or crop them if no cropped version present +- Additional error handling for images + +**1.0.18** +- Added subtitle_limiter() script, which sets subtitles to the first stream in the desired language if it's available and then toggles between this subtitle stream and 'off'. If the preferred language stream is not available it will toggle through all available subtitles instead. + **1.0.17** - Parse args for script actions to enable values with special characters to be properly escaped from Kodi using '"$INFO[ListItem.Title]"' @@ -41,7 +50,7 @@ All code contained in this project is licensed under GPL 3.0. - Enhanced Slideshow_Monitor class so that it can now fetch fanarts from containers with plugin sources when they are available then use these fanarts in the custom background slideshow. In this way, you can use a custom path to populate a global custom fanart slideshow even without any content in your local library **1.0.10** -- read_fanart() method added in 1.0.10 now triggers on services monitor initialise rather than the first time that the SlideShow monitor is run so it should display backgrounds slightly quicker +- fanart_read() method added in 1.0.10 now triggers on services monitor initialise rather than the first time that the SlideShow monitor is run so it should display backgrounds slightly quicker **1.0.10** - Custom path for Global slideshows can now be refreshed on first entry or on change of path without needing Kodi to restart https://github.com/realcopacetic/script.copacetic.helper/issues/6 diff --git a/script.copacetic.helper/addon.xml b/script.copacetic.helper/addon.xml index f12d59bf0..5eb2a65ec 100644 --- a/script.copacetic.helper/addon.xml +++ b/script.copacetic.helper/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/script.copacetic.helper/resources/lib/script/actions.py b/script.copacetic.helper/resources/lib/script/actions.py index 5aa493477..e0efada79 100644 --- a/script.copacetic.helper/resources/lib/script/actions.py +++ b/script.copacetic.helper/resources/lib/script/actions.py @@ -239,6 +239,28 @@ def shuffle_artist(**kwargs): parent='shuffle_artist') +def subtitle_limiter(lang,**kwargs): + if condition('VideoPlayer.HasSubtitles'): + player = xbmc.Player() + subtitles = [] + current_subtitle = player.getSubtitles() + subtitles = player.getAvailableSubtitleStreams() + if lang not in current_subtitle or condition('!VideoPlayer.SubtitlesEnabled'): + try: + index = subtitles.index(lang) + except ValueError as error: + log( + f'Subtitle Limiter: Error - Preferred subtitle stream ({lang}) not available, toggling through available streams instead --> {error}', force=True) + log_and_execute('Action(NextSubtitle)') + else: + player.setSubtitleStream(index) + log(f'Subtitle Limiter: Switching to subtitle stream {index} in preferred language: {lang}', force=True) + elif condition('VideoPlayer.SubtitlesEnabled'): + log_and_execute('Action(ShowSubtitles)') + else: + log('Subtitle Limiter: Error - Playing video has no subtitles', force=True) + + def toggle_addon(id, **kwargs): if condition(f'System.AddonIsEnabled({id})'): json_call('Addons.SetAddonEnabled', diff --git a/script.copacetic.helper/resources/lib/service/art.py b/script.copacetic.helper/resources/lib/service/art.py index 11b885f89..7e6e3506f 100644 --- a/script.copacetic.helper/resources/lib/service/art.py +++ b/script.copacetic.helper/resources/lib/service/art.py @@ -9,8 +9,9 @@ from resources.lib.utilities import (CROPPED_FOLDERPATH, LOOKUP_XML, TEMP_FOLDERPATH, condition, infolabel, - json_call, log, os, validate_path, - window_property, xbmc, xbmcvfs) + json_call, log, os, url_decode_path, + validate_path, window_property, xbmc, + xbmcvfs) class ImageEditor(): @@ -19,6 +20,7 @@ def __init__(self): self.cropped_folder = CROPPED_FOLDERPATH self.temp_folder = TEMP_FOLDERPATH self.lookup = LOOKUP_XML + self.destination, self.height, self.color, self.luminosity = False, False, False, False def clearlogo_cropper(self, url=False, type='clearlogo', source='ListItem', return_color=False, reporting=window_property, reporting_key=None): # establish clearlogo urls @@ -38,12 +40,11 @@ def clearlogo_cropper(self, url=False, type='clearlogo', source='ListItem', retu url = infolabel(f'{path}.Art({key})') if url: clearlogos[key] = url - # lookup urls in table or run _crop_image() and write values to table + # lookup urls in table or run crop_image() and write values to table lookup_tree = ET.parse(self.lookup) root = lookup_tree.getroot() for key, value in list(clearlogos.items()): self.id = infolabel(f'{path}.dbid') - self.destination, self.height, self.color, self.luminosity = False, False, False, False name = reporting_key or key if value: for node in root.find('clearlogos'): @@ -54,7 +55,7 @@ def clearlogo_cropper(self, url=False, type='clearlogo', source='ListItem', retu self.luminosity = node.find('luminosity').text break else: - self._crop_image(value) + self.crop_image(value) clearlogo = ET.SubElement( root.find('clearlogos'), 'clearlogo') clearlogo.attrib['name'] = value @@ -75,6 +76,42 @@ def clearlogo_cropper(self, url=False, type='clearlogo', source='ListItem', retu reporting(key=f'{name}_cropped-luminosity', set=self.luminosity) + def crop_image(self, url): + # Get image url + filename = f'{hashlib.md5(url.encode()).hexdigest()}.png' + self.destination = os.path.join(self.cropped_folder, filename) + url = self._return_image_path(url, '.png') + # Open and crop, then get height and color + try: + image = self._open_image(url) + except Exception as error: + log( + f'ImageEditor: Error - could not open cached image --> {error}', force=True) + else: + if image.mode == 'LA': # Convert if mode == 'LA' + converted_image = Image.new("RGBA", image.size) + converted_image.paste(image) + image = converted_image + try: + image = image.crop(image.convert('RGBa').getbbox()) + except ValueError as error: + log( + f'ImageEditor: Error - could not convert image due to unsupport mode {image.mode} --> {error}', force=True) + else: + # Resize image to max 1600 x 620, 2x standard kodi size of 800x310 + width, height = image.size + if width > 1600 or height > 620: + image.thumbnail((1600, 620)) + with xbmcvfs.File(self.destination, 'wb') as f: # Save new image + image.save(f, 'PNG') + self._image_functions(image) + log( + f'ImageEditor: Image cropped and saved: {url} --> {self.destination}') + if self.temp_folder in url: # If temp file created, delete it now + xbmcvfs.delete(url) + log(f'ImageEditor: Temporary file deleted --> {url}') + return (self.destination, self.height, self.color, self.luminosity) + def return_luminosity(self, rgb): # Credit to Mark Ransom for luminosity calculation # https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color @@ -90,78 +127,28 @@ def return_luminosity(self, rgb): luminosity = 0.2126 * r + 0.7152 * g + 0.0722 * b return luminosity - def _crop_image(self, url): - filename = f'{hashlib.md5(url.encode()).hexdigest()}.png' - self.destination = os.path.join(self.cropped_folder, filename) - # If crop exists, open to get height and color - if validate_path(self.destination): - image = self._open_image(self.destination) - self._image_functions(image) - # else get image url, open and crop, then get height and color - else: - url = self._return_image_path(url, '.png') - try: - image = self._open_image(url) - except Exception as error: - log( - f'ImageEditor: Error - could not open cached image --> {error}', force=True) - else: - if image.mode == 'LA': # Convert if mode == 'LA' - converted_image = Image.new("RGBA", image.size) - converted_image.paste(image) - image = converted_image - try: - image = image.crop(image.convert('RGBa').getbbox()) - except ValueError as error: - log( - f'ImageEditor: Error - could not convert image due to unsupport mode {image.mode} --> {error}', force=True) - else: - with xbmcvfs.File(self.destination, 'wb') as f: - image.save(f, 'PNG') - self._image_functions(image) - log( - f'ImageEditor: Image cropped and saved: {url} --> {self.destination}') - if self.temp_folder in url: # If temp file created, delete it now - xbmcvfs.delete(url) - log(f'ImageEditor: Temporary file deleted --> {url}') - - def _return_image_path(self, source, suffix): - # Use source URL to generate cached url. If cached url doesn't exist, return source url - cleaned_source = self.url_decode_path(source) - cached_thumb = xbmc.getCacheThumbName( - cleaned_source).replace('.tbn', '') - cached_url = os.path.join( - 'special://profile/Thumbnails/', f'{cached_thumb[0]}/', cached_thumb + suffix) - if validate_path(cached_url): - return cached_url - else: - # Create temp file to avoid access issues to direct source - filename = f'{hashlib.md5(cleaned_source.encode()).hexdigest()}.png' - destination = os.path.join(self.temp_folder, filename) - if not validate_path(destination): - xbmcvfs.copy(cleaned_source, destination) - log(f'ImageEditor: Temporary file created --> {destination}') - return destination - - def url_decode_path(self, path): - path = path[:-1] if path.endswith('/') else path - path = urllib.unquote(path.replace('image://', '')) - return path - - def _open_image(self, url): - image = Image.open(xbmcvfs.translatePath(url)) - return image - def _image_functions(self, image): self.height = self._return_scaled_height(image) self.color, self.luminosity = self._return_dominant_color(image) image.close() - def _return_scaled_height(self, image): - image.thumbnail(self.clearlogo_bbox) - size = image.size - height = size[1] - return height + def _open_image(self, url): + image = Image.open(xbmcvfs.translatePath(url)) + return image + + def _return_average_color(self, image): + h = image.histogram() + # split into red, green, blue + r = h[0:256] + g = h[256:256*2] + b = h[256*2: 256*3] + # perform the weighted average of each channel: + # the *index* is the channel value, and the *value* is its weight + return ( + sum(i*w for i, w in enumerate(r)) / sum(r), + sum(i*w for i, w in enumerate(g)) / sum(g), + sum(i*w for i, w in enumerate(b)) / sum(b) + ) def _return_dominant_color(self, image): width, height = 75, 30 @@ -197,25 +184,39 @@ def _return_dominant_color(self, image): dominant = self._rgb_to_hex(dominant) return (dominant, luminosity) + def _return_image_path(self, source, suffix): + # Use source URL to generate cached url. If cached url doesn't exist, return source url + cleaned_source = url_decode_path(source) + cached_thumb = xbmc.getCacheThumbName( + cleaned_source).replace('.tbn', '') + cached_url = os.path.join( + 'special://profile/Thumbnails/', f'{cached_thumb[0]}/', cached_thumb + suffix) + if validate_path(cached_url): + return cached_url + else: + # Create temp file to avoid access issues to direct source + filename = f'{hashlib.md5(cleaned_source.encode()).hexdigest()}.png' + destination = os.path.join(self.temp_folder, filename) + if not validate_path(destination): + xbmcvfs.copy(cleaned_source, destination) + log(f'ImageEditor: Temporary file created --> {destination}') + return destination + + def _return_scaled_height(self, image): + image.thumbnail(self.clearlogo_bbox) + try: + size = image.size + except AttributeError: + height = False + else: + height = size[1] + return height + def _rgb_to_hex(self, rgb): red, green, blue = rgb hex = 'ff%02x%02x%02x' % (red, green, blue) return hex - def _return_average_color(self, image): - h = image.histogram() - # split into red, green, blue - r = h[0:256] - g = h[256:256*2] - b = h[256*2: 256*3] - # perform the weighted average of each channel: - # the *index* is the channel value, and the *value* is its weight - return ( - sum(i*w for i, w in enumerate(r)) / sum(r), - sum(i*w for i, w in enumerate(g)) / sum(g), - sum(i*w for i, w in enumerate(b)) / sum(b) - ) - class SlideshowMonitor: def __init__(self): @@ -229,13 +230,13 @@ def __init__(self): 'Skin.String(Background_Slideshow_Custom_Path)') self.refresh_count = self.refresh_interval = self._get_refresh_interval() self.fetch_count = self.fetch_interval = self.refresh_interval * 40 + self._crop_image = ImageEditor().crop_image def background_slideshow(self): # If refresh interval has been adjusted in skin settings if self.refresh_interval != self._get_refresh_interval(): self.refresh_interval = self._get_refresh_interval() self.fetch_interval = self.refresh_interval * 40 - # Capture plugin art if it's available and on_next_run flag is true if 'plugin://' in self.custom_path: self.custom_source = 'plugin' @@ -250,8 +251,7 @@ def background_slideshow(self): if condition( 'Integer.IsGreater(Container(3300).NumItems,0)' ) and not 'library' in self.custom_source and self.on_next_run_flag: - self._get_external_arts() - + self._get_art_external() # Fech art every 40 x refresh interval, reset if custom path changes if self.fetch_count >= self.fetch_interval or self.custom_path != infolabel('Skin.String(Background_Slideshow_Custom_Path)'): self.custom_path = infolabel( @@ -263,7 +263,6 @@ def background_slideshow(self): self.fetch_count = 1 else: self.fetch_count += 1 - # Set art every refresh interval if self.refresh_count >= self.refresh_interval: for type in self.art_types: @@ -272,8 +271,31 @@ def background_slideshow(self): self.refresh_count = 1 else: self.refresh_count += 1 - - def read_fanart(self): + + def _crop_clearlogo(self, url): + lookup_tree = ET.parse(self.lookup) + root = lookup_tree.getroot() + for node in root.find('clearlogos'): + if url in node.attrib['name'] and validate_path(node.find('path').text): + path = node.find('path').text + return path + else: + crop_destination, crop_height, crop_color, crop_luminosity = self._crop_image(url) + clearlogo = ET.SubElement( + root.find('clearlogos'), 'clearlogo') + clearlogo.attrib['name'] = url + path = ET.SubElement(clearlogo, 'path') + path.text = crop_destination + height = ET.SubElement(clearlogo, 'height') + height.text = str(crop_height) + color = ET.SubElement(clearlogo, 'color') + color.text = crop_color + luminosity = ET.SubElement(clearlogo, 'luminosity') + luminosity.text = str(crop_luminosity) + lookup_tree.write(self.lookup, encoding="utf-8") + return crop_destination + + def fanart_read(self): lookup_tree = ET.parse(self.lookup) root = lookup_tree.getroot() for type in self.art_types: @@ -287,49 +309,29 @@ def read_fanart(self): ET.SubElement(root, 'backgrounds') lookup_tree.write(self.lookup, encoding="utf-8") - def write_art(self): + def fanart_write(self): lookup_tree = ET.parse(self.lookup) root = lookup_tree.getroot() for type in self.art_types: - current_fanart = infolabel(f'Window(home).Property(background_{type}_fanart)') + current_fanart = infolabel( + f'Window(home).Property(background_{type}_fanart)') for node in root.find('backgrounds'): if type in node.attrib['type']: background = node.find('path') background.text = current_fanart break - else: - background = ET.SubElement(root.find('backgrounds'), 'background') + else: + background = ET.SubElement( + root.find('backgrounds'), 'background') background.attrib['type'] = type path = ET.SubElement(background, 'path') path.text = current_fanart lookup_tree.write(self.lookup, encoding="utf-8") - def _get_external_arts(self): - if self.on_next_run_flag: - self.art['custom'] = [] - num_items = int(infolabel('Container(3300).NumItems')) - for i in range(num_items): - fanart = infolabel( - f'Container(3300).ListItem({i}).Art(fanart)') - if not fanart and 'other' in self.custom_source: - fanart = infolabel( - f'Container(3300).ListItem({i}).Art(thumb)') - if fanart: - item = { - 'title': infolabel( - f'Container(3300).ListItem({i}).Label'), - 'fanart': fanart, - 'clearlogo': infolabel( - f'Container(3300).ListItem({i}).Art(clearlogo)') - } - self.art['custom'].append(item) - self.on_next_run_flag = False - def _get_art(self): self.art = {} for type in self.art_types: self.art[type] = [] - # Populate custom path/playlist slideshow if selected in skin settings if self.custom_path and 'library' in self.custom_source and condition('Skin.String(Background_Slideshow,Custom)'): query = json_call('Files.GetDirectory', @@ -352,7 +354,6 @@ def _get_art(self): self.art['custom'].append(data) except KeyError: pass - # Populate video and music slidshows from library for item in ['movies', 'tvshows', 'artists']: dbtype = 'Video' if item != 'artists' else 'Audio' @@ -367,13 +368,32 @@ def _get_art(self): except KeyError: pass self.art['videos'] = self.art['movies'] + self.art['tvshows'] - # Populate global slideshow for list in self.art: if self.art[list]: self.art['global'] = self.art['global'] + self.art[list] return self.art + def _get_art_external(self): + self.art['custom'] = [] + num_items = int(infolabel('Container(3300).NumItems')) + for i in range(num_items): + fanart = infolabel( + f'Container(3300).ListItem({i}).Art(fanart)') + if not fanart and 'other' in self.custom_source: + fanart = infolabel( + f'Container(3300).ListItem({i}).Art(thumb)') + if fanart: + item = { + 'title': infolabel( + f'Container(3300).ListItem({i}).Label'), + 'fanart': fanart, + 'clearlogo': infolabel( + f'Container(3300).ListItem({i}).Art(clearlogo)') + } + self.art['custom'].append(item) + self.on_next_run_flag = False + def _get_refresh_interval(self): try: self.refresh_interval_check = int( @@ -389,20 +409,17 @@ def _set_art(self, key, items): fanarts = {key: value for ( key, value) in art.items() if 'fanart' in key} fanart = random.choice(list(fanarts.values())) - fanart = self._url_decode_path(fanart) + fanart = url_decode_path(fanart) if 'transform?size=thumb' in fanart: fanart = fanart[:-21] window_property(f'{key}_fanart', set=fanart) # clearlogo if present otherwise clear - clearlogo = art.get('clearlogo', False) - if clearlogo: - clearlogo = self._url_decode_path(clearlogo) + clearlogo = art.get('clearlogo-billboard', False) + if not clearlogo: + clearlogo = art.get('clearlogo', False) + if clearlogo and condition('!Skin.HasSetting(Experiment_Disable_Transitions)'): + clearlogo = url_decode_path(clearlogo) + clearlogo = self._crop_clearlogo(clearlogo) window_property(f'{key}_clearlogo', set=clearlogo) # title window_property(f'{key}_title', set=art.get('title', False)) - - def _url_decode_path(self, path): - path = path[:-1] if path.endswith('/') else path - path = path.replace('image://', '') - path = urllib.unquote(path.replace('image://', '')) - return path \ No newline at end of file diff --git a/script.copacetic.helper/resources/lib/service/monitor.py b/script.copacetic.helper/resources/lib/service/monitor.py index 41b4c7cc6..0b0601549 100644 --- a/script.copacetic.helper/resources/lib/service/monitor.py +++ b/script.copacetic.helper/resources/lib/service/monitor.py @@ -57,7 +57,7 @@ def _on_start(self): log('Monitor started', force=True) self.start = False self.player_monitor = PlayerMonitor() - self.art_monitor.read_fanart() + self.art_monitor.fanart_read() else: log('Monitor resumed', force=True) if self._conditions_met() else None while not self.abortRequested() and self._conditions_met(): @@ -181,7 +181,6 @@ def poller(self): self._on_skinsettings() self._on_recommendedsettings() self.waitForAbort(1) - # else wait for next poll else: self.check_cache = True @@ -196,7 +195,7 @@ def _on_scroll(self, key='ListItem', crop=True, return_color=True, get_info=Fals current_dbid != self.dbid or current_dbtype != self.dbtype ) and not self._container_scrolling(key): - if crop: + if crop and condition('!Skin.HasSetting(Experiment_Disable_Transitions)'): self._clearlogo_cropper( source=key, return_color=return_color, reporting=window_property) if get_info: @@ -230,7 +229,7 @@ def _on_stop(self): if not self.abortRequested(): self._on_start() else: - self.art_monitor.write_art() + self.art_monitor.fanart_write() del self.player_monitor del self.settings_monitor del self.art_monitor diff --git a/script.copacetic.helper/resources/lib/utilities.py b/script.copacetic.helper/resources/lib/utilities.py index ab4af0678..8c8cad3d1 100644 --- a/script.copacetic.helper/resources/lib/utilities.py +++ b/script.copacetic.helper/resources/lib/utilities.py @@ -232,7 +232,11 @@ def split_random_return(string, **kwargs): window_property(name, set=random) return random - + +def url_decode_path(path): + path = path[:-1] if path.endswith('/') else path + path = urllib.unquote(path.replace('image://', '')) + return path def window_property(key, set=False, clear=False, window_id=10000, debug=False): window = Window(window_id)