Skip to content

Commit 334df76

Browse files
committed
Fix duplicate list entries for the same media across repeated plays; speed up list loading (fewer JSON-RPC round trips); requires bossanova808 1.0.4
1 parent 8cf3ccd commit 334df76

4 files changed

Lines changed: 88 additions & 44 deletions

File tree

‎addon.xml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<addon id="plugin.switchback" name="Switchback" version="2.0.1" provider-name="bossanova808">
33
<requires>
44
<import addon="xbmc.python" version="3.0.1" />
5-
<import addon="script.module.bossanova808" version="1.0.3" />
5+
<import addon="script.module.bossanova808" version="1.0.4" />
66
</requires>
77
<!-- The main service entry point, to keep track of playbacks -->
88
<extension point="xbmc.service" library="service.py" />
@@ -59,6 +59,8 @@ Tänk dig följande scenario:
5959
- Quick Switchback now resumes PVR live TV and recordings correctly, with proper channel controls
6060
- Fix a rare crash when saving the Switchback list on Windows
6161
- Resume notifications now show a timestamp, and distinguish live TV/recordings from other video
62+
- Fix duplicate list entries appearing for the same movie/episode/etc across repeated plays (could happen for addon-backed library items, e.g. Jellyfin, where the reported path can differ between plays)
63+
- Faster list loading: halved the JSON-RPC calls needed to refresh a list of library items, and removed an unnecessary call on every list view
6264
</news>
6365
<assets>
6466
<icon>resources/icon.png</icon>

‎resources/lib/playback.py‎

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# noinspection PyPackages
99
from bossanova808.playback import Playback
1010
# noinspection PyPackages
11-
from bossanova808.utilities import get_resume_point, get_playcount, get_advancedsetting
11+
from bossanova808.utilities import get_playcount_and_resume_point, get_advancedsetting
1212
# noinspection PyPackages
1313
from bossanova808.logger import Logger
1414

@@ -69,47 +69,68 @@ def load_or_init(self) -> None:
6969

7070
list_needs_save = False
7171

72-
# If the user wants to filter out watched items from the list
73-
if self.remove_watched_playbacks:
74-
paths_to_remove = []
75-
for item in list(self.list):
76-
# DB item? Is it marked as watched in the DB?
77-
if item.dbid:
78-
playcount = get_playcount(item.type, item.dbid)
72+
# Defensively collapse any duplicate entries for the same piece of media - there should only
73+
# ever be one entry (the most recent) per movie/episode/etc. Duplicates can end up here from
74+
# an older version of the addon that deduplicated on .path alone, which isn't always stable
75+
# for the same media across plays (see Playback.identity_key). The list is newest-first, so
76+
# keeping the first occurrence of each identity keeps the most recent one.
77+
seen_identities = set()
78+
deduplicated_list = []
79+
for item in self.list:
80+
identity = item.identity_key
81+
if identity in seen_identities:
82+
Logger.warning(f"Removing duplicate PlaybackList entry for [{item.pluginlabel}] (identity: {identity})")
83+
list_needs_save = True
84+
continue
85+
seen_identities.add(identity)
86+
deduplicated_list.append(item)
87+
self.list = deduplicated_list
88+
89+
# Refresh resume points from the Kodi library (consider e.g. shared library scenarios), and -
90+
# if the user wants it - filter out watched items, for every library item in one pass. Both
91+
# pieces of data come from the same JSON-RPC call per dbid, rather than two separate
92+
# round trips, since a list with several library items otherwise means twice as many
93+
# JSON-RPC calls on every single load (e.g. just viewing the Switchback list).
94+
paths_to_remove = []
95+
for item in list(self.list):
96+
# DB item? Refresh its resume point, and its playcount if we care about that here
97+
if item.dbid:
98+
playcount, library_resume_point = get_playcount_and_resume_point(item.type, item.dbid)
99+
100+
if self.remove_watched_playbacks:
79101
if playcount is None:
80102
Logger.warning(f"dbid {item.dbid} no longer valid in Kodi library for [{item.pluginlabel}] - removing from Switchback list")
81103
paths_to_remove.append(item.path)
82104
list_needs_save = True
105+
continue
83106
elif playcount > 0:
84107
list_needs_save = True
85108
Logger.warning(f"Filtering watched playback from the list (as playcount > 0 in Kodi DB): [{item.pluginlabel}]")
86109
paths_to_remove.append(item.path)
110+
continue
87111

