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 +29 -0
- salin/cleaner/__init__.py +19 -0
- salin/cleaner/cleaner.py +88 -0
- salin/cleaner/dedup.py +53 -0
- salin/cleaner/normalize.py +54 -0
- salin/cleaner/parsers/__init__.py +97 -0
- salin/cleaner/parsers/common.py +64 -0
- salin/cleaner/parsers/json3.py +72 -0
- salin/cleaner/parsers/srt.py +71 -0
- salin/cleaner/parsers/srv.py +151 -0
- salin/cleaner/parsers/ttml.py +68 -0
- salin/cleaner/parsers/vtt.py +72 -0
- salin/client.py +714 -0
- salin/core/__init__.py +37 -0
- salin/core/downloader.py +690 -0
- salin/core/extractor.py +234 -0
- salin/core/fetcher.py +130 -0
- salin/core/resolver.py +196 -0
- salin/core/ytdlp_opts.py +191 -0
- salin/error.py +58 -0
- salin/languages.py +124 -0
- salin/py.typed +0 -0
- salin/shared/models.py +258 -0
- salin/transcript.py +66 -0
- salin/urls.py +36 -0
- salin-0.1.0.dist-info/METADATA +670 -0
- salin-0.1.0.dist-info/RECORD +29 -0
- salin-0.1.0.dist-info/WHEEL +4 -0
- salin-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
salin/cleaner/cleaner.py
ADDED
|
@@ -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())
|