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
[](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
+
+
+
+
+
- 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.
allGPL-3.0-only
@@ -33,8 +55,14 @@ Consider this scenario:
https://github.com/bossanova808/plugin.switchback/https://forum.kodi.tv/showthread.php?tid=379330bossanova808@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 @@
05
-
- 32002
-
+
+ 0
+ 20
+ 1
+
+
-
+
+ 0
+ true
+
+
+
+ 0
+ true
+
+
+
+ 0
+ true
+
+
+ 0false
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()