feat(intro-skipper): initial support for introskipper

This commit is contained in:
Tim 2026-01-01 13:12:46 +11:00
commit 6a2c1264de
7 changed files with 765 additions and 0 deletions

View file

@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
from __future__ import division, absolute_import, print_function, unicode_literals
##################################################################################################
import xbmc
import xbmcgui
from ..helper import LazyLogger
from ..helper.translate import translate
##################################################################################################
LOG = LazyLogger(__name__)
# Action IDs
ACTION_PARENT_DIR = 9
ACTION_PREVIOUS_MENU = 10
ACTION_BACK = 92
ACTION_SELECT = 7
# Control IDs
SKIP_BUTTON = 3020
COUNTDOWN_LABEL = 3021
# String IDs for segment types
SEGMENT_STRING_IDS = {
"Introduction": 33252,
"Credits": 33253,
"Recap": 33254,
"Preview": 33255,
"Commercial": 33259,
}
##################################################################################################
class SkipDialog(xbmcgui.WindowXMLDialog):
"""
OSD overlay dialog for skipping intro/outro segments.
Displays a "Skip" button in the bottom-right corner during playback
when a skippable segment is detected.
"""
_segment_type = None
_duration = 0
_timeout = 5 # seconds before auto-dismiss
skip_requested = False
def __init__(self, *args, **kwargs):
self._segment_type = kwargs.pop("segment_type", None)
self._duration = kwargs.pop("duration", 0)
self._timeout = kwargs.pop("timeout", 5)
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
def set_skip_info(self, segment_type, duration, timeout=5):
"""
Set the skip segment information.
Args:
segment_type: Type of segment (Introduction, Credits, Recap, Preview, Commercial)
duration: Duration of the segment in seconds
timeout: Seconds before dialog auto-dismisses (default 5)
"""
self._segment_type = segment_type
self._duration = duration
self._timeout = timeout
def onInit(self):
"""Initialize the dialog controls."""
# Format duration text
minutes = int(self._duration // 60)
seconds = int(self._duration % 60)
if minutes > 0:
duration_text = f"{minutes}m {seconds}s"
else:
duration_text = f"{seconds}s"
# Get translated segment type label
string_id = SEGMENT_STRING_IDS.get(self._segment_type)
if string_id:
segment_label = translate(string_id)
else:
segment_label = self._segment_type or "Segment"
# Set button label: "Skip Introduction (1m 30s)"
skip_text = translate(33256) # "Skip"
button_label = f"{skip_text} {segment_label} ({duration_text})"
try:
self.getControl(SKIP_BUTTON).setLabel(button_label)
except Exception as e:
LOG.warning(f"Could not set skip button label: {e}")
# Start auto-dismiss countdown
self._start_countdown()
def _start_countdown(self):
"""Start the auto-dismiss countdown timer."""
monitor = xbmc.Monitor()
remaining = self._timeout
while remaining > 0 and not monitor.abortRequested():
try:
countdown_label = self.getControl(COUNTDOWN_LABEL)
countdown_label.setLabel(f"{remaining}s")
except Exception:
pass
if monitor.waitForAbort(1):
break
remaining -= 1
# Check if dialog was closed by user
if not self.isActive():
return
# Auto-dismiss if not interacted with
if self.isActive() and not self.skip_requested:
self.close()
def isActive(self):
"""Check if the dialog is still visible."""
try:
return xbmcgui.getCurrentWindowDialogId() == 3302
except Exception:
return False
def onAction(self, action):
"""Handle user actions."""
action_id = action.getId()
if action_id in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU):
self.skip_requested = False
self.close()
elif action_id == ACTION_SELECT:
# SELECT on the button triggers skip
self.skip_requested = True
self.close()
def onClick(self, control_id):
"""Handle control clicks."""
if control_id == SKIP_BUTTON:
LOG.info(f"Skip button clicked for {self._segment_type}")
self.skip_requested = True
self.close()
def was_skipped(self):
"""Return whether the user requested to skip."""
return self.skip_requested
def show_skip_dialog(segment_type, duration, timeout=5):
"""
Convenience function to show the skip dialog.
Args:
segment_type: Type of segment (Introduction, Credits, etc.)
duration: Duration of the segment in seconds
timeout: Auto-dismiss timeout in seconds
Returns:
True if user clicked skip, False otherwise
"""
from ..helper.utils import translate_path
addon_path = translate_path("special://home/addons/plugin.video.jellyfin/resources/skins/")
dialog = SkipDialog(
"script-jellyfin-skip.xml",
addon_path,
"default",
"1080i",
segment_type=segment_type,
duration=duration,
timeout=timeout
)
dialog.doModal()
return dialog.was_skipped()

