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.
This commit is contained in:
imSp4rky
2026-10-31 15:59:12 -06:00
parent c6c0331007
commit 65e807bf5f
5 changed files with 36 additions and 15 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ format-appropriate clean-up to make a single, valid file:
`‎` / `‏` bidirectional entities. `‎` / `‏` bidirectional entities.
- **Segment merging**: when a WebVTT or boxed subtitle arrives as many DASH/HLS - **Segment merging**: when a WebVTT or boxed subtitle arrives as many DASH/HLS
segments, unshackle merges the segments back into one continuous document with 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 - **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`) 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 that confuse downstream parsers, and merges multi-line cues that a service split into
+2
View File
@@ -114,6 +114,8 @@ def test_build_error_response_debug_mode_includes_traceback() -> None:
(TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR), (TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR),
(Exception("Not available in your region"), APIErrorCode.GEOFENCE), (Exception("Not available in your region"), APIErrorCode.GEOFENCE),
(Exception("Service is geoblocked in GB"), 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)"), Exception("Service is not available in your region (GB)"),
APIErrorCode.GEOFENCE, APIErrorCode.GEOFENCE,
+16 -2
View File
@@ -351,6 +351,20 @@ def test_segmented_fvtt_parses_and_converts(tmp_path, monkeypatch):
assert cue_count(out) == 2 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): def test_segmented_fttml_parses_and_converts(tmp_path, monkeypatch):
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False) monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
frag = ( 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 assert "00:00:02.000 --> 00:00:03.000" in text
def ttml_fragment(*cues: tuple[str, str]) -> str: def ttml_fragment(*cues: tuple[str, ...]) -> str:
body = "".join(f'<p begin="{begin}" end="{end}">Line</p>' for begin, end in cues) body = "".join(f'<p begin="{cue[0]}" end="{cue[1]}">{cue[2] if len(cue) > 2 else "Line"}</p>' for cue in cues)
return f'<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="en"><body><div>{body}</div></body></tt>' return f'<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="en"><body><div>{body}</div></body></tt>'
+4 -1
View File
@@ -208,7 +208,10 @@ def categorize_exception(
exc_str = f"{exc} {root}".lower() exc_str = f"{exc} {root}".lower()
exc_type = type(root).__name__ 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( return APIError(
error_code=APIErrorCode.GEOFENCE, error_code=APIErrorCode.GEOFENCE,
message=f"Content not available in your region: {redact_secrets(str(exc))}", message=f"Content not available in your region: {redact_secrets(str(exc))}",
+13 -11
View File
@@ -376,6 +376,14 @@ class Subtitle(Track):
boxes = MP4.parse_stream(BytesIO(data)) boxes = MP4.parse_stream(BytesIO(data))
return [box.data for box in boxes if box.type == b"mdat"] 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 @staticmethod
def first_cue_start(caption_set: pycaption.CaptionSet) -> Optional[int]: 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.""" """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): for caption in fragment.get_captions(language):
caption.start = max(caption.start + shift, 0) caption.start = max(caption.start + shift, 0)
caption.end += shift caption.end += shift
if kept and kept[-1].end == caption.start and kept[-1].get_text() == caption.get_text(): if caption.end > 0:
kept[-1].end = caption.end # a cue carried into the next fragment Subtitle.append_cue(kept, caption)
elif caption.end > 0:
kept.append(caption)
if not any(captions.values()): if not any(captions.values()):
logging.getLogger("Subtitle").debug(f"ISM text: {len(payloads)} fragments, {len(times)} fragment times") logging.getLogger("Subtitle").debug(f"ISM text: {len(payloads)} fragments, {len(times)} fragment times")
self.convert(Subtitle.Codec.TimedTextMarkupLang) self.convert(Subtitle.Codec.TimedTextMarkupLang)
@@ -837,7 +843,8 @@ class Subtitle(Track):
for payload in Subtitle.mdat_payloads(data) for payload in Subtitle.mdat_payloads(data)
): ):
for lang in segment.get_languages(): 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) caption_set = pycaption.CaptionSet(caption_lists)
elif codec == Subtitle.Codec.TimedTextMarkupLang: elif codec == Subtitle.Codec.TimedTextMarkupLang:
text = try_ensure_utf8(data).decode("utf8") text = try_ensure_utf8(data).decode("utf8")
@@ -1109,12 +1116,7 @@ class Subtitle(Track):
nodes=nodes, nodes=nodes,
layout_info=layout, layout_info=layout,
) )
p_caption = captions[-1] if captions else None Subtitle.append_cue(captions, caption)
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)
return captions, language return captions, language