diff --git a/script.module.bossanova808/README.md b/script.module.bossanova808/README.md index d00e5a03fa..38245dffdb 100644 --- a/script.module.bossanova808/README.md +++ b/script.module.bossanova808/README.md @@ -7,21 +7,22 @@ _script.module.bossanova808_ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/bossanova808) -Common code for all bossanova808 Kodi addons, including: +Common code for all bossanova808 Kodi addons. -**Available from the Kodi Official Repository:** +## Available from the Kodi Official Repository -* OzWeather -* Unpause Jumpback -* Playback Resumer -* Check Previous Episode -* Caber Toss +* [OzWeather](https://kodi.wiki/view/Add-on:Oz_Weather) +* [Unpause Jumpback](https://kodi.wiki/view/Add-on:Unpause_Jumpback) +* [Playback Resumer](https://kodi.wiki/view/Add-on:Kodi_Playback_Resumer) +* [Check Previous Episode](https://kodi.wiki/view/Add-on:XBMC_Check_Previous_Episode) +* [Caber Toss](https://kodi.wiki/view/Add-on:Caber_Toss) +* [Switchback](https://kodi.wiki/view/Add-on:Switchback) -**Available from the [Bossanova808 Repository](https://github.com/bossanova808/repository.bossanova808):** +## Available from the [Bossanova808 Repository](https://github.com/bossanova808/repository.bossanova808) -* OzWeather Skin Patcher -* YoctoDisplay +* [OzWeather Skin Patcher](https://github.com/bossanova808/repository.bossanova808) +* [YoctoDisplay](https://github.com/bossanova808/repository.bossanova808) diff --git a/script.module.bossanova808/addon.xml b/script.module.bossanova808/addon.xml index d1506a59ce..30687f2d4d 100644 --- a/script.module.bossanova808/addon.xml +++ b/script.module.bossanova808/addon.xml @@ -1,16 +1,17 @@ - + - Common code needed by bossanova808 addons + Common code needed by Bossanova808 addons all GPL-3.0-only https://github.com/bossanova808/script.module.bossanova808 https://github.com/bossanova808/script.module.bossanova808 - v1.0.0 Initial release + v1.0.1 +- Updates for Piers and some defensive programming resources/icon.png diff --git a/script.module.bossanova808/changelog.txt b/script.module.bossanova808/changelog.txt index 1a647a4532..00f850143c 100644 --- a/script.module.bossanova808/changelog.txt +++ b/script.module.bossanova808/changelog.txt @@ -1,3 +1,9 @@ +v1.0.1 +- Updates for Piers and some defensive programming + +v1.0.0 +- Initial release + v0.0.1 -Alpha pre-release +- Alpha pre-release diff --git a/script.module.bossanova808/resources/lib/bossanova808/constants.py b/script.module.bossanova808/resources/lib/bossanova808/constants.py index b5dd8b504d..a6cb71d9a5 100644 --- a/script.module.bossanova808/resources/lib/bossanova808/constants.py +++ b/script.module.bossanova808/resources/lib/bossanova808/constants.py @@ -1,12 +1,11 @@ -# -*- coding: utf-8 -*- - import sys +import re import xbmc import xbmcvfs import xbmcgui import xbmcaddon -""" Just a bunch of very handy Kodi constants """ +""" Handy Kodi constants """ ADDON = xbmcaddon.Addon() ADDON_NAME = ADDON.getAddonInfo('name') @@ -15,12 +14,16 @@ ADDON_AUTHOR = ADDON.getAddonInfo('author') ADDON_VERSION = ADDON.getAddonInfo('version') ADDON_ARGUMENTS = f'{sys.argv}' -CWD = ADDON.getAddonInfo('path') -LANGUAGE = ADDON.getLocalizedString -PROFILE = xbmcvfs.translatePath(ADDON.getAddonInfo('profile')) +ADDON_SETTINGS_PATH = PROFILE = xbmcvfs.translatePath(ADDON.getAddonInfo('profile')) +ADDON_PATH = CWD = xbmcvfs.translatePath(ADDON.getAddonInfo('path')) +TRANSLATE = LANGUAGE = ADDON.getLocalizedString LOG_PATH = xbmcvfs.translatePath('special://logpath') KODI_VERSION = xbmc.getInfoLabel('System.BuildVersion') -KODI_VERSION_INT = int(KODI_VERSION.split(".")[0]) +# Parse System.BuildVersion (e.g. "21.0", "21.0-Beta1", "21.0 (20.90.801)") if possible, otherwise default to 21 (Omega) +# (Have never seen the parsing fail - in practice, the fallback to 21 only happens when unit testing modules outside of Kodi) +version_label = KODI_VERSION or '' +version_match = re.search(r'\d+', version_label) +KODI_MAJOR_VERSION = int(version_match.group(0)) if version_match else 21 USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36" HOME_WINDOW = xbmcgui.Window(10000) WEATHER_WINDOW = xbmcgui.Window(12600) diff --git a/script.module.bossanova808/resources/lib/bossanova808/exception_logger.py b/script.module.bossanova808/resources/lib/bossanova808/exception_logger.py index e925d6da83..62a4d3d8e4 100644 --- a/script.module.bossanova808/resources/lib/bossanova808/exception_logger.py +++ b/script.module.bossanova808/resources/lib/bossanova808/exception_logger.py @@ -1,7 +1,5 @@ -# coding: utf-8 - # (c) Roman Miroshnychenko 2020 -# Retrieved from: +# Originally retrieved from: # https://github.com/thetvdb/metadata.tvshows.thetvdb.com.v4.python/blob/master/metadata.tvshows.thetvdb.com.v4.python/resources/lib/exception_logger.py # (Thanks Roman! - Modified by bossanova808 as needed...) # @@ -21,13 +19,15 @@ """Exception logger with extended diagnostic info""" import inspect -import sys from contextlib import contextmanager from platform import uname from pprint import pformat from typing import Text, Callable, Generator +import sys + +# noinspection PyUnresolvedReferences import xbmc -from .constants import * +# noinspection PyPackages from .logger import Logger @@ -118,20 +118,20 @@ def log_exception(logger_func=Logger.error): - Python version - Kodi version - Module path. - - Stack trace including: + - Stack trace including * File path and line number where the exception happened * Code fragment where the exception has happened. * Local variables at the moment of the exception. After logging the diagnostic info the exception is re-raised. - Example:: + Example: with debug_exception(): # Some risky code raise RuntimeError('Fatal error!') - :param logger_func: logger function that accepts a single argument + :param logger_func: Logger function that accepts a single argument that is a log message. """ try: diff --git a/script.module.bossanova808/resources/lib/bossanova808/logger.py b/script.module.bossanova808/resources/lib/bossanova808/logger.py index df95647920..1c2976be0b 100644 --- a/script.module.bossanova808/resources/lib/bossanova808/logger.py +++ b/script.module.bossanova808/resources/lib/bossanova808/logger.py @@ -1,65 +1,99 @@ -# -*- coding: utf-8 -*- +from pprint import pprint, pformat +import sys import xbmc -from .constants import * + +from typing import Any +# noinspection PyPackages +from .constants import ADDON_NAME, ADDON_VERSION, KODI_VERSION, KODI_MAJOR_VERSION, ADDON_ARGUMENTS class Logger: @staticmethod - def log(message, level=xbmc.LOGDEBUG): + def log(message: Any, level: int = xbmc.LOGDEBUG) -> None: """ - Log a message to the Kodi log file. - If we're unit testing a module outside Kodi, print to the console instead. + Logs a message using the Kodi logging system. If the user agent is unavailable + (e.g. during unit testing), it will print the message to the console using pprint. - :param message: the message to log - :param level: the kodi log level to log at, default xbmc.LOGDEBUG - :return: + :param message: The message to be logged. If the message is not a string, it will + be formatted using `pformat` before logging. + :param level: The log level for the message, default `xbmc.LOGDEBUG`. """ - # + # (The below test will fail if we're unit testing a module) if xbmc.getUserAgent(): - xbmc.log(f'### {ADDON_NAME} {ADDON_VERSION}: {str(message)}', level) + if isinstance(message, str): + xbmc.log(f'### {ADDON_NAME.replace("Kodi ","")} {ADDON_VERSION}: {message}', level) + else: + xbmc.log(pformat(message), level) else: - print(str(message)) + # ONLY USED WHEN UNIT TESTING A MODULE! + pprint(message) @staticmethod - def info(message): + def info(*messages: Any) -> None: """ - Log a message to the Kodi log file at INFO level. + Log messages to the Kodi log file at the INFO level. - :param message: the message to log - :return: + :param messages: The messages to log """ - Logger.log(message, xbmc.LOGINFO) + for message in messages: + Logger.log(message, xbmc.LOGINFO) @staticmethod - def warning(message): + def warning(*messages: Any) -> None: """ - Log a message to the Kodi log file at WARNING level. + Log messages to the Kodi log file at the WARNING level. - :param message: the message to log - :return: + :param messages: The messages to log """ - Logger.log(message, xbmc.LOGWARNING) + for message in messages: + Logger.log(message, xbmc.LOGWARNING) @staticmethod - def error(message): + def error(*messages: Any) -> None: """ - Log a message to the Kodi log file at ERROR level. + Log messages to the Kodi log file at the ERROR level. - :param message: the message to log - :return: + :param messages: The messages to log """ - Logger.log(message, xbmc.LOGERROR) + for message in messages: + Logger.log(message, xbmc.LOGERROR) @staticmethod - def debug(*messages): + def debug(*messages: Any) -> None: """ Log messages to the Kodi log file at DEBUG level. - :param messages: the message(s) to log - :return: + :param messages: The message(s) to log """ for message in messages: Logger.log(message, xbmc.LOGDEBUG) + @staticmethod + def start(*extra_messages: Any) -> None: + """ + Log key information at the start of an addon run. + + :param extra_messages: Any extra things to log, such as "(Service)" or "(Plugin)" if it helps to identify component elements. + """ + Logger.info(f'Start {ADDON_NAME}') + if extra_messages: + Logger.info(*extra_messages) + Logger.info(f'Kodi {KODI_VERSION} (Major version {KODI_MAJOR_VERSION})') + Logger.info(f'Python {sys.version}') + if ADDON_ARGUMENTS != "['']": + Logger.info(f'Run {ADDON_ARGUMENTS}') + else: + Logger.info('No arguments supplied to addon') + + @staticmethod + def stop(*extra_messages: Any) -> None: + """ + Log key information at the end of an addon run. + + :param extra_messages: Any extra things to log, such as "(Service)" or "(Plugin)" if it helps to identify component elements. + """ + Logger.info(f'Finish {ADDON_NAME}') + if extra_messages: + Logger.info(*extra_messages) diff --git a/script.module.bossanova808/resources/lib/bossanova808/notify.py b/script.module.bossanova808/resources/lib/bossanova808/notify.py index da5af0693f..7172491f4c 100644 --- a/script.module.bossanova808/resources/lib/bossanova808/notify.py +++ b/script.module.bossanova808/resources/lib/bossanova808/notify.py @@ -1,57 +1,52 @@ -# -*- coding: utf-8 -*- - +# noinspection PyUnresolvedReferences import xbmcgui -from .constants import * +# noinspection PyPackages +from .constants import ADDON_NAME class Notify: @staticmethod - def kodi_notification(message, duration=5000, icon=xbmcgui.NOTIFICATION_INFO): + def kodi_notification(message: str, duration: int = 5000, icon: str = xbmcgui.NOTIFICATION_INFO) -> None: """ Send a custom notification to the user via the Kodi GUI :param message: the message to send :param duration: time to display notification in milliseconds, default 5000 - :param icon: xbmcgui.NOTIFICATION_INFO (default), xbmcgui.NOTIFICATION_WARNING, or xbmcgui.NOTIFICATION_ERROR (or custom icon) - :return: None + :param icon: xbmcgui.NOTIFICATION_INFO (default), xbmcgui.NOTIFICATION_WARNING, or xbmcgui.NOTIFICATION_ERROR, or custom icon """ dialog = xbmcgui.Dialog() - dialog.notification(heading=ADDON_NAME, message=message, icon=icon, time=duration) @staticmethod - def info(message, duration=5000): + def info(message: str, duration: int = 5000) -> None: """ Send an info level notification to the user via the Kodi GUI :param message: the message to display - :param duration: the duration to show the message, default 5000ms - :return: + :param duration: the duration to show the message, default 5000 ms """ Notify.kodi_notification(message, duration, xbmcgui.NOTIFICATION_INFO) @staticmethod - def warning(message, duration=5000): + def warning(message: str, duration: int = 5000) -> None: """ Send a warning notification to the user via the Kodi GUI :param message: the message to display - :param duration: the duration to show the message, default 5000ms - :return: + :param duration: the duration to show the message, default 5000 ms """ Notify.kodi_notification(message, duration, xbmcgui.NOTIFICATION_WARNING) @staticmethod - def error(message, duration=5000): + def error(message: str, duration: int = 5000) -> None: """ Send an error level notification to the user via the Kodi GUI :param message: the message to display - :param duration: the duration to show the message, default 5000ms - :return: + :param duration: the duration to show the message, default 5000 ms """ - Notify.kodi_notification(message, duration, xbmcgui.NOTIFICATION_ERROR) \ No newline at end of file + Notify.kodi_notification(message, duration, xbmcgui.NOTIFICATION_ERROR) diff --git a/script.module.bossanova808/resources/lib/bossanova808/utilities.py b/script.module.bossanova808/resources/lib/bossanova808/utilities.py index 4d528a85b1..5ece1f0b36 100644 --- a/script.module.bossanova808/resources/lib/bossanova808/utilities.py +++ b/script.module.bossanova808/resources/lib/bossanova808/utilities.py @@ -1,107 +1,227 @@ -# -*- coding: utf-8 -*- - import json -from .constants import * +import re +import xbmc +import xbmcgui +import xbmcvfs +import xml.etree.ElementTree as ElementTree +from urllib.parse import unquote +from typing import Any + +# noinspection PyPackages +from .constants import ADDON +# noinspection PyPackages from .logger import Logger -def set_property(window, name, value=""): +def set_property(window: xbmcgui.Window, name: str, value: str | None = None) -> None: """ Set a property on a window. - To clear a property, provide an empty string + To clear a property use clear_property() - :param window: Required. The Kodi window on which to set the property. - :param name: Required. Name of the property. - :param value: Optional (defaults to ""). Set the property to this value. An empty string clears the property. + :param window: The Kodi window on which to set the property. + :param name:Name of the property. + :param value: Optional (default None). Set the property to this value. An empty string, or None, clears the property, but better to use clear_property(). """ if value is None: window.clearProperty(name) + return value = str(value) if value: Logger.debug(f'Setting window property {name} to value {value}') window.setProperty(name, value) else: - Logger.debug(f'Clearing window property {name}') - window.clearProperty(name) + clear_property(window, name) + + +def clear_property(window: xbmcgui.Window, name: str) -> None: + """ + Clear a property on a window. + + :param window: + :param name: + """ + Logger.debug(f'Clearing window property {name}') + window.clearProperty(name) -def get_property(window, name): +def get_property(window: xbmcgui.Window, name: str) -> str | None: """ Return the value of a window property :param window: the Kodi window to get the property value from :param name: the name of the property to get - :return: the value of the window property + :return: the value of the window property, or None if not set """ - return window.getProperty(name) + value = window.getProperty(name) + return value if value != "" else None -def get_property_as_bool(window, name): +def get_property_as_bool(window: xbmcgui.Window, name: str) -> bool | None: """ Return the value of a window property as a boolean :param window: the Kodi window to get the property value from :param name: the name of the property to get - :return: the value of the window property in boolean form + :return: the value of the window property in boolean form, or None if not set """ - return window.getProperty(name).lower() == "true" + value = get_property(window, name) + if value is None: + return None + lowered = value.strip().lower() + if lowered in ("true", "1", "yes", "on"): + return True + if lowered in ("false", "0", "no", "off"): + return False + return None -def send_kodi_json(human_description, json_string): +def send_kodi_json(human_description: str, json_dict_or_string: str | dict) -> dict | None: """ Send a JSON command to Kodi, logging the human description, command, and result as returned. - :param human_description: Required. A human sensible description of what the command is aiming to do/retrieve. - :param json_string: Required. The json command to send. - :return: the json object loaded from the result string + :param human_description: A textual description of the command being sent to KODI. Helpful for debugging. + :param json_dict_or_string: The JSON RPC command to be sent to KODI, as a dict or string + :return: Parsed KODI JSON response as a dict. Returns None only if parsing fails. On RPC errors, a dict containing an "error" key is returned. """ - Logger.debug(f'KODI JSON RPC command: {human_description} [{json_string}]') - result = xbmc.executeJSONRPC(json_string) - Logger.debug(f'KODI JSON RPC result: {result}') - return json.loads(result) + Logger.debug(f'KODI JSON RPC command: {human_description}', json_dict_or_string) + if isinstance(json_dict_or_string, dict): + json_dict_or_string = json.dumps(json_dict_or_string) + result = xbmc.executeJSONRPC(json_dict_or_string) + try: + result = json.loads(result) + except json.JSONDecodeError: + Logger.error('Unable to parse JSON RPC result from KODI:', result) + return None + if isinstance(result, dict) and 'error' in result: + Logger.error(f'KODI JSON RPC error for {human_description}:', result) + return result + Logger.debug('KODI JSON RPC result:', result) + return result -def get_setting(setting): + +def get_setting(setting: str) -> str | None: """ - Helper function to get string type from settings + Helper function to get an addon setting :param setting: The addon setting to return - :return: the setting value + :return: the setting value, or None if not found """ - return ADDON.getSetting(setting).strip() + value = ADDON.getSetting(setting).strip() + return value or None -def get_setting_as_bool(setting): +def get_setting_as_bool(setting: str) -> bool | None: """ Helper function to get bool type from settings :param setting: The addon setting to return - :return: the setting value as boolean + :return: the setting value as boolean, or None if not found + """ + value = get_setting(setting) + if value is None: + return None + lowered = value.lower() + if lowered in ("true", "1", "yes", "on"): + return True + if lowered in ("false", "0", "no", "off"): + return False + return None + + +def get_kodi_setting(setting: str) -> Any | None: + """ + Get a Kodi setting value - for settings, see https://github.com/xbmc/xbmc/blob/18f70e7ac89fd502b94b8cd8db493cc076791f39/system/settings/settings.xml + + :param setting: the Kodi setting to return + :return: The value of the Kodi setting (remember to cast this to the appropriate type before use!) + """ + json_dict = {"jsonrpc":"2.0", "method":"Settings.GetSettingValue", "params":{"setting":setting}, "id":1} + properties_json = send_kodi_json(f'Get Kodi setting {setting}', json_dict) + if not properties_json: + Logger.error(f"Settings.GetSettingValue returned no response for [{setting}]") + return None + if 'error' in properties_json: + Logger.error(f"Settings.GetSettingValue returned error for [{setting}]:", properties_json['error']) + return None + if 'result' not in properties_json: + Logger.error(f"Settings.GetSettingValue returned no result for [{setting}]") + return None + return properties_json['result'].get('value') + + +def get_advancedsetting(setting_path: str) -> str | None: + """ + Helper function to extract a setting from Kodi's advancedsettings.xml file, + Remember: cast the result appropriately and provide the Kodi default value as a fallback if the setting is not found. + E.g.:: + Store.ignore_seconds_at_start = int(get_advancedsetting('./video/ignoresecondsatstart')) or 180 + + :param setting_path: The advanced setting, in 'section/setting' (i.e. path) form, to look for (e.g. video/ignoresecondsatstart) + :return: The setting value if found, None if not found/advancedsettings.xml doesn't exist + """ + advancedsettings_file = xbmcvfs.translatePath("special://profile/advancedsettings.xml") + + if not xbmcvfs.exists(advancedsettings_file): + return None + + root = None + try: + root = ElementTree.parse(advancedsettings_file).getroot() + Logger.info("Found and parsed advancedsettings.xml") + + except IOError: + Logger.warning("Found, but could not read advancedsettings.xml") + except ElementTree.ParseError: + Logger.warning("Found, but could not parse advancedsettings.xml") + return None + + # If we couldn't obtain a root element, bail out safely + if root is None: + return None + # Normalise: accept either 'section/setting' or './section/setting' + normalised_path = setting_path if setting_path.startswith('.') else f'./{setting_path.lstrip("./")}' + setting_element = root.find(normalised_path) + + if setting_element is not None: + text = (setting_element.text or "").strip() + return text or None + + Logger.debug(f"Setting [{setting_path}] not found in advancedsettings.xml") + return None + + +def clean_art_url(kodi_url: str) -> str: + """ + Return a cleaned, HTML-unquoted version of the art url, removing any pre-pended Kodi stuff and any trailing slash + + :param kodi_url: + :return: cleaned url string """ - return get_setting(setting).lower() == "true" + cleaned_url = unquote(kodi_url).replace("image://", "").rstrip("/") + cleaned_url = re.sub(r'^.*?@', '', cleaned_url) # pre-pended video@, pvrchannel_tv@, pvrrecording@ etc + return cleaned_url -def is_playback_paused(): +def is_playback_paused() -> bool: """ Helper function to return Kodi player state. - (Odd this is needed, it should be a testable state on Player really...) + (Odd that this is needed, it should be a testable state on Player really...) - :return: boolean indicating player paused state + :return: Boolean indicating the player paused state """ return bool(xbmc.getCondVisibility("Player.Paused")) -def footprints(startup=True): +def footprints(startup: bool = True) -> None: """ - Log the startup/exit of an addon, and key Kodi details that are helpful for debugging + TODO - this has moved to Logger - update all addons to use Logger.start/.stop directly, then ultimately remove this! + Log the startup/exit of an addon and key Kodi details that are helpful for debugging - :param startup: optional, default True. If true, log the startup of an addon, otherwise log the exit. + :param startup: Optional, default True. If true, log the startup of an addon, otherwise log the exit. """ if startup: - Logger.info(f'Start.') - Logger.info(f'Kodi {KODI_VERSION}') - Logger.info(f'Python {sys.version}') - Logger.info(f'Run {ADDON_ARGUMENTS}') + Logger.start() else: - Logger.info(f'Finish.') + Logger.stop()