salin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
salin/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from .cleaner import clean_cues, clean_transcript
4
+ from .client import Salin
5
+ from .core import extract_info
6
+ from .error import RateLimitedError, SubtitleDownloadError, TranscriptUnavailableError
7
+ from .shared.models import LanguageMatch, SubtitleFormat, SubtitleSourcePolicy
8
+ from .transcript import FetchedTranscript, TranscriptSnippet
9
+
10
+ try:
11
+ __version__ = version("salin")
12
+ except PackageNotFoundError: # pragma: no cover - running from a source checkout
13
+ __version__ = "0.0.0"
14
+
15
+ __all__ = [
16
+ "Salin",
17
+ "__version__",
18
+ "clean_cues",
19
+ "clean_transcript",
20
+ "extract_info",
21
+ "FetchedTranscript",
22
+ "TranscriptSnippet",
23
+ "RateLimitedError",
24
+ "SubtitleDownloadError",
25
+ "TranscriptUnavailableError",
26
+ "LanguageMatch",
27
+ "SubtitleSourcePolicy",
28
+ "SubtitleFormat",
29
+ ]
@@ -0,0 +1,19 @@
1
+ """
2
+ cleaner/__init__.py
3
+
4
+ Public surface of the cleaner package. Import from here rather than from
5
+ sub-modules so internal reorganisation doesn't break callers.
6
+ """
7
+
8
+ from salin.cleaner.cleaner import clean_cues, clean_transcript
9
+ from salin.cleaner.dedup import deduplicate_blocks, sanitize_word
10
+ from salin.cleaner.normalize import normalize_caption_text, strip_inline_markup
11
+
12
+ __all__ = [
13
+ "clean_cues",
14
+ "clean_transcript",
15
+ "deduplicate_blocks",
16
+ "normalize_caption_text",
17
+ "sanitize_word",
18
+ "strip_inline_markup",
19
+ ]
@@ -0,0 +1,88 @@
1
+ """Turn downloaded caption files into readable, deduplicated plain text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Iterable
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from pathlib import Path
9
+
10
+ from salin.cleaner.dedup import deduplicate_blocks
11
+ from salin.cleaner.normalize import normalize_caption_text
12
+ from salin.cleaner.parsers import read_cues
13
+ from salin.shared.models import SubtitleFile, SubtitleFormat, SubtitleResult
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ def clean_cues(path: Path, subtitle_format: SubtitleFormat) -> str:
19
+ """
20
+ Parse a subtitle file and return the cleaned transcript as a single string.
21
+
22
+ Normalization runs before deduplication so stage directions like
23
+ ``[Laughter]`` don't break the overlap detection matching.
24
+ """
25
+ if not path.exists():
26
+ raise FileNotFoundError(path)
27
+
28
+ cues = read_cues(path, subtitle_format)
29
+ blocks = [normalize_caption_text(cue.text) for cue in cues]
30
+ return deduplicate_blocks(blocks)
31
+
32
+
33
+ def _clean_one(subtitle: SubtitleFile, out_path: Path | None) -> Path:
34
+ if subtitle.sub_path is None:
35
+ raise ValueError("subtitle path is required")
36
+ if subtitle.format is None:
37
+ raise ValueError("subtitle format is required")
38
+
39
+ source = subtitle.sub_path
40
+ txt_path = (
41
+ out_path / source.with_suffix(".txt").name
42
+ if out_path is not None
43
+ else source.with_suffix(".txt")
44
+ )
45
+
46
+ transcript = clean_cues(source, subtitle.format)
47
+ txt_path.write_text(transcript, encoding="utf-8")
48
+ log.info(" ↳ clean transcript: %s", txt_path.name)
49
+ return txt_path
50
+
51
+
52
+ def clean_transcript(
53
+ transcript_result: SubtitleResult | Iterable[SubtitleFile],
54
+ *,
55
+ output_dir: Path | str | None = None,
56
+ max_workers: int = 1,
57
+ ) -> tuple[Path, ...]:
58
+ """
59
+ Clean every successfully downloaded subtitle in ``transcript_result``.
60
+
61
+ Writes one ``.txt`` per subtitle. With ``output_dir`` set, the transcripts
62
+ land there; otherwise they are written next to the source subtitle.
63
+
64
+ Returns the paths written, in input order.
65
+ """
66
+ if max_workers < 1:
67
+ raise ValueError("max_workers must be at least 1")
68
+
69
+ out_path = Path(output_dir) if output_dir is not None else None
70
+ if out_path is not None:
71
+ out_path.mkdir(parents=True, exist_ok=True)
72
+
73
+ subtitles = (
74
+ transcript_result.ok_subs
75
+ if isinstance(transcript_result, SubtitleResult)
76
+ else transcript_result
77
+ )
78
+ subtitle_list = [s for s in subtitles if s.sub_path is not None]
79
+ if not subtitle_list:
80
+ return ()
81
+
82
+ if max_workers == 1 or len(subtitle_list) == 1:
83
+ return tuple(_clean_one(subtitle, out_path) for subtitle in subtitle_list)
84
+
85
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
86
+ return tuple(
87
+ executor.map(_clean_one, subtitle_list, [out_path] * len(subtitle_list))
88
+ )
salin/cleaner/dedup.py ADDED
@@ -0,0 +1,53 @@
1
+ """Collapse the rolling word overlap that auto-generated captions repeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def sanitize_word(word: str) -> str:
7
+ """Fast alnum-only lowercase compare key (no regex)."""
8
+ return "".join(c.lower() for c in word if c.isalnum())
9
+
10
+
11
+ def _matching_overlap(tail: list[str], head: list[str]) -> int:
12
+ """
13
+ Length of the longest overlap between the end of ``tail`` and the start of
14
+ ``head``, where tokens are compared by :func:`sanitize_word` so punctuation
15
+ and casing differences don't break a match.
16
+
17
+ Tokens that sanitize to an empty string (punctuation-only, e.g. ``"..."``)
18
+ never take part in a match. Comparing them literally would let two
19
+ unrelated punctuation tokens compare equal and merge unrelated blocks.
20
+ """
21
+ for size in range(min(len(tail), len(head)), 0, -1):
22
+ pairs = zip(tail[-size:], head[:size], strict=True)
23
+ if all(
24
+ tail_key and head_key and tail_key == head_key
25
+ for tail_key, head_key in pairs
26
+ ):
27
+ return size
28
+ return 0
29
+
30
+
31
+ def deduplicate_blocks(blocks: list[str]) -> str:
32
+ """Merge parsed caption blocks into one deduplicated transcript."""
33
+ final_words: list[str] = []
34
+
35
+ for block in blocks:
36
+ block_words = block.split()
37
+ if not block_words:
38
+ continue
39
+
40
+ if not final_words:
41
+ final_words = block_words
42
+ continue
43
+
44
+ # Bounding strictly by the shorter list size.
45
+ # Capping this by a small constant prevented any duplicate longer
46
+ # than that constant from being matched.
47
+ window = min(len(final_words), len(block_words))
48
+ clean_tail = [sanitize_word(w) for w in final_words[-window:]]
49
+ clean_head = [sanitize_word(w) for w in block_words[:window]]
50
+
51
+ final_words.extend(block_words[_matching_overlap(clean_tail, clean_head) :])
52
+
53
+ return " ".join(final_words)
@@ -0,0 +1,54 @@
1
+ """Caption text normalization shared by the parsers and the deduplicator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+
8
+ _INLINE_TAG_RE = re.compile(r"<[^>]+>")
9
+ _BREAK_TAG_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
10
+ _BROKEN_TAG_RE = re.compile(r"<[^>]*\s*$")
11
+ _MUSIC_CHAR_RE = re.compile(r"[\u266a\u266b\U0001f3b5\U0001f3b6]")
12
+
13
+ # Matches speaker chevrons (>> or >) preceded by start-of-string or whitespace
14
+ _SPEAKER_PREFIX_RE = re.compile(r"(?:^|\s)>{1,2}\s*")
15
+
16
+ _BRACKET_STAGE_RE = re.compile(
17
+ r"\[(?:Music|Applause|Laughter|Silence|Inaudible|Crowd)[^\]]*\]",
18
+ re.IGNORECASE,
19
+ )
20
+ _PAREN_STAGE_RE = re.compile(
21
+ r"\((?:music|applause|laughter|inaudible|crowd noise)[^)]*\)",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+ # A cue that is only music symbols, e.g. "[♪♪♪]", matches neither stage pattern
26
+ # above because neither spells a keyword. The brackets survive the symbol
27
+ # removal and reach the transcript as a stray "[]".
28
+ _EMPTY_GROUP_RE = re.compile(r"\[\s*\]|\(\s*\)")
29
+
30
+
31
+ def strip_inline_markup(text: str) -> str:
32
+ # <br> separates words, so it has to become a space before the generic tag
33
+ # stripper removes it -- otherwise "rules<br />and" collapses to
34
+ # "rulesand". TTML uses it; VTT and SRT use newlines instead.
35
+ text = _BREAK_TAG_RE.sub(" ", text)
36
+ text = _INLINE_TAG_RE.sub("", text)
37
+ text = _BROKEN_TAG_RE.sub("", text)
38
+ text = html.unescape(text)
39
+ return " ".join(text.split())
40
+
41
+
42
+ def normalize_caption_text(text: str) -> str:
43
+ """Remove stage directions and caption artifacts while keeping spoken words."""
44
+ text = strip_inline_markup(text)
45
+ text = _MUSIC_CHAR_RE.sub("", text)
46
+ text = _EMPTY_GROUP_RE.sub("", text)
47
+
48
+ # Replace chevrons with a space to prevent merging adjacent words,
49
+ # then let the final split().join() clean up double spaces.
50
+ text = _SPEAKER_PREFIX_RE.sub(" ", text)
51
+
52
+ text = _BRACKET_STAGE_RE.sub("", text)
53
+ text = _PAREN_STAGE_RE.sub("", text)
54
+ return " ".join(text.split())
@@ -0,0 +1,97 @@
1
+ """
2
+ parsers/__init__.py
3
+
4
+ Registry mapping SubtitleFormat -> parser. Adding a format means adding one
5
+ entry here plus a module that returns ParsedCues.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from salin.error import UnsupportedFormatError
13
+ from salin.shared.models import SubtitleFormat
14
+
15
+ from .common import CueReader, ParsedCue, SourceParser, timestamp_to_ms
16
+ from .json3 import parse_json3_cues, read_json3_cues
17
+ from .srt import parse_srt_cues, read_srt_cues
18
+ from .srv import (
19
+ parse_srv1_cues,
20
+ parse_srv2_cues,
21
+ parse_srv3_cues,
22
+ read_srv1_cues,
23
+ read_srv2_cues,
24
+ read_srv3_cues,
25
+ )
26
+ from .ttml import parse_ttml_cues, read_ttml_cues
27
+ from .vtt import parse_vtt_cues, read_vtt_cues
28
+
29
+ CUE_READERS: dict[SubtitleFormat, CueReader] = {
30
+ SubtitleFormat.SRT: read_srt_cues,
31
+ SubtitleFormat.VTT: read_vtt_cues,
32
+ SubtitleFormat.JSON3: read_json3_cues,
33
+ SubtitleFormat.SRV1: read_srv1_cues,
34
+ SubtitleFormat.SRV2: read_srv2_cues,
35
+ SubtitleFormat.SRV3: read_srv3_cues,
36
+ SubtitleFormat.TTML: read_ttml_cues,
37
+ }
38
+
39
+ SOURCE_PARSERS: dict[SubtitleFormat, SourceParser] = {
40
+ SubtitleFormat.SRT: parse_srt_cues,
41
+ SubtitleFormat.VTT: parse_vtt_cues,
42
+ SubtitleFormat.JSON3: parse_json3_cues,
43
+ SubtitleFormat.SRV1: parse_srv1_cues,
44
+ SubtitleFormat.SRV2: parse_srv2_cues,
45
+ SubtitleFormat.SRV3: parse_srv3_cues,
46
+ SubtitleFormat.TTML: parse_ttml_cues,
47
+ }
48
+
49
+ SUPPORTED_FORMATS = tuple(CUE_READERS)
50
+
51
+
52
+ def reader_for(subtitle_format: SubtitleFormat) -> CueReader:
53
+ reader = CUE_READERS.get(subtitle_format)
54
+ if reader is None:
55
+ supported = ", ".join(f"'{f}'" for f in SUPPORTED_FORMATS)
56
+ raise UnsupportedFormatError(
57
+ f"No subtitle parser for format '{subtitle_format}'. Supported: {supported}"
58
+ )
59
+ return reader
60
+
61
+
62
+ def read_cues(path: Path, subtitle_format: SubtitleFormat) -> list[ParsedCue]:
63
+ """Read a subtitle file into fully-resolved cues."""
64
+ return reader_for(subtitle_format)(path)
65
+
66
+
67
+ def read_blocks(path: Path, subtitle_format: SubtitleFormat) -> list[str]:
68
+ """Read a subtitle file into text blocks, one per cue."""
69
+ return [cue.text for cue in read_cues(path, subtitle_format)]
70
+
71
+
72
+ __all__ = [
73
+ "CUE_READERS",
74
+ "SOURCE_PARSERS",
75
+ "SUPPORTED_FORMATS",
76
+ "CueReader",
77
+ "SourceParser",
78
+ "ParsedCue",
79
+ "parse_json3_cues",
80
+ "parse_srt_cues",
81
+ "parse_srv1_cues",
82
+ "parse_srv2_cues",
83
+ "parse_srv3_cues",
84
+ "parse_ttml_cues",
85
+ "parse_vtt_cues",
86
+ "read_blocks",
87
+ "read_cues",
88
+ "read_json3_cues",
89
+ "read_srt_cues",
90
+ "read_srv1_cues",
91
+ "read_srv2_cues",
92
+ "read_srv3_cues",
93
+ "read_ttml_cues",
94
+ "read_vtt_cues",
95
+ "reader_for",
96
+ "timestamp_to_ms",
97
+ ]
@@ -0,0 +1,64 @@
1
+ """Shared types for subtitle cue parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from xml.etree import ElementTree
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ParsedCue:
13
+ index: int
14
+ start_ms: int
15
+ end_ms: int
16
+ text: str
17
+
18
+
19
+ type CueReader = Callable[[Path], list[ParsedCue]]
20
+
21
+ # Every parse_*_cues function takes the file's full text, so callers can pick
22
+ # whichever form is convenient and the registry stays uniform.
23
+ type SourceParser = Callable[[str], list[ParsedCue]]
24
+
25
+
26
+ def timestamp_to_ms(value: str) -> int:
27
+ """Converts a standard caption timestamp string (with , or .) into milliseconds."""
28
+ normalized = value.strip().replace(",", ".")
29
+ hours, minutes, seconds = normalized.split(":")
30
+ total_seconds = int(hours) * 3600 + int(minutes) * 60 + float(seconds)
31
+ return int(total_seconds * 1000)
32
+
33
+
34
+ def iter_local(root: ElementTree.Element, name: str) -> Iterator[ElementTree.Element]:
35
+ """
36
+ Yield descendants whose tag is ``name``, ignoring any XML namespace.
37
+
38
+ A namespaced document qualifies every tag as ``{uri}name``, so a plain
39
+ ``root.iter("p")`` silently finds nothing in TTML, which is always served
40
+ with ``xmlns="http://www.w3.org/ns/ttml"``.
41
+ """
42
+ for node in root.iter():
43
+ if isinstance(node.tag, str) and node.tag.rpartition("}")[2] == name:
44
+ yield node
45
+
46
+
47
+ def element_text(node: ElementTree.Element) -> str:
48
+ """
49
+ Flatten an element's full text content, turning ``<br>`` into a space.
50
+
51
+ ``node.text`` alone stops at the first child, so for TTML
52
+ ``<p>rules<br />and</p>`` would yield just "rules" and silently drop the
53
+ rest of the cue.
54
+ """
55
+ parts: list[str] = [node.text or ""]
56
+
57
+ for child in node:
58
+ if isinstance(child.tag, str) and child.tag.rpartition("}")[2] == "br":
59
+ parts.append(" ")
60
+ else:
61
+ parts.append(element_text(child))
62
+ parts.append(child.tail or "")
63
+
64
+ return "".join(parts)
@@ -0,0 +1,72 @@
1
+ """
2
+ json3.py
3
+
4
+ YouTube's JSON caption format. Structured rather than rolling, so the cues come
5
+ out already separated -- no cue-boundary heuristics needed::
6
+
7
+ {"events": [{"tStartMs": 1360, "dDurationMs": 1680,
8
+ "segs": [{"utf8": "..."}]}]}
9
+
10
+ Events with no ``segs`` are gaps between captions and carry no text; ``segs``
11
+ entries hold contiguous chunks of one line, so they join without a separator.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from salin.cleaner.normalize import normalize_caption_text
21
+
22
+ from .common import ParsedCue
23
+
24
+
25
+ def parse_json3_cues(source: str) -> list[ParsedCue]:
26
+ payload: Any = json.loads(source)
27
+ if not isinstance(payload, dict):
28
+ raise ValueError("json3 payload must be a JSON object")
29
+
30
+ events = payload.get("events")
31
+ if not isinstance(events, list):
32
+ raise ValueError("json3 payload has no 'events' list")
33
+
34
+ cues: list[ParsedCue] = []
35
+
36
+ for event in events:
37
+ if not isinstance(event, dict):
38
+ continue
39
+
40
+ start = event.get("tStartMs")
41
+ if not isinstance(start, int):
42
+ continue
43
+
44
+ segs = event.get("segs")
45
+ if not isinstance(segs, list):
46
+ continue
47
+
48
+ text = normalize_caption_text(
49
+ "".join(
50
+ seg["utf8"] for seg in segs if isinstance(seg, dict) and seg.get("utf8")
51
+ )
52
+ )
53
+ if not text:
54
+ continue
55
+
56
+ duration = event.get("dDurationMs")
57
+ cues.append(
58
+ ParsedCue(
59
+ index=len(cues),
60
+ start_ms=start,
61
+ end_ms=start + (duration if isinstance(duration, int) else 0),
62
+ text=text,
63
+ )
64
+ )
65
+
66
+ return cues
67
+
68
+
69
+ def read_json3_cues(path: Path) -> list[ParsedCue]:
70
+ if not path.exists():
71
+ raise FileNotFoundError(path)
72
+ return parse_json3_cues(path.read_text(encoding="utf-8-sig"))
@@ -0,0 +1,71 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from salin.cleaner.normalize import normalize_caption_text
5
+
6
+ from .common import ParsedCue, timestamp_to_ms
7
+
8
+ _SRT_TIMESTAMP_RE = re.compile(
9
+ r"(\d{2}:\d{2}:\d{2},\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2},\d{3})"
10
+ )
11
+ _SRT_SEQUENCE_RE = re.compile(r"^\d+$")
12
+
13
+
14
+ def parse_srt_cues(source: str) -> list[ParsedCue]:
15
+ """Parse SRT source text into structured SRT ParsedCues."""
16
+ cues: list[ParsedCue] = []
17
+ current_lines: list[str] = []
18
+ start_ms: int | None = None
19
+ end_ms: int | None = None
20
+
21
+ def flush() -> None:
22
+ nonlocal start_ms, end_ms, current_lines
23
+ if start_ms is None or end_ms is None:
24
+ current_lines = []
25
+ return
26
+
27
+ # Merge lines and normalize them using the shared normalization logic
28
+ text = normalize_caption_text(" ".join(current_lines)) if current_lines else ""
29
+ if text:
30
+ cues.append(
31
+ ParsedCue(
32
+ index=len(cues),
33
+ start_ms=start_ms,
34
+ end_ms=end_ms,
35
+ text=text,
36
+ )
37
+ )
38
+ current_lines = []
39
+ start_ms = None
40
+ end_ms = None
41
+
42
+ for raw in source.splitlines():
43
+ line = raw.strip()
44
+ if not line:
45
+ flush()
46
+ continue
47
+
48
+ match = _SRT_TIMESTAMP_RE.search(line)
49
+ if match:
50
+ start_ms = timestamp_to_ms(match.group(1))
51
+ end_ms = timestamp_to_ms(match.group(2))
52
+ continue
53
+
54
+ # A bare index only ever precedes a timestamp, so a digit-only line
55
+ # before one is the sequence number. After a timestamp the same line is
56
+ # real text -- a caption can legitimately be just "2024".
57
+ if start_ms is None and _SRT_SEQUENCE_RE.match(line):
58
+ continue
59
+
60
+ current_lines.append(line)
61
+
62
+ flush()
63
+ return cues
64
+
65
+
66
+ def read_srt_cues(path: Path) -> list[ParsedCue]:
67
+ """Read an SRT file and return fully-resolved ParsedCues (including times)."""
68
+ if not path.exists():
69
+ raise FileNotFoundError(path)
70
+ with open(path, encoding="utf-8-sig") as f:
71
+ return parse_srt_cues(f.read())