From 80009811f9f7359d55b7ac4b531aafdf9df85b96 Mon Sep 17 00:00:00 2001 From: mediaminister Date: Tue, 23 Dec 2025 22:26:06 +0100 Subject: [PATCH] Delay subtitles based on ad cues --- resources/lib/kodiutils.py | 20 ++++++++++ resources/lib/play/content.py | 70 ++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/resources/lib/kodiutils.py b/resources/lib/kodiutils.py index d1daad8..f70e8c9 100644 --- a/resources/lib/kodiutils.py +++ b/resources/lib/kodiutils.py @@ -5,6 +5,7 @@ import os import re +from contextlib import contextmanager from html import unescape from urllib.parse import quote, urlencode @@ -703,6 +704,14 @@ def jsonrpc(*args, **kwargs): return loads(xbmc.executeJSONRPC(dumps(kwargs))) +@contextmanager +def open_file(path, flags='r'): + """Open a file (using xbmcvfs)""" + fdesc = xbmcvfs.File(path, flags) + yield fdesc + fdesc.close() + + def listdir(path): """Return all files in a directory (using xbmcvfs)""" return xbmcvfs.listdir(path) @@ -711,3 +720,14 @@ def listdir(path): def delete(path): """Remove a file (using xbmcvfs)""" return xbmcvfs.delete(path) + + +def mkdirs(path): + """Create directory including parents (using xbmcvfs)""" + _LOGGER.debug('Recursively create directory (%s)', path) + return xbmcvfs.mkdirs(path) + + +def exists(path): + """Whether the path exists (using xbmcvfs)""" + return xbmcvfs.exists(path) diff --git a/resources/lib/play/content.py b/resources/lib/play/content.py index 237de95..defda99 100644 --- a/resources/lib/play/content.py +++ b/resources/lib/play/content.py @@ -6,7 +6,7 @@ import os import re import time -from datetime import datetime +from datetime import datetime, timedelta from resources.lib import kodiutils from resources.lib.play import utils @@ -350,7 +350,7 @@ def get_stream(self, uuid: str, content_type: str) -> ResolvedStream: ) ad_data = json.loads(utils.post_url(ssai_url, data='')) manifest_url = ad_data.get('stream_manifest') - subtitle_url = self.extract_subtitle_from_manifest(manifest_url) + subtitle_url = self.adjust_subtitle(ad_data) stream_type = STREAM_DASH if not manifest_url or not stream_type: @@ -385,6 +385,72 @@ def get_stream(self, uuid: str, content_type: str) -> ResolvedStream: subtitles=[subtitle_url] if subtitle_url else [], ) + def adjust_subtitle(self, ad_json): + """Adjust subtitle""" + subtitle_url = self.extract_subtitle_from_manifest(ad_json.get('stream_manifest')) + subtitle = utils.get_url(subtitle_url) + + # Clean up old subtitles + subtitle_dir = os.path.join(kodiutils.addon_profile(), 'subs', '') + _, files = kodiutils.listdir(subtitle_dir) + if files: + for item in files: + kodiutils.delete(os.path.join(subtitle_dir, item)) + + # Cache original + subtitle_path = os.path.join(subtitle_dir, 'T888.Original.Dutch.vtt') + if not kodiutils.exists(subtitle_dir): + kodiutils.mkdirs(subtitle_dir) + with kodiutils.open_file(subtitle_path, 'w') as webvtt_output: + webvtt_output.write(subtitle) + + time_events_url = ad_json.get('time_events_url') + data = utils.get_url(time_events_url) + + events_json = json.loads(data) + cues = events_json.get('cuepoints') + + ad_breaks = [] + webvtt_timing_regex = re.compile(r'\n(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})\s') + + for cue in cues: + duration = float(cue.get('end_float')) - float(cue.get('start_float')) + ad_breaks.append({ + 'start': cue.get('start_float'), + 'duration': duration + }) + + subtitle = webvtt_timing_regex.sub(lambda match: self.adjust_webvtt_timing(match, ad_breaks), subtitle) + + # Cache adjusted subtitles + subtitle_path = os.path.join(subtitle_dir, 'T888.Dutch.vtt') + with kodiutils.open_file(subtitle_path, 'w') as webvtt_output: + webvtt_output.write(subtitle) + return subtitle_path + + def adjust_webvtt_timing(self, match, ad_breaks): + """Adjust the timing of a webvtt timestamp""" + sub_timings = [] + for timestamp in match.groups(): + hours, minutes, seconds, millis = (int(x) for x in [timestamp[:-10], timestamp[-9:-7], timestamp[-6:-4], timestamp[-3:]]) + sub_timings.append(timedelta(hours=hours, minutes=minutes, seconds=seconds, milliseconds=millis)) + original_start_time = sub_timings[0] + for ad_break in ad_breaks: + # time format: seconds.fraction or seconds + ad_break_start = timedelta(milliseconds=ad_break.get('start') * 1000) + ad_break_duration = timedelta(milliseconds=ad_break.get('duration') * 1000) + if ad_break_start <= original_start_time: + # advance start and end timestamp + for idx, item in enumerate(sub_timings): + sub_timings[idx] -= ad_break_duration + for idx, item in enumerate(sub_timings): + hours, remainder = divmod(item.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + millis = item.microseconds // 1000 + sub_timings[idx] = '%02d:%02d:%02d.%03d' % (hours, minutes, seconds, millis) + adjusted_webvtt_timing = '\n{} --> {} '.format(sub_timings[0], sub_timings[1]) + return adjusted_webvtt_timing + def extract_subtitle_from_manifest(self, manifest_url): """Extract subtitle URL from a DASH manifest""" from xml.etree.ElementTree import fromstring