View file

@ -507,3 +507,31 @@ class API(object):
"""
response = self.send_request(server_address, "system/info/public")
return response.url.replace("/system/info/public", "")
def get_intro_skipper_segments(self, item_id):
"""Get intro-skipper plugin segments (Introduction, Credits, Recap, Preview)."""
try:
return self._get("Episode/%s/IntroSkipperSegments" % item_id)
except HTTPException as e:
if e.status == 404:
LOG.debug("Intro-skipper plugin not installed or no segments for %s", item_id)
else:
LOG.warning("Error fetching intro-skipper segments: %s", e)
return None
except Exception as e:
LOG.warning("Error fetching intro-skipper segments: %s", e)
return None
def get_media_segments(self, item_id):
"""Get native Media Segments API data (Jellyfin 10.10+ fallback)."""
try:
return self._get("MediaSegments", params={"itemId": item_id})
except HTTPException as e:
if e.status == 404:
LOG.debug("Media Segments API not available for %s", item_id)
else:
LOG.warning("Error fetching media segments: %s", e)
return None
except Exception as e:
LOG.warning("Error fetching media segments: %s", e)
return None

View file

@ -25,6 +25,9 @@ class Player(xbmc.Player):
played = {}
up_next = False
skip_segments = {}
skip_prompted = set()
skip_dialog = None
def __init__(self):
xbmc.Player.__init__(self)
@ -117,6 +120,8 @@ class Player(xbmc.Player):
item["Server"].jellyfin.session_playing(data)
window("jellyfin.skip.%s.bool" % item["Id"], True)
self._fetch_skip_segments(item)
if monitor.waitForAbort(2):
return
@ -387,6 +392,9 @@ class Player(xbmc.Player):
}
item["Server"].jellyfin.session_progress(data)
if settings("introSkipEnabled.bool"):
self.check_skip_segments(item, item["CurrentPosition"])
def onPlayBackStopped(self):
"""Will be called when user stops playing a file."""
window("jellyfin_play", clear=True)
@ -472,3 +480,160 @@ class Player(xbmc.Player):
window("jellyfin.external_check", clear=True)
self.played.clear()
def _fetch_skip_segments(self, item):
if not settings("introSkipEnabled.bool"):
return
item_id = item["Id"]
self.skip_segments.pop(item_id, None)
self.skip_prompted = set()
if self.skip_dialog:
try:
self.skip_dialog.close()
except Exception:
pass
self.skip_dialog = None
segments = item["Server"].jellyfin.get_intro_skipper_segments(item_id)
if not segments:
segments = item["Server"].jellyfin.get_media_segments(item_id)
if segments:
segments = self._convert_media_segments(segments)
if segments:
self.skip_segments[item_id] = segments
LOG.info("Loaded intro-skipper segments for %s: %s", item_id, list(segments.keys()))
def _convert_media_segments(self, response):
if not response or "Items" not in response:
return None
type_map = {
"Intro": "Introduction",
"Outro": "Credits",
"Recap": "Recap",
"Preview": "Preview",
"Commercial": "Commercial",
}
segments = {}
for item in response["Items"]:
seg_type = type_map.get(item.get("Type"))
if seg_type:
segments[seg_type] = {
"EpisodeId": item.get("ItemId"),
"Start": item.get("StartTicks", 0) / 10000000.0,
"End": item.get("EndTicks", 0) / 10000000.0,
}
return segments if segments else None
def check_skip_segments(self, item, current_position):
item_id = item["Id"]
segments = self.skip_segments.get(item_id)
if not segments:
return
for segment_type, segment in segments.items():
if not self._is_segment_enabled(segment_type):
continue
start = segment.get("Start", 0)
end = segment.get("End", 0)
if not start or not end or end <= start:
continue
if start <= current_position <= start + 5:
segment_key = "%s:%s" % (item_id, segment_type)
if segment_key in self.skip_prompted:
continue
self.skip_prompted.add(segment_key)
if segment_type == "Credits" and not self.up_next:
self.up_next = True
self.next_up()
self._handle_skip_segment(segment_type, start, end)
break
def _is_segment_enabled(self, segment_type):
setting_map = {
"Introduction": "skipIntroduction.bool",
"Credits": "skipCredits.bool",
"Recap": "skipRecap.bool",
"Preview": "skipPreview.bool",
"Commercial": "skipCommercial.bool",
}
setting_key = setting_map.get(segment_type)
if not setting_key:
return False
return settings(setting_key)
def _handle_skip_segment(self, segment_type, start, end):
mode = settings("introSkipMode")
if mode == 0:
self.seekTime(end)
LOG.info("Auto-skipped %s to %.1f", segment_type, end)
elif mode == 1:
self._show_skip_button(segment_type, end - start, end)
elif mode == 2:
if dialog("yesno", "{jellyfin}", translate(33257), autoclose=5000):
self.seekTime(end)
LOG.info("User skipped %s to %.1f", segment_type, end)
def _show_skip_button(self, segment_type, duration, end_time):
from .dialogs.skip import SkipDialog
from .helper.utils import translate_path
if self.skip_dialog:
try:
self.skip_dialog.close()
except Exception:
pass
addon_path = translate_path("special://home/addons/plugin.video.jellyfin/resources/skins/")
self.skip_dialog = SkipDialog(
"script-jellyfin-skip.xml",
addon_path,
"default",
"1080i",
)
self.skip_dialog.set_skip_info(segment_type, duration)
self.skip_dialog.show()
self._skip_end_time = end_time
self._monitor_skip_dialog()
def _monitor_skip_dialog(self):
monitor = xbmc.Monitor()
while self.skip_dialog and not monitor.abortRequested():
if not self.skip_dialog.isActive():
break
if self.skip_dialog.skip_requested:
self.seekTime(self._skip_end_time)
LOG.info("User clicked skip button, seeking to %.1f", self._skip_end_time)
break
try:
current_pos = self.getTime()
if current_pos >= self._skip_end_time:
break
except Exception:
break
if monitor.waitForAbort(0.2):
break
if self.skip_dialog:
try:
self.skip_dialog.close()
except Exception:
pass
self.skip_dialog = None

View file

@ -1173,3 +1173,63 @@ msgctxt "#33245"
msgid "384"
msgstr "384"
msgctxt "#33246"
msgid "Intro Skipper"
msgstr "Intro Skipper"
msgctxt "#33247"
msgid "Enable intro/outro skipping"
msgstr "Enable intro/outro skipping"
msgctxt "#33248"
msgid "Skip mode"
msgstr "Skip mode"
msgctxt "#33249"
msgid "Auto skip"
msgstr "Auto skip"
msgctxt "#33250"
msgid "Show skip button"
msgstr "Show skip button"
msgctxt "#33251"
msgid "Ask every time"
msgstr "Ask every time"
msgctxt "#33252"
msgid "Introduction"
msgstr "Introduction"
msgctxt "#33253"
msgid "Credits"
msgstr "Credits"
msgctxt "#33254"
msgid "Recap"
msgstr "Recap"
msgctxt "#33255"
msgid "Preview"
msgstr "Preview"
msgctxt "#33256"
msgid "Skip"
msgstr "Skip"
msgctxt "#33257"
msgid "Skip intro?"
msgstr "Skip intro?"
msgctxt "#33258"
msgid "Skip credits?"
msgstr "Skip credits?"
msgctxt "#33259"
msgid "Commercial"
msgstr "Commercial"
msgctxt "#33260"
msgid "Press SELECT to skip"
msgstr "Press SELECT to skip"

View file

@ -425,6 +425,80 @@
</control>
</setting>
</group>
<group id="6" label="33246">
<setting id="introSkipEnabled" type="boolean" label="33247" help="">
<level>0</level>
<default>true</default>
<control type="toggle"/>
</setting>
<setting id="introSkipMode" type="integer" label="33248" help="" parent="introSkipEnabled">
<level>0</level>
<default>1</default>
<constraints>
<options>
<option label="33249">0</option>
<option label="33250">1</option>
<option label="33251">2</option>
</options>
</constraints>
<dependencies>
<dependency type="visible">
<condition operator="is" setting="introSkipEnabled">true</condition>
</dependency>
</dependencies>
<control type="spinner" format="string"/>
</setting>
<setting id="skipIntroduction" type="boolean" label="33252" help="" parent="introSkipEnabled">
<level>0</level>
<default>true</default>
<dependencies>
<dependency type="visible">
<condition operator="is" setting="introSkipEnabled">true</condition>
</dependency>
</dependencies>
<control type="toggle"/>
</setting>
<setting id="skipCredits" type="boolean" label="33253" help="" parent="introSkipEnabled">
<level>0</level>
<default>true</default>
<dependencies>
<dependency type="visible">
<condition operator="is" setting="introSkipEnabled">true</condition>
</dependency>
</dependencies>
<control type="toggle"/>
</setting>
<setting id="skipRecap" type="boolean" label="33254" help="" parent="introSkipEnabled">
<level>0</level>
<default>false</default>
<dependencies>
<dependency type="visible">
<condition operator="is" setting="introSkipEnabled">true</condition>
</dependency>
</dependencies>
<control type="toggle"/>
</setting>
<setting id="skipPreview" type="boolean" label="33255" help="" parent="introSkipEnabled">
<level>0</level>
<default>false</default>
<dependencies>
<dependency type="visible">
<condition operator="is" setting="introSkipEnabled">true</condition>
</dependency>
</dependencies>
<control type="toggle"/>
</setting>
<setting id="skipCommercial" type="boolean" label="33259" help="" parent="introSkipEnabled">
<level>0</level>
<default>false</default>
<dependencies>
<dependency type="visible">
<condition operator="is" setting="introSkipEnabled">true</condition>
</dependency>
</dependencies>
<control type="toggle"/>
</setting>
</group>
</category>
<category id="interface" label="30235" help="">
<group id="1">

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Skip Intro/Credits OSD overlay button for Jellyfin -->
<window id="3302" type="dialog">
<defaultcontrol always="true">3020</defaultcontrol>
<controls>
<!-- Semi-transparent background overlay (bottom right corner) -->
<control type="group">
<animation effect="fade" end="100" time="300">WindowOpen</animation>
<animation effect="fade" start="100" end="0" time="300">WindowClose</animation>
<animation type="WindowOpen" reversible="false">
<effect type="slide" start="100,0" end="0,0" time="300" tween="cubic" easing="out" />
</animation>
<animation type="WindowClose" reversible="false">
<effect type="slide" start="0,0" end="100,0" time="300" tween="cubic" easing="in" />
</animation>
<!-- Position in bottom right, above the progress bar area -->
<left>1480</left>
<top>880</top>
<width>400</width>
<height>80</height>
<!-- Background panel -->
<control type="image">
<left>0</left>
<top>0</top>
<width>400</width>
<height>80</height>
<texture colordiffuse="E6000000">white.png</texture>
<aspectratio>stretch</aspectratio>
<bordersize>2</bordersize>
<bordertexture colordiffuse="FF00A4DC">white.png</bordertexture>
</control>
<!-- Jellyfin logo icon -->
<control type="image">
<left>15</left>
<top>20</top>
<width>40</width>
<height>40</height>
<texture>logo-white.png</texture>
<aspectratio>keep</aspectratio>
</control>
<!-- Skip button -->
<control type="button" id="3020">
<left>65</left>
<top>10</top>
<width>320</width>
<height>60</height>
<align>center</align>
<aligny>center</aligny>
<font>font13</font>
<textcolor>ffffffff</textcolor>
<focusedcolor>ff000000</focusedcolor>
<shadowcolor>66000000</shadowcolor>
<disabledcolor>FF404040</disabledcolor>
<texturefocus colordiffuse="FF00A4DC">white.png</texturefocus>
<texturenofocus colordiffuse="33ffffff">white.png</texturenofocus>
<label>Skip</label>
<onclick>close</onclick>
<onleft>noop</onleft>
<onright>noop</onright>
<onup>noop</onup>
<ondown>noop</ondown>
</control>
<!-- Countdown timer display -->
<control type="label" id="3021">
<left>0</left>
<top>65</top>
<width>400</width>
<height>20</height>
<align>center</align>
<font>font10</font>
<textcolor>99ffffff</textcolor>
<label></label>
</control>
</control>
</controls>
</window>

181
tests/test_intro_skipper.py Normal file
View file

@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
from __future__ import division, absolute_import, print_function, unicode_literals
import pytest
class TestIntroSkipperSegmentParsing:
def test_parse_intro_skipper_response(self):
response = {
"Introduction": {
"EpisodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"Start": 42.5,
"End": 122.0
},
"Credits": {
"EpisodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"Start": 2458.0,
"End": 2520.0
}
}
assert "Introduction" in response
assert "Credits" in response
assert response["Introduction"]["Start"] == 42.5
assert response["Introduction"]["End"] == 122.0
def test_parse_empty_response(self):
response = {}
assert len(response) == 0
def test_parse_partial_response(self):
response = {
"Introduction": {
"EpisodeId": "test-id",
"Start": 10.0,
"End": 60.0
}
}
assert "Introduction" in response
assert "Credits" not in response
class TestMediaSegmentsConversion:
def test_convert_media_segments_to_intro_skipper_format(self):
media_segments_response = {
"Items": [
{
"ItemId": "test-item-id",
"Type": "Intro",
"StartTicks": 425000000,
"EndTicks": 1220000000
},
{
"ItemId": "test-item-id",
"Type": "Outro",
"StartTicks": 24580000000,
"EndTicks": 25200000000
}
]
}
type_map = {
"Intro": "Introduction",
"Outro": "Credits",
"Recap": "Recap",
"Preview": "Preview",
"Commercial": "Commercial",
}
segments = {}
for item in media_segments_response["Items"]:
seg_type = type_map.get(item.get("Type"))
if seg_type:
segments[seg_type] = {
"EpisodeId": item.get("ItemId"),
"Start": item.get("StartTicks", 0) / 10000000.0,
"End": item.get("EndTicks", 0) / 10000000.0,
}
assert "Introduction" in segments
assert "Credits" in segments
assert segments["Introduction"]["Start"] == 42.5
assert segments["Introduction"]["End"] == 122.0
assert segments["Credits"]["Start"] == 2458.0
assert segments["Credits"]["End"] == 2520.0
def test_convert_empty_media_segments(self):
response = {"Items": []}
assert len(response["Items"]) == 0
def test_convert_media_segments_missing_items(self):
response = {}
assert "Items" not in response
class TestSegmentDetection:
@pytest.mark.parametrize(
"current_position,segment_start,segment_end,expected_in_window",
[
(42.5, 42.5, 122.0, True),
(45.0, 42.5, 122.0, True),
(47.5, 42.5, 122.0, True),
(48.0, 42.5, 122.0, False),
(40.0, 42.5, 122.0, False),
(100.0, 42.5, 122.0, False),
],
)
def test_segment_detection_window(self, current_position, segment_start, segment_end, expected_in_window):
in_window = segment_start <= current_position <= segment_start + 5
assert in_window == expected_in_window
def test_segment_key_generation(self):
item_id = "a1b2c3d4"
segment_type = "Introduction"
segment_key = "%s:%s" % (item_id, segment_type)
assert segment_key == "a1b2c3d4:Introduction"
def test_skip_prompted_tracking(self):
skip_prompted = set()
segment_key = "item123:Introduction"
assert segment_key not in skip_prompted
skip_prompted.add(segment_key)
assert segment_key in skip_prompted
skip_prompted.add(segment_key)
assert len(skip_prompted) == 1
class TestSkipModes:
def test_skip_mode_values(self):
AUTO_SKIP = 0
SHOW_BUTTON = 1
ASK_EVERY_TIME = 2
assert AUTO_SKIP == 0
assert SHOW_BUTTON == 1
assert ASK_EVERY_TIME == 2
def test_segment_type_settings_map(self):
setting_map = {
"Introduction": "skipIntroduction.bool",
"Credits": "skipCredits.bool",
"Recap": "skipRecap.bool",
"Preview": "skipPreview.bool",
"Commercial": "skipCommercial.bool",
}
assert "Introduction" in setting_map
assert "Credits" in setting_map
assert "Recap" in setting_map
assert "Preview" in setting_map
assert "Commercial" in setting_map
assert setting_map["Introduction"] == "skipIntroduction.bool"
class TestDurationFormatting:
@pytest.mark.parametrize(
"duration_seconds,expected_text",
[
(30, "30s"),
(60, "1m 0s"),
(90, "1m 30s"),
(120, "2m 0s"),
(150, "2m 30s"),
(0, "0s"),
],
)
def test_duration_formatting(self, duration_seconds, expected_text):
minutes = int(duration_seconds // 60)
seconds = int(duration_seconds % 60)
if minutes > 0:
duration_text = "%dm %ds" % (minutes, seconds)
else:
duration_text = "%ds" % seconds
assert duration_text == expected_text