From 4d1e8bfdab64ec0794942bb2ace985f868a54476 Mon Sep 17 00:00:00 2001 From: bossanova808 Date: Tue, 7 Oct 2025 00:15:43 +0000 Subject: [PATCH] [plugin.switchback] 2.0.0 --- plugin.switchback/README.md | 30 +- plugin.switchback/addon.xml | 68 ++- plugin.switchback/changelog.txt | 9 + plugin.switchback/context_menu.py | 10 + plugin.switchback/plugin.py | 6 +- .../resource.language.en_gb/strings.po | 42 +- .../resource.language.sv_se/strings.po | 39 ++ plugin.switchback/resources/lib/monitor.py | 14 +- plugin.switchback/resources/lib/playback.py | 486 +++++++++++++++++- plugin.switchback/resources/lib/player.py | 292 ++++------- plugin.switchback/resources/lib/store.py | 108 ++-- .../resources/lib/switchback_context_menu.py | 13 + .../resources/lib/switchback_plugin.py | 231 +++++---- .../resources/lib/switchback_service.py | 28 +- plugin.switchback/resources/settings.xml | 26 +- plugin.switchback/service.py | 1 + 16 files changed, 951 insertions(+), 452 deletions(-) create mode 100644 plugin.switchback/context_menu.py create mode 100644 plugin.switchback/resources/language/resource.language.sv_se/strings.po create mode 100644 plugin.switchback/resources/lib/switchback_context_menu.py diff --git a/plugin.switchback/README.md b/plugin.switchback/README.md index 1bbd154960..0e0d17ecee 100644 --- a/plugin.switchback/README.md +++ b/plugin.switchback/README.md @@ -6,23 +6,35 @@ Switchback [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/bossanova808) -Kodi utility for fast switching between recently played media. +## What is Switchback for Kodi? -Available from the Kodi official repository (i.e. don't install from here - just go to Add-ons -> Get Add-ons -> Video -> Switchback) +Kodi utility for fast switching between recently played media. -Keeps a list of recently played media - supports Kodi library episodes, movies, PVR channels, songs and non-library files. Does not (yet?) support add-ons or PVR recordings. +Available from the Kodi official repository (i.e. don't install from here - just go to Add-ons -> Get Add-ons -> Services -> Switchback) -The primary intended use is to make for super easy Switchback between two in progress video. Bind a remote/keyboard key to Switchback, e.g.: +Keeps a list of recently played media - supports Kodi Addons, library episodes & movies, PVR (live & recordings), and non-library videos. -`PlayMedia(plugin://plugin.switchback/?mode=switchback,resume)` +The primary intended use is to make for super easy Switchback between two in progress video. Consider this scenario: -- You are watching 'video A' with your partner. You are interrupted and your partner needs to tend to the kids/howl at the moon. -- You navigate to 'video B' and watch some of that. +- You're watching 'video A' with your partner. You're interrupted, and your partner needs to tend to the kids/howl at the moon. +- You navigate to 'video B' and watch some of that while you're waiting. - Your partner comes back. -- You hit your one button 'Switchback' and 'video A' starts playing again, no need for tedious navigation etc. -- You are interrupted again - the moon is so very bright tonight - hit your 'Switchback' to resume 'video B' immediately, again with one magic button and no tedious navigation. +- You hit your one button 'Switchback' and 'video A' instantly starts playing again -> no need for tedious navigation etc. +- You're interrupted, again! The moon really _is_ so very bright tonight. Hit your 'Switchback' button to instantly resume 'video B', again with no tedious navigation. + +## Using Switchback + +Switchback can be used like a standard Video plugin, via context menu, or bind a remote/keyboard key for instant Switchback, e.g.: + +`PlayMedia(plugin://plugin.switchback/?mode=switchback,resume)` +` + +...or to show the full current Switchback list of recently played items: + +`RunAddon(plugin.switchback)` + Support is via the [forum thread](https://forum.kodi.tv/showthread.php?tid=379330), or open an issue here. diff --git a/plugin.switchback/addon.xml b/plugin.switchback/addon.xml index fde27da19f..e8562a0445 100644 --- a/plugin.switchback/addon.xml +++ b/plugin.switchback/addon.xml @@ -1,31 +1,53 @@ - + - - + + - + - + - video audio - + video audio + + + + + + + Integer.IsGreater(Window(Home).Property(Switchback_List_Length),1) + + + + Integer.IsGreater(Window(Home).Property(Switchback_List_Length),0) + + + - Utility for fast switching between recently played media. + Switchback is a Kodi utility for fast switching between recently played media. + Switchback är ett Kodi-verktyg för snabb växling mellan nyligen spelade medier. Kodi utility for fast switching between recently played media. - -Keeps a list of recently played media - supports Kodi library episodes, movies, PVR channels, songs and non-library files. Does not (yet?) support add-ons or PVR recordings. - -The primary intended use is to make for super easy Switchback between two in progress videos (by binding a remote key to Switchback, see Wiki/Forum thread for info). - +Keeps a list of recently played videos. +The primary intended use is to make for super easy Switchback between two in progress video. See Wiki for usage notes including how to bind remote/keyboard controls for instant Switchback. Consider this scenario: -- You are watching 'video A' with your partner. You are interrupted and your partner needs to tend to the kids/howl at the moon. -- You navigate to 'video B' and watch some of that. -- Your partner comes back. -- You hit your one button 'Switchback' and 'video A' starts playing again, no need for tedious navigation etc. -- You are interrupted again - the moon is so very bright tonight - hit your 'Switchback' to resume 'video B' immediately, again with one magic button and no tedious navigation. +* You're watching 'video A' with your partner. You're interrupted, and your partner needs to tend to the kids/howl at the moon. +* You navigate to 'video B' and watch some of that while you're waiting. +* Your partner comes back. +* You hit your one button 'Switchback' and 'video A' instantly starts playing again - no need for tedious navigation etc. +* You're interrupted, again! The moon really is so very bright tonight. Hit your 'Switchback' button to instantly resume 'video B', again with no tedious navigation. + + +Kodi-verktyg för snabb växling mellan nyligen spelade medier. +Spara en lista över nyligen spelade videor. +Den primära användningen är att göra det superenkelt att växla mellan två pågående videor. Se Wiki för användningsanvisningar, inklusive hur du kopplar fjärrkontroll/tangentbordskontroller för omedelbar växling. +Tänk dig följande scenario: +* Du tittar på ”video A” med din partner. Ni blir avbrutna och din partner måste ta hand om barnen/skälla på månen. +* Du navigerar till ”video B” och tittar på den medan du väntar. +* Din partner kommer tillbaka. +* Du trycker på din enda knapp ”Switchback” och ”video A” börjar omedelbart spelas upp igen – utan tråkig navigering etc. +* Du blir avbruten igen! Månen är verkligen väldigt ljus ikväll. Tryck på din ’Switchback’-knapp för att omedelbart återuppta ”video B”, återigen utan tråkig navigering. all GPL-3.0-only @@ -33,8 +55,14 @@ Consider this scenario: https://github.com/bossanova808/plugin.switchback/ https://forum.kodi.tv/showthread.php?tid=379330 bossanova808@gmail.com - - v1.0.0 Initial release + v2.0.0 Re-write! +- Support addons +- Support PVR live/recordings +- (Remove music support) +- Add context menus (configurable) +- Better artwork support +- Better notifications +- Filter watched items from list (configurable) resources/icon.png diff --git a/plugin.switchback/changelog.txt b/plugin.switchback/changelog.txt index b9f82d58b1..8b6c512f1c 100644 --- a/plugin.switchback/changelog.txt +++ b/plugin.switchback/changelog.txt @@ -1,3 +1,12 @@ +v2.0.0 Re-write! +- Support addons +- Support PVR live/recordings +- (Remove music support) +- Add context menus (configurable) +- Better artwork support +- Better notifications +- Filter watched items from list (configurable) + v1.0.0 - Initial Release - Supports Episodes, Movies, Songs, PVR channels, Non-library files diff --git a/plugin.switchback/context_menu.py b/plugin.switchback/context_menu.py new file mode 100644 index 0000000000..7c0cf1cf5a --- /dev/null +++ b/plugin.switchback/context_menu.py @@ -0,0 +1,10 @@ +import sys + +from bossanova808 import exception_logger +from resources.lib import switchback_context_menu + +if __name__ == "__main__": + with exception_logger.log_exception(): + # args would be passed through, if there were any... + # + switchback_context_menu.run(sys.argv[1:]) diff --git a/plugin.switchback/plugin.py b/plugin.switchback/plugin.py index 0cc2793e34..73d39f6226 100644 --- a/plugin.switchback/plugin.py +++ b/plugin.switchback/plugin.py @@ -1,9 +1,7 @@ from bossanova808 import exception_logger from resources.lib import switchback_plugin -import sys + if __name__ == "__main__": with exception_logger.log_exception(): - switchback_plugin.run(sys.argv[1:]) - - + switchback_plugin.run() diff --git a/plugin.switchback/resources/language/resource.language.en_gb/strings.po b/plugin.switchback/resources/language/resource.language.en_gb/strings.po index 2c298e9643..6a6da818cb 100644 --- a/plugin.switchback/resources/language/resource.language.en_gb/strings.po +++ b/plugin.switchback/resources/language/resource.language.en_gb/strings.po @@ -1,17 +1,17 @@ # Kodi Media Center language file msgid "" msgstr "" -"Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" -"POT-Creation-Date: 2020-04-26 11:43+0000\n" +"Project-Id-Version: Kodi Addons\n" +"Report-Msgid-Bugs-To: translations@kodi.tv\n" +"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE\n" +"Last-Translator: Kodi Translation Team\n" +"Language-Team: English (https://kodi.weblate.cloud/languages/en_gb/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: en\n" -"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" msgctxt "#32000" msgid "General" @@ -25,10 +25,30 @@ msgctxt "#32002" msgid "Maximum Switchback list length" msgstr "" -msgctxt "#32003" -msgid "Include Music Playbacks?" +msgctxt "#32004" +msgid "Delete from Switchback list" msgstr "" -msgctxt "#32004" -msgid "Delete playback from Switchback list" -msgstr "" \ No newline at end of file +msgctxt "#32006" +msgid "Save Switchback list across Kodi sessions?" +msgstr "" + +msgctxt "#32007" +msgid "No Switchback found to play" +msgstr "" + +msgctxt "#32008" +msgid "Switchback: List" +msgstr "" + +msgctxt "#32009" +msgid "Enable Switchback context menu items?" +msgstr "" + +msgctxt "#32010" +msgid "Automatically filter watched items out of the Switchback list?" +msgstr "" + +msgctxt "#32011" +msgid "(After a switchback playback) force browse to episode in library?" +msgstr "" diff --git a/plugin.switchback/resources/language/resource.language.sv_se/strings.po b/plugin.switchback/resources/language/resource.language.sv_se/strings.po new file mode 100644 index 0000000000..f461ce7500 --- /dev/null +++ b/plugin.switchback/resources/language/resource.language.sv_se/strings.po @@ -0,0 +1,39 @@ +# Kodi Media Center Swedish language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2020-04-26 11:43+0000\n" +"PO-Revision-Date: 2025-05-06 07:40+0200\n" +"Last-Translator: Daniel Nylander \n" +"Language-Team: Swedish\n" +"Language: sv\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.6\n" + +msgctxt "#32000" +msgid "General" +msgstr "Allmänt" + +msgctxt "#32001" +msgid "Basic Settings" +msgstr "Grundläggande inställningar" + +msgctxt "#32002" +msgid "Maximum Switchback list length" +msgstr "Maximal längd på Switchback-listan" + +msgctxt "#32003" +msgid "Include Music Playbacks?" +msgstr "Inkludera musikuppspelningar?" + +msgctxt "#32004" +msgid "Delete playback from Switchback list" +msgstr "Ta bort uppspelning från Switchback-listan" + +msgctxt "#32005" +msgid "Filter watched items out of the Switchback list?" +msgstr "Filtrera sedda objekt från Switchback-listan?" diff --git a/plugin.switchback/resources/lib/monitor.py b/plugin.switchback/resources/lib/monitor.py index 17a5394abc..efb47ce8d9 100644 --- a/plugin.switchback/resources/lib/monitor.py +++ b/plugin.switchback/resources/lib/monitor.py @@ -1,25 +1,15 @@ from bossanova808.logger import Logger from resources.lib.store import Store import xbmc -import json class KodiEventMonitor(xbmc.Monitor): + # noinspection PyUnusedLocal def __init__(self, *args, **kwargs): xbmc.Monitor.__init__(self) - Logger.debug('KodiEventMonitor __init__') + Logger.debug('Monitor __init__') def onSettingsChanged(self): Logger.info('onSettingsChanged - reload them.') Store.load_config_from_settings() - - # noinspection PyMethodMayBeStatic - def onAbortRequested(self): - Logger.debug('onAbortRequested') - - def onNotification(self, sender, method, data): - if method == 'Player.OnStop': - data = json.loads(data) - Logger.debug("Notification:") - Logger.debug(data) diff --git a/plugin.switchback/resources/lib/playback.py b/plugin.switchback/resources/lib/playback.py index 68b906e15b..026c7efa7c 100644 --- a/plugin.switchback/resources/lib/playback.py +++ b/plugin.switchback/resources/lib/playback.py @@ -1,33 +1,473 @@ +import os import json -from dataclasses import dataclass +from dataclasses import dataclass, asdict +from typing import List, Optional +import xbmc +import xbmcgui +import xbmcvfs + +# noinspection PyPackages +from bossanova808.utilities import clean_art_url, send_kodi_json, get_resume_point, get_playcount, get_advancedsetting +# noinspection PyPackages +from bossanova808.logger import Logger +# noinspection PyUnresolvedReferences +from infotagger.listitem import ListItemInfoTag @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 - 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 + file: Optional[str] = None + path: Optional[str] = None + type: Optional[str] = None # episode, movie, video (per Kodi types) - song is the other type, but Switchback supports video only + source: Optional[str] = None # kodi_library, pvr_live, pvr_recording, addon, file + dbid: Optional[int] = None + tvshowdbid: Optional[int] = None + totalseasons: Optional[int] = None + title: Optional[str] = None + label: Optional[str] = None + label2: Optional[str] = None + thumbnail: Optional[str] = None + fanart: Optional[str] = None + poster: Optional[str] = None + icon: Optional[str] = None + year: Optional[int] = None + showtitle: Optional[str] = None + season: Optional[int] = None + episode: Optional[int] = None + resumetime: Optional[int] = None + totaltime: Optional[int] = None + duration: Optional[int] = None + channelname: Optional[str] = None + channelnumberlabel: Optional[str] = None + channelgroup: Optional[str] = None + + @property + def pluginlabel(self) -> str: + """ + Create a more full label, e.g. Showname (2x03) - Episode title + + :return: The Switchback label for display in the plugin list + """ + label = self.title or self.label or self.channelname or (os.path.basename(self.path) if self.path else None) or "Unknown" + if self.showtitle: + if (self.season is not None and self.season >= 0) and (self.episode is not None and self.episode >= 0): + label = f"{self.showtitle} ({self.season}x{self.episode:02d}) - {self.title or label}" + elif self.season is not None and self.season >= 0: + label = f"{self.showtitle} ({self.season}x?) - {self.title or label}" + else: + label = f"{self.showtitle} - {self.title or label}" + elif self.channelname: + if self.source == "pvr_live": + label = f"{self.channelname} (PVR Live)" + else: + label = f"{label} (PVR Recording {self.channelname})" + + if self.source == "addon": + label = f"{label} (* Add-on)" + return label + + @property + def pluginlabel_short(self) -> str: + """ + Create a shorter label, e.g. Showname (2x03) + """ + label = self.title or self.label or self.channelname or (os.path.basename(self.path) if self.path else None) or "Unknown" + if self.showtitle: + if (self.season is not None and self.season >= 0) and (self.episode is not None and self.episode >= 0): + label = f"{self.showtitle} ({self.season}x{self.episode:02d})" + elif self.season is not None and self.season >= 0: + label = f"{self.showtitle} ({self.season}x?)" + else: + label = f"{self.showtitle}" + + return label + + def _is_addon_playback(self) -> bool: + """ + Determine if playback originates from an addon + + :return: True if playback is from an addon, False otherwise + """ + path_lower = (self.path or '').lower() + + # Method 1: Check for plugin:// URLs (most reliable) + if path_lower.startswith('plugin://'): + return True + + # Method 2: Check ListItem.Path infolabel for plugin URLs + listitem_path_lower = xbmc.getInfoLabel('ListItem.Path').lower() + if listitem_path_lower.startswith('plugin://'): + return True + + # Method 3: Check if an addon ID is associated with the current item + addon_id = xbmc.getInfoLabel('ListItem.Property(Addon.ID)') + if addon_id: + return True + + # Method 4: Check container path (for addon-generated content) + container_path_lower = xbmc.getInfoLabel('Container.FolderPath').lower() + if container_path_lower.startswith('plugin://'): + return True + + # Method 5: Conservative HTTP fallback for local addon proxies only + if path_lower.startswith(('http://', 'https://')): + # Exclude known WebDAV/cloud storage patterns + webdav_patterns = [ + '/dav/', 'webdav', '.nextcloud.', 'owncloud', '/remote.php/', 'dropbox', 'googledrive', 'onedrive', + ] + + if not any(pattern in path_lower for pattern in webdav_patterns): + # Additional check: look for typical addon URL structures + if any(indicator in path_lower for indicator in ('plugin', 'addon')): + Logger.debug("Classified as addon via HTTP fallback heuristic", path_lower) + return True + # If we still have an Addon.ID or container is a plugin, treat as addon + addon_id_http = xbmc.getInfoLabel('ListItem.Property(Addon.ID)') + container_path_lower_http = xbmc.getInfoLabel('Container.FolderPath').lower() + if addon_id_http or container_path_lower_http.startswith('plugin://'): + return True + # Accept loopback hosts commonly used by addon proxy/resolvers + if any(host in path_lower for host in ('127.0.0.1', 'localhost', '[::1]')): + Logger.debug("Classified as addon via localhost HTTP fallback", path_lower) + return True + + return False + + def update(self, new_details: dict) -> None: + """ + Update a Playback object with new details + + :param new_details: a dictionary (need not be complete) of the Playback object's new details + """ + for key, value in new_details.items(): + if hasattr(self, key): + setattr(self, key, value) + else: + Logger.error(f"Playback.update: Unknown key [{key}]") + + def toJson(self) -> str: + """ + Return the Playback object as JSON + + :return: the Playback object as JSON + """ + return json.dumps(asdict(self), ensure_ascii=False, indent=2) + + def update_playback_details(self, file: str, item: xbmcgui.ListItem) -> None: + """ + Update the Playback object with details from a playing Kodi ListItem object and InfoLabels + + :param file: the current file Kodi is playing (from xbmc.Player().getPlayingFile()) + :param item: the current Kodi playing item (from xbmc.Player().getPlayingItem()) + """ + + self.path = item.getPath() + self.file = file + self.label = item.getLabel() + self.label2 = item.getLabel2() + + # Updated as playback progresses (see switchback_service.py), but initialise here in cast of early exits etc. + if self.source != "pvr_live": + # Getting from the player directly is more reliable than using item.getVideoInfoTag() etc + self.totaltime = self.duration = int(xbmc.Player().getTotalTime()) + self.resumetime = int(xbmc.Player().getTime()) + + # Determine the Playback source - Kodi Library (...get DBID), PVR, Addon, or Non-Library file? + dbid_label = xbmc.getInfoLabel('VideoPlayer.DBID') + try: + self.dbid = int(dbid_label) if dbid_label else None + except ValueError: + self.dbid = None + + if self.dbid: + self.source = "kodi_library" + elif xbmc.getCondVisibility('PVR.IsPlayingTV') or xbmc.getCondVisibility('PVR.IsPlayingRadio'): + self.source = "pvr_live" + elif (self.path or '').lower().startswith('pvr://recordings/'): + self.source = "pvr_recording" + elif self._is_addon_playback(): + self.source = "addon" + else: + Logger.debug("Not from Kodi library, PVR, or addon - treating as a non-library media file") + self.source = "file" + + # TITLE + if self.source != "pvr_live": + self.title = xbmc.getInfoLabel('VideoPlayer.Title') + else: + self.title = xbmc.getInfoLabel('VideoPlayer.ChannelName') + + # MEDIA TYPE (see also source above, e.g. to distinguish PVR from non library video) + # Infotagger/Kodi expect mediatype in {"video","movie","tvshow","season","episode","musicvideo"}. + if xbmc.getInfoLabel('VideoPlayer.TVShowTitle'): + self.type = "episode" + tvshowdbid_label = xbmc.getInfoLabel('VideoPlayer.TvShowDBID') + try: + self.tvshowdbid = int(tvshowdbid_label) if tvshowdbid_label else None + except ValueError: + self.tvshowdbid = None + + elif self.dbid: + self.type = "movie" + elif xbmc.getInfoLabel('VideoPlayer.ChannelName'): + self.type = "video" # use standard mediatype; PVR tracked via self.source + else: + self.type = "video" + + # ARTWORK - POSTER, FANART THUMBNAIL and ICON + self.poster = clean_art_url(xbmc.getInfoLabel('Player.Art(tvshow.poster)') or xbmc.getInfoLabel('Player.Art(poster)') or xbmc.getInfoLabel('Player.Art(thumb)')) + self.fanart = clean_art_url(xbmc.getInfoLabel('Player.Art(fanart)')) + thumbnail = xbmc.getInfoLabel('Player.Art(thumb)') or (item.getArt('thumb') or '') + self.thumbnail = clean_art_url(thumbnail) + icon = xbmc.getInfoLabel('Player.Art(icon)') or (item.getArt('icon') or '') + self.icon = clean_art_url(icon) + + # OTHER DETAILS + # PVR Live/Recordings + self.channelname = xbmc.getInfoLabel('VideoPlayer.ChannelName') + self.channelnumberlabel = xbmc.getInfoLabel('VideoPlayer.ChannelNumberLabel') + self.channelgroup = xbmc.getInfoLabel('VideoPlayer.ChannelGroup') + # Episodes & Movies + year_label = xbmc.getInfoLabel('VideoPlayer.Year') + try: + self.year = int(year_label) if year_label else None + except ValueError: + self.year = None + # Episodes + self.showtitle = xbmc.getInfoLabel('VideoPlayer.TVShowTitle') + season_label = xbmc.getInfoLabel('VideoPlayer.Season') + episode_label = xbmc.getInfoLabel('VideoPlayer.Episode') + try: + self.season = int(season_label) if season_label else None + except ValueError: + self.season = None + try: + self.episode = int(episode_label) if episode_label else None + except ValueError: + self.episode = None + # Episodes -> we also want the number of seasons so we can force-browse to the appropriate spot after a Swtichback initiated playback + if self.tvshowdbid: + json_dict = { + "jsonrpc":"2.0", + "id":"VideoLibrary.GetSeasons", + "method":"VideoLibrary.GetSeasons", + "params":{ + "tvshowid":self.tvshowdbid, + }, + } + + properties_json = send_kodi_json(f'Get seasons details for tv show {self.showtitle}', json_dict) + if not properties_json or 'result' not in properties_json: + Logger.error("VideoLibrary.GetSeasons returned no result") + self.totalseasons = None + # Continue without seasons info + return + + if 'error' in properties_json: + Logger.error("VideoLibrary.GetSeasons returned error:", properties_json['error']) + self.totalseasons = None + return + properties = properties_json['result'] + + # {'limits': {'end': 2, 'start': 0, 'total': 2}, 'seasons': [...]} + total_limit = properties.get('limits', {}).get('total') + self.totalseasons = total_limit if isinstance(total_limit, int) else None + if self.totalseasons is None and 'seasons' in properties: + self.totalseasons = len(properties['seasons']) + + # noinspection PyMethodMayBeStatic + def create_list_item_from_playback(self) -> xbmcgui.ListItem: + """ + Create a Kodi ListItem object from a Playback object + + :return: ListItem: a Kodi ListItem object constructed from the Playback object + """ + + Logger.debug("Creating list item from playback:", self) + + list_item = xbmcgui.ListItem(label=self.pluginlabel, path=self.file if self.source not in ["addon", "pvr_live"] else self.path) + art = {key:value for key, value in {"thumb":self.thumbnail, "poster":self.poster, "fanart":self.fanart, "icon":self.icon}.items() if value} + if art: + list_item.setArt(art) + list_item.setProperty('IsPlayable', 'true') + + # PVR channels are not really videos! See: https://forum.kodi.tv/showthread.php?tid=381623&pid=3232826#pid3232826 + # So that's all we need to do for PVR playbacks + if self.source == "pvr_live": + return list_item + + # Otherwise, it's an episode/movie/file etc...set the InfoVideoTag stuff + tag = ListItemInfoTag(list_item, "video") + duration_seconds = None + if self.duration is not None: + duration_seconds = self.duration + elif self.totaltime is not None: + duration_seconds = self.totaltime + + # Infotagger seems the best way to do this currently as is well tested + # I found directly setting things on InfoVideoTag to be buggy/inconsistent + infolabels = { + 'mediatype':self.type, + 'dbid':self.dbid, + 'title':self.title, + 'path':self.path, + 'year':self.year, + 'tvshowtitle':self.showtitle, + 'episode':self.episode, + 'season':self.season, + 'duration':duration_seconds, + } + tag.set_info(infolabels) + + # Required, otherwise immediate Switchback mode won't resume properly + # These keys are correct, even if CodeRabbit says they are not - see https://github.com/jurialmunkey/script.module.infotagger/blob/f138c1dd7201a8aff7541292fbfc61ed7b3a9aa1/resources/modules/infotagger/listitem.py#L204 + tag.set_resume_point({'ResumeTime':float(self.resumetime or 0.0), 'TotalTime':float(self.totaltime or 0.0)}) + if self.tvshowdbid: + list_item.setProperty('tvshowdbid', str(self.tvshowdbid)) + + return list_item + + +@dataclass +class PlaybackList: + """ + A list of Playback objects, with some helper methods. Stored both in memory (accessible via .list) and on disk (filename at .file) + Is a standard Python list, so can be iterated over, indexed, etc., and of course, all standard list methods are available. + + To create a PlaybackList:: + switchback = PlaybackList([], xbmcvfs.translatePath(os.path.join(PROFILE, "switchback_list.json"))) + """ + list: List[Playback] + file: str + remove_watched_playbacks: bool = False + + def toJson(self) -> str: + """ + Return the list of Playback objects as JSON + + :return: the list of Playback objects as JSON + """ + return json.dumps([p.__dict__ for p in self.list], ensure_ascii=False, indent=2) + + def init(self) -> None: + """ + Initialise/reset in memory PlaybackList, and delete/re-create the empty PlaybackList file + """ + self.list = [] + xbmcvfs.mkdirs(os.path.dirname(self.file)) + with open(self.file, 'w', encoding='utf-8') as switchback_list_file: + switchback_list_file.write('[]') + + def load_or_init(self) -> None: + """ + Load a JSON-formatted PlaybackList from the PlaybackList file + """ + Logger.info("Try to load PlaybackList from file:", self.file) + # Ensure we start from a clean slate before loading from disk + self.list = [] + try: + with open(self.file, 'r', encoding='utf-8') as switchback_list_file: + switchback_list_json = json.load(switchback_list_file) + if not isinstance(switchback_list_json, list): + Logger.error(f"PlaybackList file [{self.file}] did not contain a JSON array — reinitialising") + self.init() + return + for playback in switchback_list_json: + self.list.append(Playback(**playback)) + + except FileNotFoundError: + Logger.warning(f"Could not find: [{self.file}] - creating empty PlaybackList & file") + self.init() + except json.JSONDecodeError: + Logger.error(f"JSONDecodeError - Unable to parse PlaybackList file [{self.file}] - creating empty PlaybackList & file") + self.init() + + list_needs_save = False + + # If the user wants to filter out watched items from the list + if self.remove_watched_playbacks: + paths_to_remove = [] + for item in list(self.list): + # DB item? Is it marked as watched in the DB? + if item.dbid: + playcount = get_playcount(item.type, item.dbid) + if playcount and playcount > 0: + list_needs_save = True + Logger.debug(f"Filtering watched playback from the list (as playcount > 0 in Kodi DB): [{item.pluginlabel}]") + paths_to_remove.append(item.path) + + # Not a DB item, use a calculation instead and compare to the playcount_minium_percent + elif item.resumetime and item.totaltime: + percent_played = (item.resumetime / item.totaltime) * 100 + # Use the user set playcount_minium_percent if there is one, or fallback to Kodi default 90 percent + setting = get_advancedsetting('video/playcountminimumpercent') + playcount_minium_percent = float(setting) if setting and setting != 0 else 90.0 + if percent_played >= playcount_minium_percent: + list_needs_save = True + Logger.debug(f"Filtering watched playback from the list (as {percent_played:.1f}% played over playcount_minium_percent {playcount_minium_percent}%): [{item.pluginlabel}]") + paths_to_remove.append(item.path) + + if paths_to_remove: + list_needs_save = True + for path in paths_to_remove: + self.remove_playbacks_of_path(path) + + # Update resume points with current data from the Kodi library (consider e.g. shared library scenarios) + for item in self.list: + if item.dbid: + library_resume_point = get_resume_point(item.type, item.dbid) + if library_resume_point != item.resumetime: + Logger.debug(f"Retrieved library resume point: {library_resume_point} != existing list resume point {item.resumetime} - updating playback list") + list_needs_save = True + item.resumetime = library_resume_point + + if list_needs_save: + self.save_to_file() + + def save_to_file(self) -> None: + """ + Save the PlaybackList to the PlaybackList file (as JSON) + """ + Logger.info(f"Saving PlaybackList to file: {self.file}") + import tempfile + directory_name = os.path.dirname(self.file) + temp_dir = None + if directory_name: + xbmcvfs.mkdirs(directory_name) + temp_dir = directory_name + with tempfile.NamedTemporaryFile('w', delete=False, encoding='utf-8', dir=temp_dir) as temp_file: + temp_file.write(self.toJson()) + temporary_name = temp_file.name + os.replace(temporary_name, self.file) + + def delete_file(self) -> None: + """ + Deletes the PlaybackList file + """ + if os.path.exists(self.file): + Logger.info(f"Deleting PlaybackList file [{self.file}]") + os.remove(self.file) + + def remove_playbacks_of_path(self, path: str) -> None: + """ + Remove any playbacks of a given path from the PlaybackList + """ + self.list = [x for x in self.list if x.path != path] + def find_playback_by_path(self, path: str) -> Optional[Playback]: + """ + Return a playback with the matching path if found, otherwise None + :param path: str The path to search for + :return: Playback or None: The Playback object if found, otherwise None + """ + Logger.debug(f"find_playback_by_path: {path}") + for playback in self.list: + if playback.path == path: + Logger.debug(f"Matched playback to [{playback.path}]") + return playback + Logger.debug(f"No matching playback for [{path}]") + return None diff --git a/plugin.switchback/resources/lib/player.py b/plugin.switchback/resources/lib/player.py index 48f6116473..a79038ea6d 100644 --- a/plugin.switchback/resources/lib/player.py +++ b/plugin.switchback/resources/lib/player.py @@ -1,14 +1,10 @@ -import time -from urllib.parse import unquote +import xbmc +from bossanova808.constants import HOME_WINDOW from bossanova808.logger import Logger -from bossanova808.utilities import * -# noinspection PyPackages -from .store import Store -# noinspection PyPackages -from .playback import Playback -import xbmc -import json +from resources.lib.playback import Playback + +from resources.lib.store import Store class KodiPlayer(xbmc.Player): @@ -16,221 +12,125 @@ class KodiPlayer(xbmc.Player): This class represents/monitors the Kodi video player """ + # noinspection PyUnusedLocal def __init__(self, *args): xbmc.Player.__init__(self) - Logger.debug('KodiPlayer __init__') + Logger.debug('Player __init__') # Use on AVStarted (vs Playback started) we want to record a playback only if the user actually _saw_ a video... def onAVStarted(self): Logger.info('onAVStarted') - # Reload the Switchback list in case the plugin is being called after a list deletion - Store.load_config_from_settings() - - if xbmc.getCondVisibility('Player.HasMedia'): - # In general this tool only makes sense for video, really, but just in case, we support music. - # Short circuit here if music is playing & the user has requested we do not record music playback via the settings (=default) - if not xbmc.getCondVisibility('Player.HasVideo') and not Store.include_music: - return - - # Clear any legacy recorded playback details by creating a new Playback object + # KISS - only support video... + if xbmc.getCondVisibility('Player.HasVideo'): + + # (If only we could just serialise a Kodi ListItem...) + item = self.getPlayingItem() + file = self.getPlayingFile() + + # 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.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.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("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.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 = self.getPlayingFile() - - # @TODO - add support for addons/plugins? Too many things won't play back without a token etc, so not for V1! - if 'http' in Store.current_playback.file: - Logger.info("Not recording playback as is an http source (plugin/addon) - not yet supported.") - return - # @TODO - add support for PVR recordings? Experience errors playing these back using the PVR file URL?? - elif 'recordings' in Store.current_playback.file: - Logger.warning("Not recording playback as is a PVR recording - not yet supported.") - return - - # Get more info on what is playing (empty values are returned if they're not relevant/set) - # Unfortunately, when playing from an offscreen playlist, the GetItem properties don't seem to be set, - # but can get info from the InfoLabels instead it seems, see: https://forum.kodi.tv/showthread.php?tid=379301 - - if xbmc.getCondVisibility('Player.HasVideo'): - stub = "Video" - else: - stub = "Music" - - json_dict = { - "jsonrpc": "2.0", - "id": "XBMC.GetInfoLabels", - "method": "XBMC.GetInfoLabels", - "params": {"labels": [ - "Player.Folderpath", - "Player.Art(thumb)", - "Player.Art(poster)", - "Player.Art(fanart)", - "Player.Duration", - "Player.Art(tvshow.poster)", - "Player.Art(movie.poster)", - # Episodes - "VideoPlayer.DBID", - "VideoPlayer.Title", - "VideoPlayer.Year", - "VideoPlayer.TVShowTitle", - "VideoPlayer.TvShowDBID", - "VideoPlayer.Season", - "VideoPlayer.Episode", - # PVR - "VideoPlayer.ChannelName", - "VideoPlayer.ChannelNumberLabel", - "VideoPlayer.ChannelGroup", - # Music - "MusicPlayer.DBID", - "MusicPlayer.Title", - "MusicPlayer.Year", - "MusicPlayer.Album", - "MusicPlayer.Artist", - "MusicPlayer.TrackNumber" - ]} - } - - query = json.dumps(json_dict) - properties_json = send_kodi_json(f'Get playback details from InfoLabels', query) - properties = properties_json['result'] - - # WHAT IS THE SOURCE - Kodi Library (...get DBID), PVR, or Non-Library Media? - Store.current_playback.dbid = int(properties.get(f'{stub}Player.DBID')) if properties.get(f'{stub}Player.DBID') else None - if Store.current_playback.dbid: - Store.current_playback.source = "kodi_library" - elif properties['VideoPlayer.ChannelName']: - Store.current_playback.source = "pvr_live" - else: - # Logger.info("Not from Kodi library, not PVR, not an http source - must be a non library media file") - Store.current_playback.source = "file" - - # TITLE - Store.current_playback.title = properties.get(f'{stub}Player.Title', "") - - # PLAYBACK TIME and DURATION - Store.current_playback.totaltime = Store.kodi_player.getTotalTime() - # Times in form 1:35:23 don't seem to work properly, seeing inaccurate durations, so use float from getTotalTime() below instead - Store.current_playback.duration = Store.kodi_player.getTotalTime() - # This will get updated as playback progress by the service, but initialise them here... - Store.current_playback.resumetime = Store.kodi_player.getTime() - - # ARTWORK - POSTER, FANART and THUMBNAIL - Store.current_playback.poster = properties.get('Player.Art(tvshow.poster)') or properties.get('Player.Art(poster)') - Store.current_playback.fanart = unquote(properties['Player.Art(fanart)']).replace("image://", "").rstrip("/") - if not Store.current_playback.poster: - Store.current_playback.poster = unquote(properties['Player.Art(fanart)']).replace("image://", "").rstrip("/") - Store.current_playback.thumbnail = unquote(properties['Player.Art(thumb)']).replace("image://", "").rstrip("/") - # @TODO - can this be improved? Why does Kodi return e.g. '.mkv' files for thumbnail - extracted thumbs?? - if 'jpg' not in Store.current_playback.thumbnail: - Store.current_playback.thumbnail = unquote(properties['Player.Art(fanart)']).replace("image://", "").rstrip("/") - - # DETERMINE THE MEDIA TYPE - not 100% on the logic here... - if properties['VideoPlayer.TVShowTitle']: - Store.current_playback.type = "episode" - Store.current_playback.tvshowdbid = int(properties['VideoPlayer.TvShowDBID']) if properties.get('VideoPlayer.TvShowDBID') else None - elif properties['VideoPlayer.ChannelName']: - Store.current_playback.type = "video" - if 'channels' in Store.current_playback.file: - Store.current_playback.source = "pvr.live" - elif 'recordings' in Store.current_playback.file: - Store.current_playback.source = "pvr.recording" - elif stub == 'Video' and Store.current_playback.dbid: - Store.current_playback.type = "movie" - elif stub == "Video": - Store.current_playback.type = "video" - else: - Store.current_playback.type = "song" - - # DETAILS - Store.current_playback.year = int(properties.get(f'{stub}Player.Year')) if properties.get(f'{stub}Player.Year') else None - Store.current_playback.showtitle = properties.get('VideoPlayer.TVShowTitle') - Store.current_playback.season = int(properties.get('VideoPlayer.Season')) if properties.get('VideoPlayer.Season') else None - Store.current_playback.episode = int(properties.get('VideoPlayer.Episode')) if properties.get('VideoPlayer.Episode') else None - Store.current_playback.channelname = properties.get('VideoPlayer.ChannelName') - Store.current_playback.channelnumberlabel = properties.get('VideoPlayer.ChannelNumberLabel') - Store.current_playback.channelgroup = properties.get('VideoPlayer.ChannelGroup') - Store.current_playback.album = properties.get('MusicPlayer.Album') - Store.current_playback.artist = properties.get('MusicPlayer.Artist') - Store.current_playback.tracknumber = int(properties.get('MusicPlayer.TrackNumber')) if properties.get('MusicPlayer.TrackNumber') else None - - # @TODO - consider NOT recording playbacks that are fully watched? - # More than changing this is needed though, as people rarely un things out right to the end, credits etc, so would need - # to retrieve watched status somehow... - def onPlayBackEnded(self): # video ended by simply running out (i.e. user didn't stop it) + Store.current_playback.update_playback_details(file, item) + + # Playback finished 'naturally' + def onPlayBackEnded(self): self.onPlaybackFinished() - def onPlayBackStopped(self): # user stopped video + # User stopped playback + def onPlayBackStopped(self): self.onPlaybackFinished() @staticmethod def onPlaybackFinished(): """ - Playback has finished, so update our Switchback List - """ - Logger.debug("onPlaybackFinished with Store.current_playback:") + Playback has finished - we need to update the PlaybackList and save it to file, and, if the user desires, force Kodi to browse to the appropriate show/season - if not Store.current_playback or not Store.current_playback.file: - Logger.error("No current playback details available...not recording this playback") + :return: + """ + if not Store.current_playback or not Store.current_playback.path: + Logger.error("onPlaybackFinished with no current playback details available?! ...not recording this playback") return + 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 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 switchback_playback == 'true': - if Store.current_playback.type == "episode": - Logger.debug("Force browsing to tvshow/season/ of just finished playback") - command = json.dumps({ - "jsonrpc": "2.0", - "id": 1, - "method": "GUI.ActivateWindow", - "params": { - "window": "videos", - # DBID is used here, not the expected TvShowDBID as I can't seem to get that infolabel set for a Switchback playback?? - # See switchback_plugin.py - 'dbid': playback.dbid if playback.type != 'episode' else playback.tvshowdbid, - "parameters": [f'videodb://tvshows/titles/{Store.current_playback.dbid}/{Store.current_playback.season}'], - } - }) - send_kodi_json(f'Browse to episode of {Store.current_playback.showtitle}', command) - # @TODO - is is possible to force Kodi to browse to a particular movie? - # (i.e. a particular movie focussed within the movies list) - # elif Store.current_playback.type == "movie": - # Logger.debug("Force browsing to movie of just finished playback") - # command = json.dumps({ - # "jsonrpc": "2.0", - # "id": 1, - # "method": "GUI.ActivateWindow", - # "params": { - # "window": "videos", - # "parameters": [f'videodb://movies/titles/', 'return'], - # } - # }) - # send_kodi_json(f'Browse to movie {Store.current_playback.title}', command) - - Logger.debug(Store.switchback_list) - - # This approach keeps all the details from the original playback - # (in case they don't make it through when the repeat playback from the Switchback list occurs - as sometimes seems to be the case) - playback_to_remove = None - for previous_playback in Store.switchback_list: - if previous_playback.file == Store.current_playback.file: - playback_to_remove = previous_playback - break + # 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 Store.episode_force_browse and 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}' + # 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}' + 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)') + + # 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) + playback_to_remove = Store.switchback.find_playback_by_path(Store.current_playback.path) if playback_to_remove: - Store.switchback_list.remove(playback_to_remove) + 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 - playback_to_remove.resumetime = Store.current_playback.resumetime - playback_to_remove.totaltime = Store.current_playback.totaltime - Store.switchback_list.insert(0, playback_to_remove) + if Store.current_playback.source != "pvr_live": + playback_to_remove.update({'resumetime': Store.current_playback.resumetime, 'totaltime': Store.current_playback.totaltime}) + # Re-insert at the top of the list + Store.switchback.list.insert(0, playback_to_remove) else: - Store.switchback_list.insert(0, Store.current_playback) - - Logger.debug(Store.switchback_list) + Store.switchback.list.insert(0, Store.current_playback) # Trim the list to the max length - Store.switchback_list = Store.switchback_list[0:Store.maximum_list_length] + Store.switchback.list = Store.switchback.list[0:Store.maximum_list_length] + # Finally, save the updated PlaybackList + 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() - # Finally, Save the playback list to file - Store.save_switchback_list() + # 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/plugin.switchback/resources/lib/store.py b/plugin.switchback/resources/lib/store.py index 9b62ada2d9..1402b34054 100644 --- a/plugin.switchback/resources/lib/store.py +++ b/plugin.switchback/resources/lib/store.py @@ -1,34 +1,38 @@ -import json -from json import JSONDecodeError - -from bossanova808.constants import * -from bossanova808.logger import Logger -from bossanova808.notify import Notify import os -from types import SimpleNamespace -from resources.lib.playback import Playback +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 resources.lib.playback import PlaybackList class Store: """ - Helper class to read in and store the addon settings, and to provide a centralised store + Helper class to read in and store the addon settings, and to provide a general centralised store + Create one with: Store() """ # Static class variables, referred to elsewhere by Store.whatever # https://docs.python.org/3/faq/programming.html#how-do-i-create-static-class-data-and-static-class-methods kodi_event_monitor = None kodi_player = None # Holds our playlist of things played back, in first is the latest order - switchback_list = [] - switchback_list_file = xbmcvfs.translatePath(os.path.join(PROFILE, "switchback_list.json")) + switchback = None # When something is being played back, store the details current_playback = None + # Playbacks are of these possible types + kodi_video_types = ["movie", "tvshow", "episode", "musicvideo", "video", "file"] + kodi_music_types = ["song", "album"] # Addon settings + save_across_sessions = ADDON.getSettingBool('save_across_sessions') maximum_list_length = ADDON.getSettingInt('maximum_list_length') - include_music = ADDON.getSettingBool('include_music') - # Playbacks are of these possible types - kodi_video_types = ["movie", "tvshow", "episode", "musicvideo", "video"] - kodi_music_types = ["song","album"] + enable_context_menu = ADDON.getSettingBool('enable_context_menu') + episode_force_browse = ADDON.getSettingBool('episode_force_browse') + remove_watched_playbacks = ADDON.getSettingBool('remove_watched_playbacks') + + # GUI Settings - to work out how to force browse to a show after a switchback initiated playback + flatten_tvshows = None def __init__(self): """ @@ -36,6 +40,11 @@ def __init__(self): :return: """ Store.load_config_from_settings() + Store.load_config_from_kodi_settings() + Store.switchback = PlaybackList([], xbmcvfs.translatePath(os.path.join(PROFILE, "switchback.json")), Store.remove_watched_playbacks) + Store.switchback.load_or_init() + Store.update_switchback_context_menu() + @staticmethod def load_config_from_settings(): @@ -45,49 +54,36 @@ def load_config_from_settings(): """ Logger.info("Loading configuration") Store.maximum_list_length = ADDON.getSettingInt('maximum_list_length') - Store.include_music = ADDON.getSettingBool('include_music') Logger.info(f"Maximum Switchback list length is: {Store.maximum_list_length}") - Logger.info(f"Include Music is: {Store.include_music}") - - Store.switchback_list = [] - Logger.info(f"Loading Switchback playlist from file: {Store.switchback_list_file}") - try: - with open(Store.switchback_list_file, 'r') as switchback_list_file: - switchback_list_json = json.load(switchback_list_file) - for playback in switchback_list_json: - Store.switchback_list.append(Playback(**playback)) - except FileNotFoundError: - Logger.error("Switchback list file not found, creating empty Switchback list file") - # Creates an empty Switchback list file if it doesn't yet exist - os.makedirs(os.path.dirname(Store.switchback_list_file), exist_ok=True) - with open(Store.switchback_list_file, 'w'): - pass - Store.switchback_list = [] - except JSONDecodeError: - Logger.error("Unable to parse switchback list, JSONDecodeError...corrupt or empty, starting a new empty list") - Store.switchback_list = [] - except: - raise - - # Filter out any music playbacks if the setting is currently not to record them - for playback_to_maybe_remove in Store.switchback_list: - if not Store.include_music and playback_to_maybe_remove.type in Store.kodi_music_types: - Logger.warning("Include music is false, removing music playback from Switchback list") - Store.switchback_list.remove(playback_to_maybe_remove) - - Logger.info("Switchback List is:") - Logger.info(Store.switchback_list) + 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"Enable context menu is: {Store.enable_context_menu}") + Store.remove_watched_playbacks = ADDON.getSettingBool('remove_watched_playbacks') + Logger.info(f"Remove watched playbacks is: {Store.remove_watched_playbacks}") + Store.episode_force_browse = ADDON.getSettingBool('episode_force_browse') + Logger.info(f"Episode force browse is: {Store.episode_force_browse}") @staticmethod - def save_switchback_list(): - """ - Save the Switchback list to file in JSON format - :return: - """ - with open(Store.switchback_list_file, 'w', encoding='utf-8') as f: - json_string = json.dumps([vars(playback) for playback in Store.switchback_list], indent=4) - f.write(json_string) - - + 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}") + @staticmethod + def update_switchback_context_menu(): + if Store.enable_context_menu: + Logger.debug(f"Updating Home Window Properties for context menu") + Logger.debug("Switchback list is:", Store.switchback.list) + set_property(HOME_WINDOW, 'Switchback_List_Length', str(len(Store.switchback.list))) + if len(Store.switchback.list) == 1: + set_property(HOME_WINDOW, 'Switchback_Item', Store.switchback.list[0].pluginlabel) + elif len(Store.switchback.list) > 1: + set_property(HOME_WINDOW, 'Switchback_Item', Store.switchback.list[1].pluginlabel) + else: + clear_property(HOME_WINDOW, 'Switchback_Item') + @staticmethod + def update_home_window_switchback_property(path: str): + Logger.debug(f"Updating Home Window Properties for playback, path: {path}") + set_property(HOME_WINDOW, 'Switchback', path) diff --git a/plugin.switchback/resources/lib/switchback_context_menu.py b/plugin.switchback/resources/lib/switchback_context_menu.py new file mode 100644 index 0000000000..b600c03e87 --- /dev/null +++ b/plugin.switchback/resources/lib/switchback_context_menu.py @@ -0,0 +1,13 @@ +import xbmc +from bossanova808.logger import Logger + + +# This is 'main'... +# noinspection PyUnusedLocal +def run(args): + Logger.start(f"(Context Menu) {args}") + if args[0] == "switchback": + xbmc.executebuiltin("PlayMedia(plugin://plugin.switchback/?mode=switchback,resume)") + else: + xbmc.executebuiltin("RunAddon(plugin.switchback)") + Logger.stop("(Context Menu)") diff --git a/plugin.switchback/resources/lib/switchback_plugin.py b/plugin.switchback/resources/lib/switchback_plugin.py index cc4330559a..5be4fdaea1 100644 --- a/plugin.switchback/resources/lib/switchback_plugin.py +++ b/plugin.switchback/resources/lib/switchback_plugin.py @@ -1,123 +1,148 @@ +import sys from urllib.parse import parse_qs +# noinspection PyUnresolvedReferences import xbmc import xbmcplugin -import json -from bossanova808.constants import * +import xbmcgui + +from resources.lib.store import Store +from bossanova808.constants import TRANSLATE from bossanova808.logger import Logger from bossanova808.notify import Notify -from bossanova808.utilities import * -# noinspection PyPackages -from .store import Store -from infotagger.listitem import ListItemInfoTag - - -def create_kodi_list_item_from_playback(playback, index=None, offscreen=False): - Logger.info("Creating list item from playback") - Logger.info(playback) - - label = playback.title - if playback.showtitle: - if playback.season >= 0 and playback.episode >= 0: - label = f"{playback.showtitle} ({playback.season}x{playback.episode:02d}) - {playback.title}" - elif playback.season >= 0: - label = f"{playback.showtitle} ({playback.season}x?) - {playback.title}" - else: - label = f"{playback.showtitle} - {playback.title}" - elif playback.channelname: - if playback.source == "pvr.live": - label = f"PVR Live - Channel {playback.channelname}" - else: - label = f"PVR Recording - Channel {playback.channelname} - {playback.title}" - elif playback.album: - label = f"{playback.artist} - {playback.album} - {playback.tracknumber:02d}. {playback.title}" - elif playback.artist: - label = f"{playback.artist} - {playback.title}" - - list_item = xbmcgui.ListItem(label=label, path=playback.file, offscreen=offscreen) - tag = ListItemInfoTag(list_item, 'video') - infolabels = { - 'mediatype': playback.type, - 'dbid': playback.dbid if playback.type != 'episode' else playback.tvshowdbid, - 'tvshowdbid': playback.tvshowdbid, - 'title': playback.title, - 'year': playback.year, - 'tvshowtitle': playback.showtitle, - 'episode': playback.episode, - 'season': playback.season, - 'album': playback.album, - 'artist': [playback.artist], - 'tracknumber': playback.tracknumber, - 'duration': playback.totaltime, - } - # Infotagger seems the best way to do this currently as is well teste - # I found directly setting things on InfoVideoTag to be buggy/inconsistent - tag.set_info(infolabels) - list_item.setPath(path=playback.file) - list_item.setArt({"thumbnail": playback.thumbnail}) - list_item.setArt({"poster": playback.poster}) - list_item.setArt({"fanart": playback.fanart}) - # Auto resumes just won't work without these, even though they are deprecated... - list_item.setProperty('TotalTime', str(playback.totaltime)) - list_item.setProperty('ResumeTime', str(playback.resumetime)) - list_item.setProperty('StartOffset', str(playback.resumetime)) - list_item.setProperty('IsPlayable', 'true') - - # index can be zero, so in this case, must explicitly check against None! - if index is not None: - list_item.addContextMenuItems([(LANGUAGE(32004), "RunPlugin(plugin://plugin.switchback?mode=delete&index=" + str(index) + ")")]) - - return list_item - - -def run(args): - plugin_instance = int(sys.argv[1]) - footprints() - Logger.info("(Plugin)") + +# PVR HACK! +# 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()) + # Get the full details from our stored playback + # pvr_playback = Store.switchback.find_playback_by_path(path) + 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) + + +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() - xbmcplugin.setContent(plugin_instance, 'mixed') + plugin_instance = int(sys.argv[1]) + xbmcplugin.setContent(plugin_instance, 'video') parsed_arguments = parse_qs(sys.argv[2][1:]) - Logger.info(parsed_arguments) + Logger.debug(parsed_arguments) mode = parsed_arguments.get('mode', None) - Logger.info(f"Mode: {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 mode and mode[0] == "switchback": - try: - switchback = Store.switchback_list[1] - Logger.info(f"Playing previous file (Store.switchback_list[1]) {switchback.file}") - list_item = create_kodi_list_item_from_playback(switchback, offscreen=True) - Notify.kodi_notification(f"{list_item.getLabel()}", 3000, ADDON_ICON) + # 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 "switchback" in modes: - # Set a property so we can force browse later at the end of playback - HOME_WINDOW.setProperty('Switchback', 'true') + # First, determine what to play, if anything... + if not Store.switchback.list: + Notify.error(TRANSLATE(32007)) + Logger.error("No Switchback found to play") + return - xbmcplugin.setResolvedUrl(plugin_instance, True, list_item) - except IndexError: - Notify.error("No previous item found to play") - Logger.error("No previous item found to play") + 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.save_switchback_list() - # Force refresh the list - Logger.debug("Force refresh the container so we see the latest Switchback list") - xbmc.executebuiltin("Container.Refresh") - - # Default mode - show the whole Switchback List + 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 = create_kodi_list_item_from_playback(playback, index=index) - xbmcplugin.addDirectoryItem(plugin_instance, playback.file, list_item) - - xbmcplugin.endOfDirectory(plugin_instance) + for index, playback in enumerate(Store.switchback.list[0:Store.maximum_list_length]): + list_item = playback.create_list_item_from_playback() + # 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) + # 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) # And we're done... - footprints(startup=False) + Logger.stop("(Plugin)") diff --git a/plugin.switchback/resources/lib/switchback_service.py b/plugin.switchback/resources/lib/switchback_service.py index 307e3ccd2b..67504a5af1 100644 --- a/plugin.switchback/resources/lib/switchback_service.py +++ b/plugin.switchback/resources/lib/switchback_service.py @@ -1,31 +1,31 @@ from bossanova808.utilities import * -# noinspection PyPackages -from .store import Store -# noinspection PyPackages -from .monitor import KodiEventMonitor -# noinspection PyPackages -from .player import KodiPlayer +from resources.lib.store import Store +from resources.lib.monitor import KodiEventMonitor +from resources.lib.player import KodiPlayer import xbmc # This is 'main'... def run(): - - footprints() - Logger.info("(Service)") + Logger.start("(Service)") Store() Store.kodi_event_monitor = KodiEventMonitor(xbmc.Monitor) Store.kodi_player = KodiPlayer(xbmc.Player) while not Store.kodi_event_monitor.abortRequested(): - # Abort was requested while waiting. We should exit + # Abort was requested while waiting. We should exit. if Store.kodi_event_monitor.waitForAbort(1): break # Otherwise, if we're playing something, record where we are up to, for later resumes # (Playback record is created onAVStarted in player.py, so check here that it is available) - elif Store.current_playback and Store.kodi_player.isPlaying(): + elif Store.current_playback and Store.current_playback.source != "pvr_live" and Store.kodi_player.isPlaying(): Store.current_playback.resumetime = Store.kodi_player.getTime() - Store.kodi_event_monitor.waitForAbort(0.5) + xbmc.sleep(500) + + # Tidy up if the user wants us to + if not Store.save_across_sessions: + Logger.info('save_across_sessions is False, so deleting switchback.json') + Store.switchback.delete_file() - # and, we're done... - footprints(startup=False) + # And, we're done... + Logger.stop("(Service)") diff --git a/plugin.switchback/resources/settings.xml b/plugin.switchback/resources/settings.xml index 4e48f81949..c4b4520498 100644 --- a/plugin.switchback/resources/settings.xml +++ b/plugin.switchback/resources/settings.xml @@ -6,11 +6,29 @@ 0 5 - - 32002 - + + 0 + 20 + 1 + + - + + 0 + true + + + + 0 + true + + + + 0 + true + + + 0 false diff --git a/plugin.switchback/service.py b/plugin.switchback/service.py index 5395ccd81c..9f5e5231dc 100644 --- a/plugin.switchback/service.py +++ b/plugin.switchback/service.py @@ -1,6 +1,7 @@ from bossanova808 import exception_logger from resources.lib import switchback_service + if __name__ == "__main__": with exception_logger.log_exception(): switchback_service.run()