mirror of
https://github.com/jellyfin/jellyfin-kodi.git
synced 2026-04-27 22:05:38 +00:00
feat(intro-skipper): initial support for introskipper
This commit is contained in:
parent
07cae7c95a
commit
6a2c1264de
7 changed files with 765 additions and 0 deletions
181
jellyfin_kodi/dialogs/skip.py
Normal file
181
jellyfin_kodi/dialogs/skip.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
76
resources/skins/default/1080i/script-jellyfin-skip.xml
Normal file
76
resources/skins/default/1080i/script-jellyfin-skip.xml
Normal 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
181
tests/test_intro_skipper.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue