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