After transcription the user can paste a translation produced elsewhere (ChatGPT, DeepL, a human translator) and have it map onto the segments that already exist — no re-transcription, no timing loss. Three input shapes are auto-detected: a timestamped .srt/.vtt (cues matched to segments by time overlap, greedy one-to-one so one long cue can't be copied onto several rows), numbered lines (`1.` / `2)` / `[3]`, mapped by number and falling back to order when a model renumbers mid-answer), and plain lines (positional, blank lines treated as separators rather than empty translations). Nothing is applied until the preview dialog has shown every row as before→after with unmatched rows flagged. Applying goes through `pasteTranslations` in useSegmentEditing, which mirrors `segmentEditField`'s duties across rows in ONE undo step: write `text` and `translations[dubLangCode]` in lock-step and clear the stale machine-translation badges. It never writes `text_original` (the translate source `handleTranslateAll` reads — overwriting it would poison every later re-translate) and never touches a language other than the active one. Changing `text` alone marks those rows stale via the existing per-language fingerprints, so no new flag is needed. The new `POST /dub/parse-subtitle-text` is a stateless wrapper over the existing `services.srt_parser.parse_srt`, so the lenient cue parsing stays single-sourced instead of being reimplemented in JavaScript. Also fixes a ReDoS in that parser, reachable today via /dub/import-srt: `_TIMING_RE` used `^\s*` under re.MULTILINE, so at every line start the engine consumed all remaining blank lines before failing on the first digit — quadratic. 20k blank lines already took 1.7s and a 2 MB blank-line file never returned, pinning the request thread. Horizontal-whitespace-only classes make the scan linear. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
2.9 KiB
Python
106 lines
2.9 KiB
Python
"""Tests for POST /dub/parse-subtitle-text.
|
|
|
|
Stateless wrapper over `services.srt_parser.parse_srt` that backs the
|
|
"paste a translation from an external source" flow. It must stay
|
|
job-free (no mutation, no file I/O), reject oversized pastes, and give a
|
|
typed 400 when the paste has no timed cues at all.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
|
|
|
|
|
def _call(text):
|
|
from api.routers import dub_core
|
|
from schemas.requests import ParseSubtitleTextRequest
|
|
|
|
return dub_core.dub_parse_subtitle_text(ParseSubtitleTextRequest(text=text))
|
|
|
|
|
|
SRT = """1
|
|
00:00:01,000 --> 00:00:04,500
|
|
Hola mundo.
|
|
|
|
2
|
|
00:00:05,250 --> 00:00:08,000
|
|
Segunda línea.
|
|
"""
|
|
|
|
VTT = """WEBVTT
|
|
|
|
00:00:01.000 --> 00:00:04.500
|
|
Hola mundo.
|
|
|
|
00:00:05.250 --> 00:00:08.000
|
|
Segunda línea.
|
|
"""
|
|
|
|
|
|
def test_parses_srt_into_cues():
|
|
res = _call(SRT)
|
|
assert res["skipped_cues"] == 0
|
|
assert res["dropped_overlaps"] == 0
|
|
assert res["segments"] == [
|
|
{"start": 1.0, "end": 4.5, "text": "Hola mundo."},
|
|
{"start": 5.25, "end": 8.0, "text": "Segunda línea."},
|
|
]
|
|
|
|
|
|
def test_parses_vtt_style_timestamps():
|
|
# WebVTT uses `.` for the ms separator and has no cue indices; the
|
|
# lenient parser handles both, and the `WEBVTT` header sits before the
|
|
# first timing line so it is never mistaken for cue text.
|
|
res = _call(VTT)
|
|
assert [s["text"] for s in res["segments"]] == ["Hola mundo.", "Segunda línea."]
|
|
assert res["segments"][0]["start"] == 1.0
|
|
|
|
|
|
def test_response_carries_no_job_shaped_fields():
|
|
# The client keeps its own segments (ids, timings, text_original); this
|
|
# endpoint must hand back cues only, never a segment record that could
|
|
# tempt a caller into replacing rows wholesale.
|
|
res = _call(SRT)
|
|
assert set(res["segments"][0]) == {"start", "end", "text"}
|
|
|
|
|
|
def test_garbage_input_is_a_typed_400():
|
|
with pytest.raises(HTTPException) as exc:
|
|
_call("Just some translated prose with no timestamps at all.")
|
|
assert exc.value.status_code == 400
|
|
assert "no timed cues" in str(exc.value.detail).lower()
|
|
|
|
|
|
def test_empty_input_is_a_typed_400():
|
|
with pytest.raises(HTTPException) as exc:
|
|
_call("")
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
def test_oversized_paste_is_rejected_before_parsing():
|
|
from api.routers import dub_core
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
_call("x" * (dub_core._MAX_SUBTITLE_PASTE_CHARS + 1))
|
|
assert exc.value.status_code == 413
|
|
assert "too large" in str(exc.value.detail).lower()
|
|
|
|
|
|
def test_at_the_size_limit_is_still_parsed():
|
|
from api.routers import dub_core
|
|
|
|
pad = "\n" * (dub_core._MAX_SUBTITLE_PASTE_CHARS - len(SRT))
|
|
res = _call(SRT + pad)
|
|
assert len(res["segments"]) == 2
|
|
|
|
|
|
def test_route_is_registered_without_a_job_id():
|
|
from api.routers import dub_core
|
|
|
|
paths = {r.path for r in dub_core.router.routes}
|
|
assert "/dub/parse-subtitle-text" in paths
|