diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..bf48c70 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,7 @@ +reviews: + review_instructions: | + CRITICAL: Always present code changes in before/after format for easy copy-pasting. NEVER show diffs under any circumstances. Use clear "Before:" and "After:" code blocks only. + poem: false + in_progress_fortune: false +chat: + art: false diff --git a/plugin.py b/plugin.py index b99c35c..73d39f6 100644 --- a/plugin.py +++ b/plugin.py @@ -1,9 +1,7 @@ -import sys - from bossanova808 import exception_logger from resources.lib import switchback_plugin if __name__ == "__main__": with exception_logger.log_exception(): - switchback_plugin.run(sys.argv[1:]) + switchback_plugin.run() diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 3932eaf..a559e2e 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -43,4 +43,4 @@ msgstr "" msgctxt "#32009" msgid "Enable Switchback context menu items?" -msgstr "" \ No newline at end of file +msgstr "" diff --git a/resources/lib/playback.py b/resources/lib/playback.py deleted file mode 100644 index 3b48068..0000000 --- a/resources/lib/playback.py +++ /dev/null @@ -1,34 +0,0 @@ -import json -from dataclasses import dataclass - - -@dataclass -class Playback: - """ - Stores whatever data we can grab about a Kodi Playback so that we can display it nicely in the Switchback list - """ - file:str = None - path:str = None - type:str = None # episode, movie, video, song - source:str = None # kodi_library, pvr_live, media_file - dbid:int = None - tvshowdbid: int = None - title:str = None - thumbnail:str = None - fanart:str = None - poster:str = None - year:int = None - showtitle:str = None - season:int = None - episode:int = None - resumetime:float = None - totaltime:float = None - duration:float = None - artist:str = None - album:str = None - tracknumber:int = None - channelname: str = None - channelnumberlabel:str = None - channelgroup:str = None - - diff --git a/resources/lib/player.py b/resources/lib/player.py index 67c0d55..93a7740 100644 --- a/resources/lib/player.py +++ b/resources/lib/player.py @@ -1,7 +1,11 @@ -from bossanova808.utilities import * -from resources.lib.store import Store +import xbmc + +from bossanova808.constants import HOME_WINDOW +from bossanova808.logger import Logger from bossanova808.playback import Playback +from resources.lib.store import Store + class KodiPlayer(xbmc.Player): """ @@ -24,29 +28,29 @@ def onAVStarted(self): item = self.getPlayingItem() file = self.getPlayingFile() - # If the current playback was Switchback-triggered from a Kodi ListItem (i.e. not PVR, see hack in switchback_plugin.py), + # If the current playback was Switchback-triggered from a Kodi ListItem, # retrieve the previously recorded Playback details from the list. Set the Home Window properties that have not yet been set. if item.getProperty('Switchback') or HOME_WINDOW.getProperty('Switchback'): - Logger.debug("Switchback triggered playback, so finding and re-using existing Playback object") + Logger.info("Switchback triggered playback, so attempting to find and re-use existing Playback object") Logger.debug("Home Window property is:", HOME_WINDOW.getProperty('Switchback')) Logger.debug("ListItem property is:", item.getProperty('Switchback')) path_to_find = HOME_WINDOW.getProperty('Switchback') or item.getProperty('Switchback') or item.getPath() Store.current_playback = Store.switchback.find_playback_by_path(path_to_find) if Store.current_playback: - Logger.debug("Re-using previously stored Playback object:", Store.current_playback) + Logger.info("Found. Re-using previously stored Playback object:", Store.current_playback) # We won't have access to the listitem once playback is finishes, so set a property now so it can be used/cleared in onPlaybackFinished below Store.update_home_window_switchback_property(Store.current_playback.path) return else: - Logger.error(f"Switchback triggered playback, but no playback found in the list for this path - this shouldn't happen?!", path_to_find) + Logger.error("Switchback triggered playback, but no playback found in the list for this path - this shouldn't happen?!", path_to_find) - # If we got to here, this was not a Switchback-triggered playback, or for some reason we've been unable to find the Playback - # Create a new Playback object and record the details/ - Logger.debug("Not a Switchback playback, or error retrieving previous Playback, so creating a new Playback object to record details") + # If we got to here, this was not a Switchback-triggered playback, or for some reason we've been unable to find the Playback. + # Create a new Playback object and record the details. + Logger.info("Not a Switchback playback, or error retrieving previous Playback, so creating a new Playback object to record details") Store.current_playback = Playback() - Store.current_playback.file = file - Store.current_playback.update_playback_details_from_listitem(item) + Store.current_playback.update_playback_details(file, item) + # Playback finished 'naturally' def onPlayBackEnded(self): self.onPlaybackFinished() @@ -65,45 +69,41 @@ def onPlaybackFinished(): Logger.error("onPlaybackFinished with no current playback details available?! ...not recording this playback") return - Logger.debug("onPlaybackFinished with Store.current_playback:", Store.current_playback) - Logger.debug("onPlaybackFinished with Store.switchback.list: ", Store.switchback.list) + Store.switchback.load_or_init() + + Logger.debug("onPlaybackFinished with Store.current_playback:") + Logger.debug(Store.current_playback) + Logger.debug("onPlaybackFinished with Store.switchback.list:") + Logger.debug(Store.switchback.list) # Was this a Switchback-initiated playback? - # (This property was set above in onAVStarted if the ListItem property was set, or explicitly in the PVR HACK! section in switchback_plugin.py) + # (This property was set above in onAVStarted if the ListItem property was set, or explicitly in the PVR HACK! section in switchback_plugin.py this we only need to test for this) switchback_playback = HOME_WINDOW.getProperty('Switchback') # Clear the property if set, now playback has finished HOME_WINDOW.clearProperty('Switchback') - # If we Switchbacked to an episode, force Kodi to browse to the Show/Season - if switchback_playback == 'true': - if Store.current_playback.type == "episode": - Logger.info(f"Force browsing to tvshow/season of just finished playback") + # If we Switchbacked to a library episode, force Kodi to browse to the Show/Season + # (NB it is not possible to force Kodi to go to movies and focus a specific movie as far as I can determine) + if switchback_playback: + if Store.current_playback.type == "episode" and Store.current_playback.source == "kodi_library": + Logger.info("Force browsing to tvshow/season of just finished playback") Logger.debug(f'flatten tvshows {Store.flatten_tvshows} totalseasons {Store.current_playback.totalseasons} dbid {Store.current_playback.dbid} tvshowdbid {Store.current_playback.tvshowdbid}') # Default: Browse to the show window = f'videodb://tvshows/titles/{Store.current_playback.tvshowdbid}' - # If the user has Flatten TV shows set to 'never' (=0), browse to the actual season - if Store.flatten_tvshows == 0: + # 0 = Never flatten → browse to show root + # 1 = If only one season → browse to season only when there are multiple seasons + # 2 = Always flatten → browse to season + if Store.flatten_tvshows == 2: window += f'/{Store.current_playback.season}' - # Else if the user has Flatten TV shows set to 'If Only One Season' and there is indeed more than one season, browse to the actual season - elif Store.flatten_tvshows == 1 and Store.current_playback.totalseasons > 1: + elif Store.flatten_tvshows == 1 and (Store.current_playback.totalseasons or 0) > 1: window += f'/{Store.current_playback.season}' - xbmc.executebuiltin(f'ActivateWindow(Videos,{window},return)') - else: - # TODO - is is possible to force Kodi to go to movies and focus a specific movie? - pass - # This rather long-windeed approach is used to keep ALL the details recorded from the original playback + # This rather long-winded approach is used to keep ALL the details recorded from the original playback # (in case they don't make it through when the playback is Switchback initiated - as sometimes seems to be the case) - - # for previous_playback in Store.switchback.list: - # if previous_playback.path == Store.current_playback.path: - # playback_to_remove = previous_playback - # break - playback_to_remove = Store.switchback.find_playback_by_path(Store.current_playback.path) if playback_to_remove: - Logger.debug("Shuffling list order") + Logger.debug("Updating Playback and list order") # Remove it from its current position Store.switchback.list.remove(playback_to_remove) # Update with the current playback times @@ -116,9 +116,21 @@ def onPlaybackFinished(): # Trim the list to the max length Store.switchback.list = Store.switchback.list[0:Store.maximum_list_length] - # Finally, save the updated PlaybackList - Logger.debug("Saving updated Store.switchback.list:", Store.switchback.list) Store.switchback.save_to_file() + Logger.debug("Saved updated Store.switchback.list:", Store.switchback.list) + # & make sure the context menu items are updated Store.update_switchback_context_menu() + + # And update the current view so if we're in the Switchback plugin listing, it gets refreshed + # Use a delayed refresh to ensure Kodi has fully returned to the listing - but don't block, use threading + def delayed_refresh(): + xbmc.sleep(200) # Wait 200ms for UI to settle + xbmc.executebuiltin("Container.Refresh") + + import threading + threading.Thread(target=delayed_refresh).start() + + # ALTERNATIVE, but behaviour is slower/more visually janky + # xbmc.executebuiltin('AlarmClock(SwitchbackRefresh,Container.Refresh,00:00:01,silent)') diff --git a/resources/lib/store.py b/resources/lib/store.py index fcb4a3e..9a61760 100644 --- a/resources/lib/store.py +++ b/resources/lib/store.py @@ -1,6 +1,10 @@ import os -from bossanova808.utilities import * +import xbmcvfs + +from bossanova808.constants import HOME_WINDOW, PROFILE, ADDON +from bossanova808.logger import Logger +from bossanova808.utilities import get_kodi_setting, set_property, clear_property from bossanova808.playback import PlaybackList @@ -49,10 +53,11 @@ def load_config_from_settings(): Store.save_across_sessions = ADDON.getSettingBool('save_across_sessions') Logger.info(f"Save across sessions is: {Store.save_across_sessions}") Store.enable_context_menu = ADDON.getSettingBool('enable_context_menu') - Logger.info(f"Enbale context menu is: {Store.enable_context_menu}") + Logger.info(f"Enable context menu is: {Store.enable_context_menu}") @staticmethod def load_config_from_kodi_settings(): + # Note: this is an int, not a bool — 0 = Never, 1 = 'If only one season', 2 = Always Store.flatten_tvshows = int(get_kodi_setting('videolibrary.flattentvshows')) Logger.info(f"Flatten TV Shows is: {Store.flatten_tvshows}") diff --git a/resources/lib/switchback_plugin.py b/resources/lib/switchback_plugin.py index af93bbf..5be4fda 100644 --- a/resources/lib/switchback_plugin.py +++ b/resources/lib/switchback_plugin.py @@ -1,35 +1,36 @@ +import sys from urllib.parse import parse_qs +# noinspection PyUnresolvedReferences import xbmc import xbmcplugin +import xbmcgui from resources.lib.store import Store -from bossanova808.utilities import * +from bossanova808.constants import TRANSLATE +from bossanova808.logger import Logger from bossanova808.notify import Notify # PVR HACK! -# ListItems and setResolvedUrl does not handle PVR links properly, see https://forum.kodi.tv/showthread.php?tid=381623 -# (TODO: remove this hack when setResolvedUrl/ListItems are fixed to properly handle PVR links in listitem.path) +# Needed to trigger live PVR playback with proper PVR controls. +# See https://forum.kodi.tv/showthread.php?tid=381623 def pvr_hack(path): + xbmc.PlayList(xbmc.PLAYLIST_VIDEO).clear() # Kodi is jonesing for one of these, so give it the sugar it needs, see: https://forum.kodi.tv/showthread.php?tid=381623&pid=3232778#pid3232778 xbmcplugin.setResolvedUrl(int(sys.argv[1]), False, xbmcgui.ListItem()) - xbmc.PlayList(xbmc.PLAYLIST_VIDEO).clear() # Get the full details from our stored playback # pvr_playback = Store.switchback.find_playback_by_path(path) - builtin = f'PlayMedia("{path}"' - if "pvr://recordings" in path: - builtin += f',resume' - builtin += ")" - Logger.debug("Work around PVR links not being handled by ListItem/setResolvedUrl - use PlayMedia:", builtin) + builtin = f'PlayMedia("{path}")' + Logger.debug("Work around PVR links not being handled by ListItem/setResolvedUrl - use PlayMedia instead:", builtin) # No ListItem to set a property on here, so set on the Home Window instead Store.update_home_window_switchback_property(path) xbmc.executebuiltin(builtin) -# noinspection PyUnusedLocal -def run(args): +def run(): Logger.start("(Plugin)") + # This also forces an update of the Switchback list from disk, in case of changes via the service side of things. Store() plugin_instance = int(sys.argv[1]) @@ -38,65 +39,108 @@ def run(args): parsed_arguments = parse_qs(sys.argv[2][1:]) Logger.debug(parsed_arguments) mode = parsed_arguments.get('mode', None) - if mode: + modes = set([m.strip() for m in mode[0].split(",") if m.strip()]) if mode else set() + if modes: Logger.info(f"Switchback mode: {mode}") else: Logger.info("Switchback mode: default - generate 'folder' of items") # Switchback mode - easily swap between switchback.list[0] and switchback.list[1] # If there's only one item in the list, then resume playing that item - if mode and mode[0] == "switchback": - try: - if len(Store.switchback.list) == 1: - switchback_to_play = Store.switchback.list[0] - Logger.info(f"Playing Switchback[0] - path [{Store.switchback.list[0].path}]") - else: - switchback_to_play = Store.switchback.list[1] - Logger.info(f"Playing Switchback[1] - path [{Store.switchback.list[1].path}]") - Logger.info(f"{switchback_to_play.pluginlabel}") - - # Notify the user and set properties so we can identify this playback as having been originated from a Switchback - Notify.kodi_notification(f"{switchback_to_play.pluginlabel}", 3000, ADDON_ICON) - list_item = switchback_to_play.create_list_item_from_playback(offscreen=True) - # (TODO: remove this hack when setResolvedUrl/ListItems are fixed to properly handle PVR links in listitem.path) - if "pvr://" in switchback_to_play.path: - pvr_hack(switchback_to_play.path) - else: - list_item.setProperty('Switchback', switchback_to_play.path) - xbmcplugin.setResolvedUrl(plugin_instance, True, list_item) + if "switchback" in modes: - except IndexError: + # First, determine what to play, if anything... + if not Store.switchback.list: Notify.error(TRANSLATE(32007)) Logger.error("No Switchback found to play") + return + + if len(Store.switchback.list) == 1: + switchback_to_play = Store.switchback.list[0] + Logger.debug("Switchback to index 0") + else: + switchback_to_play = Store.switchback.list[1] + Logger.debug("Switchback to index 1") + + # We know what to play... + Logger.info(f"Switchback! Switching back to: {switchback_to_play.pluginlabel}") + Logger.debug(f"Path: [{switchback_to_play.path}]") + Logger.debug(f"File: [{switchback_to_play.file}]") + image = switchback_to_play.poster or switchback_to_play.icon + Notify.kodi_notification(f"{switchback_to_play.pluginlabel_short}", 3000, image) + + # Short circuit here if PVR, see pvr_hack above. + if 'pvr://channels' in switchback_to_play.path: + pvr_hack(switchback_to_play.path) + return + + # Normal path for everything else + list_item = switchback_to_play.create_list_item_from_playback() + list_item.setProperty('Switchback', switchback_to_play.path) + # Store.update_home_window_switchback_property(switchback_to_play.path) + xbmcplugin.setResolvedUrl(plugin_instance, True, list_item) + Logger.stop("(Plugin)") + return # Delete an item from the Switchback list - e.g. if it is not playing back properly from Switchback - if mode and mode[0] == "delete": - index_to_remove = parsed_arguments.get('index', None) - if index_to_remove: - Logger.info(f"Deleting playback {index_to_remove[0]} from Switchback list") - Store.switchback.list.remove(Store.switchback.list[int(index_to_remove[0])]) - Store.switchback.save_to_file() - Store.update_switchback_context_menu() - # Force refresh the list - Logger.debug("Force refreshing the container, so Kodi immediately displays the updated Switchback list") - xbmc.executebuiltin("Container.Refresh") - - # (TODO: remove this hack when setResolvedUrl/ListItems are fixed to properly handle PVR links in listitem.path) - if mode and mode[0] == "pvr_hack": - xbmcplugin.setResolvedUrl(int(sys.argv[1]), False, xbmcgui.ListItem()) - path = parsed_arguments.get('path', None)[0] + elif "delete" in modes: + index_values = parsed_arguments.get('index') + if index_values: + try: + idx = int(index_values[0]) + except (ValueError, TypeError): + Logger.error("Invalid 'index' parameter for delete:", index_values) + return + if 0 <= idx < len(Store.switchback.list): + Logger.info(f"Deleting playback {idx} from Switchback list") + Store.switchback.list.pop(idx) + else: + Logger.error("Index out of range for delete:", idx) + return + else: + Logger.error("Missing 'index' parameter for delete") + return + + # Save the updated list and then reload it, just to be sure + Store.switchback.save_to_file() + Store.switchback.load_or_init() + Store.update_switchback_context_menu() + Logger.debug("Force refreshing the container, so Kodi immediately displays the updated Switchback list") + xbmc.executebuiltin("Container.Refresh") + + # See pvr_hack(path) above + elif "pvr_hack" in modes: + path_values = parsed_arguments.get('path') + if not path_values or not path_values[0]: + Logger.error("Missing 'path' parameter for pvr_hack") + return + path = path_values[0] Logger.debug(f"Triggering PVR Playback hack for {path}") pvr_hack(path) + return # Default mode - show the whole Switchback List (each of which has a context menu option to delete itself) else: for index, playback in enumerate(Store.switchback.list[0:Store.maximum_list_length]): list_item = playback.create_list_item_from_playback() - list_item.addContextMenuItems([(LANGUAGE(32004), "RunPlugin(plugin://plugin.switchback?mode=delete&index=" + str(index) + ")")]) + # Add delete option to this item + list_item.addContextMenuItems([(TRANSLATE(32004), "RunPlugin(plugin://plugin.switchback?mode=delete&index=" + str(index) + ")")]) + # For detecting Switchback playbacks (in player.py) list_item.setProperty('Switchback', playback.path) - # Don't use playback.path here, as list_item may now have the plugin proxy url for PVR live playback - # (TODO: remove this hack when setResolvedUrl/ListItems are fixed to properly handle PVR links in listitem.path) - xbmcplugin.addDirectoryItem(plugin_instance, list_item.getPath(), list_item) + # Use the 'proxy' URL if we're dealing with pvr_live and need to trigger the PVR playback hack + if playback.source == "pvr_live": + proxy_url = f"plugin://plugin.switchback?mode=pvr_hack&path={playback.path}" + Logger.debug(f"Creating directory item with pvr_hack proxy url: {proxy_url}") + xbmcplugin.addDirectoryItem(plugin_instance, proxy_url, list_item) + # TODO -> not sure if URL encoding needed in some cases? Maybe CodeRabbit knows? + # args = urlencode({'mode': 'pvr_hack', 'path': self.path}) + # proxy_url = f"plugin://plugin.switchback/?{args}" + + # Otherwise use file for all Kodi library playbacks, and path for addons (as those may include tokens etc) + else: + url = playback.file if playback.source not in ["addon", "pvr_live"] else playback.path + # Logger.debug(f"Creating directory item with url: {url}") + xbmcplugin.addDirectoryItem(plugin_instance, url, list_item) xbmcplugin.endOfDirectory(plugin_instance, cacheToDisc=False)