From 65e807bf5f74448e1e8962ec52e22b036f6089dc Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sat, 31 Oct 2026 15:59:12 -0600 Subject: [PATCH] fix(subtitle): join cues that a segment boundary splits in stpp and wvtt Share one append_cue join across DASH fTTML, ISM and fVTT parsing. Also map "geo-block" and "geolocation" errors to GEOFENCE in the API. --- docs/guide/subtitles.md | 2 +- tests/remote/unit/test_errors.py | 2 ++ tests/tracks/test_subtitle_convert.py | 18 ++++++++++++++++-- unshackle/core/api/errors.py | 5 ++++- unshackle/core/tracks/subtitle.py | 24 +++++++++++++----------- 5 files changed, 36 insertions(+), 15 deletions(-) diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index fe734a73..5538cf8e 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -52,7 +52,7 @@ format-appropriate clean-up to make a single, valid file: `‎` / `‏` bidirectional entities. - **Segment merging**: when a WebVTT or boxed subtitle arrives as many DASH/HLS segments, unshackle merges the segments back into one continuous document with - correct timing. + correct timing. A cue that a segment boundary cuts in two comes out as one cue. - **WebVTT sanitization**: merged WebVTT runs through a repair pipeline that clamps negative timestamps to `00:00:00.000`, removes stray cue identifiers (like `Q0`, `Q1`) that confuse downstream parsers, and merges multi-line cues that a service split into diff --git a/tests/remote/unit/test_errors.py b/tests/remote/unit/test_errors.py index 3f0d791b..4478e89c 100644 --- a/tests/remote/unit/test_errors.py +++ b/tests/remote/unit/test_errors.py @@ -114,6 +114,8 @@ def test_build_error_response_debug_mode_includes_traceback() -> None: (TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR), (Exception("Not available in your region"), APIErrorCode.GEOFENCE), (Exception("Service is geoblocked in GB"), APIErrorCode.GEOFENCE), + (Exception("Content is geo-blocked."), APIErrorCode.GEOFENCE), + (Exception("Manifest request failed: GeoLocationBlocked"), APIErrorCode.GEOFENCE), ( Exception("Service is not available in your region (GB)"), APIErrorCode.GEOFENCE, diff --git a/tests/tracks/test_subtitle_convert.py b/tests/tracks/test_subtitle_convert.py index e361ee1e..9d69fc24 100644 --- a/tests/tracks/test_subtitle_convert.py +++ b/tests/tracks/test_subtitle_convert.py @@ -351,6 +351,20 @@ def test_segmented_fvtt_parses_and_converts(tmp_path, monkeypatch): assert cue_count(out) == 2 +def test_segmented_fttml_joins_a_cue_split_at_a_segment_boundary(): + data = build_stpp_mp4( + ttml_fragment(("00:00:01.000", "00:00:02.000", "Carried")), + ttml_fragment(("00:00:02.000", "00:00:03.500", "Carried"), ("00:00:03.500", "00:00:04.000", "Next")), + ) + + captions = Subtitle.parse(data, Codec.fTTML).get_captions("en") + + assert [(c.start / 1e6, c.end / 1e6, c.get_text()) for c in captions] == [ + (1.0, 3.5, "Carried"), + (3.5, 4.0, "Next"), + ] + + def test_segmented_fttml_parses_and_converts(tmp_path, monkeypatch): monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False) frag = ( @@ -448,8 +462,8 @@ def test_dash_fvtt_cues_lose_the_presentation_time_offset(tmp_path, monkeypatch) assert "00:00:02.000 --> 00:00:03.000" in text -def ttml_fragment(*cues: tuple[str, str]) -> str: - body = "".join(f'

Line

' for begin, end in cues) +def ttml_fragment(*cues: tuple[str, ...]) -> str: + body = "".join(f'

{cue[2] if len(cue) > 2 else "Line"}

' for cue in cues) return f'
{body}
' diff --git a/unshackle/core/api/errors.py b/unshackle/core/api/errors.py index 85f2de22..2950bbb6 100644 --- a/unshackle/core/api/errors.py +++ b/unshackle/core/api/errors.py @@ -208,7 +208,10 @@ def categorize_exception( exc_str = f"{exc} {root}".lower() exc_type = type(root).__name__ - if any(keyword in exc_str for keyword in ["geofence", "geoblock", "region", "not available in", "territory"]): + if any( + keyword in exc_str + for keyword in ["geofence", "geoblock", "geo-block", "geolocation", "region", "not available in", "territory"] + ): return APIError( error_code=APIErrorCode.GEOFENCE, message=f"Content not available in your region: {redact_secrets(str(exc))}", diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index b031b5d5..29cf8542 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -376,6 +376,14 @@ class Subtitle(Track): boxes = MP4.parse_stream(BytesIO(data)) return [box.data for box in boxes if box.type == b"mdat"] + @staticmethod + def append_cue(kept: CaptionList, caption: Caption) -> None: + """Append caption to kept, or extend the last cue that a segment boundary cut and caption carries on.""" + if kept and kept[-1].end == caption.start and kept[-1].get_text() == caption.get_text(): + kept[-1].end = caption.end + else: + kept.append(caption) + @staticmethod def first_cue_start(caption_set: pycaption.CaptionSet) -> Optional[int]: """The start of the first cue, in microseconds, looking past empty languages; None with no cues.""" @@ -413,10 +421,8 @@ class Subtitle(Track): for caption in fragment.get_captions(language): caption.start = max(caption.start + shift, 0) caption.end += shift - if kept and kept[-1].end == caption.start and kept[-1].get_text() == caption.get_text(): - kept[-1].end = caption.end # a cue carried into the next fragment - elif caption.end > 0: - kept.append(caption) + if caption.end > 0: + Subtitle.append_cue(kept, caption) if not any(captions.values()): logging.getLogger("Subtitle").debug(f"ISM text: {len(payloads)} fragments, {len(times)} fragment times") self.convert(Subtitle.Codec.TimedTextMarkupLang) @@ -837,7 +843,8 @@ class Subtitle(Track): for payload in Subtitle.mdat_payloads(data) ): for lang in segment.get_languages(): - caption_lists[lang].extend(segment.get_captions(lang)) + for caption in segment.get_captions(lang): + Subtitle.append_cue(caption_lists[lang], caption) caption_set = pycaption.CaptionSet(caption_lists) elif codec == Subtitle.Codec.TimedTextMarkupLang: text = try_ensure_utf8(data).decode("utf8") @@ -1109,12 +1116,7 @@ class Subtitle(Track): nodes=nodes, layout_info=layout, ) - p_caption = captions[-1] if captions else None - if p_caption and caption.start == p_caption.end and str(caption.nodes) == str(p_caption.nodes): - # it's a duplicate, but lets take its end time - p_caption.end = caption.end - continue - captions.append(caption) + Subtitle.append_cue(captions, caption) return captions, language