88-
# Not a DB item, use a calculation instead and compare to the playcount_minium_percent
89-
elif item.resumetime and item.totaltime:
90-
percent_played = (item.resumetime / item.totaltime) * 100
91-
# Use the user set playcount_minium_percent if there is one, or fallback to Kodi default 90 percent
92-
setting = get_advancedsetting('video/playcountminimumpercent')
93-
playcount_minium_percent = float(setting) if setting and setting != 0 else 90.0
94-
if percent_played >= playcount_minium_percent:
95-
list_needs_save = True
96-
Logger.debug(f"Filtering watched playback from the list (as {percent_played:.1f}% played over playcount_minium_percent {playcount_minium_percent}%): [{item.pluginlabel}]")
97-
paths_to_remove.append(item.path)
98-
99-
if paths_to_remove:
100-
list_needs_save = True
101-
for path in paths_to_remove:
102-
self.remove_playbacks_of_path(path)
103-
104-
# Update resume points with current data from the Kodi library (consider e.g. shared library scenarios)
105-
for item in self.list:
106-
if item.dbid:
107-
library_resume_point = get_resume_point(item.type, item.dbid)
108112
if library_resume_point != item.resumetime:
109113
Logger.debug(f"Retrieved library resume point: {library_resume_point} != existing list resume point {item.resumetime} - updating playback list")
110114
list_needs_save = True
111115
item.resumetime = library_resume_point
112116

117+
# Not a DB item - if the user wants watched items filtered, use a calculation instead
118+
# and compare to the playcount_minium_percent (there's no library playcount to check)
119+
elif self.remove_watched_playbacks and item.resumetime and item.totaltime:
120+
percent_played = (item.resumetime / item.totaltime) * 100
121+
# Use the user set playcount_minium_percent if there is one, or fallback to Kodi default 90 percent
122+
setting = get_advancedsetting('video/playcountminimumpercent')
123+
playcount_minium_percent = float(setting) if setting and setting != 0 else 90.0
124+
if percent_played >= playcount_minium_percent:
125+
list_needs_save = True
126+
Logger.debug(f"Filtering watched playback from the list (as {percent_played:.1f}% played over playcount_minium_percent {playcount_minium_percent}%): [{item.pluginlabel}]")
127+
paths_to_remove.append(item.path)
128+
129+
if paths_to_remove:
130+
list_needs_save = True
131+
for path in paths_to_remove:
132+
self.remove_playbacks_of_path(path)
133+
113134
if list_needs_save:
114135
self.save_to_file()
115136

@@ -175,3 +196,24 @@ def find_playback_by_path(self, path: str) -> Optional[Playback]:
175196
return playback
176197
Logger.debug(f"No matching playback for [{path}]")
177198
return None
199+
200+
def find_playback_by_identity(self, playback: Playback) -> Optional[Playback]:
201+
"""
202+
Return an existing list entry representing the same piece of media as the given playback
203+
(see Playback.identity_key), if found, otherwise None. Unlike find_playback_by_path(), this
204+
still recognises the same library movie/episode/etc even if the specific path/file Kodi
205+
reports for it differs between plays (e.g. a direct library click vs an addon-triggered
206+
Switchback replay of the same item) - which is what actually determines whether a repeat
207+
play updates the existing entry or wrongly creates a duplicate.
208+
209+
:param playback: the Playback to find an existing match for
210+
:return: Playback or None: The matching Playback object if found, otherwise None
211+
"""
212+
identity = playback.identity_key
213+
Logger.debug(f"find_playback_by_identity: {identity}")
214+
for existing in self.list:
215+
if existing.identity_key == identity:
216+
Logger.debug(f"Matched playback to [{existing.pluginlabel}]")
217+
return existing
218+
Logger.debug(f"No matching playback for identity [{identity}]")
219+
return None

‎resources/lib/player.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from bossanova808.constants import HOME_WINDOW
44
from bossanova808.logger import Logger
5+
from bossanova808.utilities import get_kodi_setting
56
from resources.lib.playback import Playback
67

78
from resources.lib.store import Store
@@ -87,21 +88,30 @@ def onPlaybackFinished():
8788
if Store.episode_force_browse and switchback_playback:
8889
if Store.current_playback.type == "episode" and Store.current_playback.source == "kodi_library":
8990
Logger.info("Force browsing to tvshow/season of just finished playback")
90-
Logger.debug(f'flatten tvshows {Store.flatten_tvshows} totalseasons {Store.current_playback.totalseasons} dbid {Store.current_playback.dbid} tvshowdbid {Store.current_playback.tvshowdbid}')
91+
# Fetched fresh here rather than cached, since it's a Kodi system setting the user
92+
# can change at any time - there's no settings-changed notification we can hook for
93+
# it (unlike our own addon settings), so a stale cached value could otherwise persist
94+
# for the rest of the Kodi session. This only runs for episodes with force-browse
95+
# enabled, so the extra JSON-RPC call here is rare and not worth caching.
96+
flatten_tvshows = int(get_kodi_setting('videolibrary.flattentvshows'))
97+
Logger.debug(f'flatten tvshows {flatten_tvshows} totalseasons {Store.current_playback.totalseasons} dbid {Store.current_playback.dbid} tvshowdbid {Store.current_playback.tvshowdbid}')
9198
# Default: Browse to the show
9299
window = f'videodb://tvshows/titles/{Store.current_playback.tvshowdbid}'
93100
# 0 = Never flatten → browse to show root
94101
# 1 = If only one season → browse to season only when there are multiple seasons
95102
# 2 = Always flatten → browse to season
96-
if Store.flatten_tvshows == 2:
103+
if flatten_tvshows == 2:
97104
window += f'/{Store.current_playback.season}'
98-
elif Store.flatten_tvshows == 1 and (Store.current_playback.totalseasons or 0) > 1:
105+
elif flatten_tvshows == 1 and (Store.current_playback.totalseasons or 0) > 1:
99106
window += f'/{Store.current_playback.season}'
100107
xbmc.executebuiltin(f'ActivateWindow(Videos,{window},return)')
101108

102109
# This rather long-winded approach is used to keep ALL the details recorded from the original playback
103110
# (in case they don't make it through when the playback is Switchback initiated - as sometimes seems to be the case)
104-
playback_to_remove = Store.switchback.find_playback_by_path(Store.current_playback.path)
111+
# Matched by identity (not path) - the path/file Kodi reports for the same library item can
112+
# differ between a direct play and a Switchback-triggered replay (see Playback.identity_key),
113+
# and matching on path alone would otherwise create a duplicate entry for the same media.
114+
playback_to_remove = Store.switchback.find_playback_by_identity(Store.current_playback)
105115
if playback_to_remove:
106116
Logger.debug("Updating Playback and list order")
107117
# Remove it from its current position

‎resources/lib/store.py‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from bossanova808.constants import HOME_WINDOW, PROFILE, ADDON
66
from bossanova808.logger import Logger
7-
from bossanova808.utilities import get_kodi_setting, set_property, clear_property
7+
from bossanova808.utilities import set_property, clear_property
88
from resources.lib.playback import PlaybackList
99

1010

@@ -31,16 +31,12 @@ class Store:
3131
episode_force_browse = ADDON.getSettingBool('episode_force_browse')
3232
remove_watched_playbacks = ADDON.getSettingBool('remove_watched_playbacks')
3333

34-
# GUI Settings - to work out how to force browse to a show after a switchback initiated playback
35-
flatten_tvshows = None
36-
3734
def __init__(self):
3835
"""
3936
Load in the addon settings and do basic initialisation stuff
4037
:return:
4138
"""
4239
Store.load_config_from_settings()
43-
Store.load_config_from_kodi_settings()
4440
Store.switchback = PlaybackList([], xbmcvfs.translatePath(os.path.join(PROFILE, "switchback.json")), Store.remove_watched_playbacks)
4541
Store.switchback.load_or_init()
4642
Store.update_switchback_context_menu()
@@ -64,12 +60,6 @@ def load_config_from_settings():
6460
Store.episode_force_browse = ADDON.getSettingBool('episode_force_browse')
6561
Logger.info(f"Episode force browse is: {Store.episode_force_browse}")
6662

67-
@staticmethod
68-
def load_config_from_kodi_settings():
69-
# Note: this is an int, not a bool — 0 = Never, 1 = 'If only one season', 2 = Always
70-
Store.flatten_tvshows = int(get_kodi_setting('videolibrary.flattentvshows'))
71-
Logger.info(f"Flatten TV Shows is: {Store.flatten_tvshows}")
72-
7363
@staticmethod
7464
def update_switchback_context_menu():
7565
if Store.enable_context_menu:

0 commit comments

Comments
 (0)