ytcap 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.
ytcap/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Top-level package for ytcap."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
ytcap/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Module execution support for ``python -m ytcap``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ raise SystemExit(main())
ytcap/app/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Application use-case layer for ytcap."""
2
+
3
+ from .process_batch import ProcessBatchOptions, ProcessBatchResult, process_batch
@@ -0,0 +1,80 @@
1
+ """Helpers for recognizing completed video outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ytcap.exporters.output_paths import OutputLayout
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ExistingVideoOutput:
15
+ metadata_path: Path
16
+ subtitle_path: Path
17
+
18
+
19
+ def find_existing_video_output(
20
+ video_id: str,
21
+ *,
22
+ layout: OutputLayout,
23
+ language: str,
24
+ source: str,
25
+ subtitle_format: str,
26
+ ) -> ExistingVideoOutput | None:
27
+ """Return matching existing output for the requested subtitle selection."""
28
+ meta_path = layout.metadata_path(video_id)
29
+ if not meta_path.exists():
30
+ return None
31
+
32
+ try:
33
+ with open(meta_path, "r", encoding="utf-8") as f:
34
+ meta = json.load(f)
35
+ except (OSError, json.JSONDecodeError):
36
+ return None
37
+
38
+ subtitles = meta.get("subtitles", [])
39
+ if not isinstance(subtitles, list):
40
+ return None
41
+
42
+ for track in subtitles:
43
+ if not _track_matches(track, language=language, source=source, subtitle_format=subtitle_format):
44
+ continue
45
+ subtitle_path = _existing_subtitle_path(track.get("path"), layout=layout)
46
+ if subtitle_path is not None:
47
+ return ExistingVideoOutput(metadata_path=meta_path, subtitle_path=subtitle_path)
48
+ return None
49
+
50
+
51
+ def _track_matches(track: Any, *, language: str, source: str, subtitle_format: str) -> bool:
52
+ if not isinstance(track, dict):
53
+ return False
54
+ if not track.get("selected") or not track.get("downloaded"):
55
+ return False
56
+ if track.get("language") != language:
57
+ return False
58
+ track_source = track.get("source")
59
+ if source == "any":
60
+ if track_source not in {"manual", "auto"}:
61
+ return False
62
+ elif track_source != source:
63
+ return False
64
+
65
+ formats = track.get("formats")
66
+ return isinstance(formats, list) and subtitle_format in formats
67
+
68
+
69
+ def _existing_subtitle_path(path_value: Any, *, layout: OutputLayout) -> Path | None:
70
+ if not isinstance(path_value, str) or not path_value:
71
+ return None
72
+
73
+ subtitle_path = Path(path_value)
74
+ if subtitle_path.exists():
75
+ return subtitle_path
76
+
77
+ rooted_path = layout.root / subtitle_path
78
+ if rooted_path.exists():
79
+ return rooted_path
80
+ return None
@@ -0,0 +1,318 @@
1
+ """Subtitle export use case."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from ytcap.errors import ErrorCode, YtcapError
9
+ from ytcap.exporters.jsonl_writer import write_cue_jsonl_file, write_sentence_jsonl_file
10
+ from ytcap.exporters.output_paths import normalized_file_path
11
+ from ytcap.models.subtitle import SubtitleCue, SubtitleSentence
12
+ from ytcap.services.subtitle_parser import parse_srt_file, parse_vtt_file
13
+ from ytcap.services.subtitle_segmenter import segment_cues_into_sentences
14
+
15
+
16
+ SUPPORTED_SUBTITLE_SUFFIXES = (".srt", ".vtt")
17
+ SUPPORTED_SEGMENTS = ("cue", "sentence")
18
+ KNOWN_SOURCES = ("manual", "auto")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ExportSubtitlesOptions:
23
+ input_path: str | Path
24
+ segments: str
25
+ output_dir: str | Path
26
+ video_id: str | None = None
27
+ language: str | None = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ExportedSubtitleFile:
32
+ input_path: Path
33
+ output_path: Path
34
+ video_id: str
35
+ language: str
36
+ source: str
37
+ segment_count: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ExportSubtitlesResult:
42
+ files: tuple[ExportedSubtitleFile, ...]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _SubtitleFileMetadata:
47
+ video_id: str | None
48
+ language: str | None
49
+ source: str
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class _ExportSubtitleJob:
54
+ input_path: Path
55
+ output_path: Path
56
+ metadata: _SubtitleFileMetadata
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class _PreparedSubtitleExport:
61
+ job: _ExportSubtitleJob
62
+ cues: tuple[SubtitleCue, ...]
63
+ sentences: tuple[SubtitleSentence, ...] | None
64
+ segment_count: int
65
+
66
+
67
+ def export_subtitles(options: ExportSubtitlesOptions) -> ExportSubtitlesResult:
68
+ _validate_segments(options.segments)
69
+ input_path = Path(options.input_path)
70
+ subtitle_files = _subtitle_files(input_path, options=options)
71
+ output_dir = Path(options.output_dir)
72
+ jobs = _export_jobs(subtitle_files, output_dir=output_dir, options=options)
73
+ _validate_output_paths(jobs)
74
+ prepared_exports = tuple(
75
+ _prepare_subtitle_export(job, segments=options.segments)
76
+ for job in jobs
77
+ )
78
+
79
+ exported_files = tuple(
80
+ _write_prepared_export(prepared, segments=options.segments)
81
+ for prepared in prepared_exports
82
+ )
83
+ return ExportSubtitlesResult(files=exported_files)
84
+
85
+
86
+ def _subtitle_files(input_path: Path, *, options: ExportSubtitlesOptions) -> tuple[Path, ...]:
87
+ if not input_path.exists():
88
+ raise YtcapError(
89
+ ErrorCode.INVALID_INPUT,
90
+ f"input path does not exist '{input_path}'",
91
+ exit_code=2,
92
+ )
93
+
94
+ if input_path.is_file():
95
+ _validate_supported_file(input_path)
96
+ return (input_path,)
97
+
98
+ if input_path.is_dir():
99
+ if options.video_id or options.language:
100
+ raise YtcapError(
101
+ ErrorCode.INVALID_INPUT,
102
+ "--video-id and --lang can only be used with a single subtitle file",
103
+ exit_code=2,
104
+ )
105
+ try:
106
+ files = tuple(
107
+ sorted(
108
+ path
109
+ for path in input_path.iterdir()
110
+ if path.is_file() and _is_supported_subtitle_file(path)
111
+ )
112
+ )
113
+ except OSError as exc:
114
+ raise YtcapError(
115
+ ErrorCode.INVALID_INPUT,
116
+ f"could not read input directory '{input_path}': {exc}",
117
+ exit_code=2,
118
+ ) from exc
119
+ if not files:
120
+ raise YtcapError(
121
+ ErrorCode.INVALID_INPUT,
122
+ f"no SRT/VTT subtitle files found in directory '{input_path}'",
123
+ exit_code=2,
124
+ )
125
+ return files
126
+
127
+ raise YtcapError(
128
+ ErrorCode.INVALID_INPUT,
129
+ f"input path is not a file or directory '{input_path}'",
130
+ exit_code=2,
131
+ )
132
+
133
+
134
+ def _export_jobs(
135
+ subtitle_files: tuple[Path, ...],
136
+ *,
137
+ output_dir: Path,
138
+ options: ExportSubtitlesOptions,
139
+ ) -> tuple[_ExportSubtitleJob, ...]:
140
+ jobs: list[_ExportSubtitleJob] = []
141
+ for path in subtitle_files:
142
+ metadata = _metadata_for_path(path, options=options)
143
+ output_path = normalized_file_path(
144
+ output_dir,
145
+ video_id=str(metadata.video_id),
146
+ language=str(metadata.language),
147
+ segments=options.segments,
148
+ )
149
+ jobs.append(
150
+ _ExportSubtitleJob(
151
+ input_path=path,
152
+ output_path=output_path,
153
+ metadata=metadata,
154
+ )
155
+ )
156
+ return tuple(jobs)
157
+
158
+
159
+ def _validate_output_paths(jobs: tuple[_ExportSubtitleJob, ...]) -> None:
160
+ seen: dict[Path, Path] = {}
161
+ for job in jobs:
162
+ existing_input_path = seen.get(job.output_path)
163
+ if existing_input_path is not None:
164
+ raise YtcapError(
165
+ ErrorCode.INVALID_INPUT,
166
+ (
167
+ f"multiple subtitle files map to the same output file '{job.output_path}': "
168
+ f"'{existing_input_path}' and '{job.input_path}'"
169
+ ),
170
+ exit_code=2,
171
+ )
172
+ seen[job.output_path] = job.input_path
173
+
174
+ for job in jobs:
175
+ if job.output_path.exists():
176
+ raise YtcapError(
177
+ ErrorCode.OUTPUT_WRITE_FAILED,
178
+ (
179
+ f"output file already exists '{job.output_path}'; "
180
+ "remove it or choose another --out directory"
181
+ ),
182
+ exit_code=5,
183
+ )
184
+
185
+
186
+ def _prepare_subtitle_export(
187
+ job: _ExportSubtitleJob,
188
+ *,
189
+ segments: str,
190
+ ) -> _PreparedSubtitleExport:
191
+ cues = tuple(_parse_subtitle_file(job.input_path))
192
+ if segments == "cue":
193
+ return _PreparedSubtitleExport(
194
+ job=job,
195
+ cues=cues,
196
+ sentences=None,
197
+ segment_count=len(cues),
198
+ )
199
+
200
+ sentences = tuple(segment_cues_into_sentences(cues))
201
+ return _PreparedSubtitleExport(
202
+ job=job,
203
+ cues=cues,
204
+ sentences=sentences,
205
+ segment_count=len(sentences),
206
+ )
207
+
208
+
209
+ def _write_prepared_export(
210
+ prepared: _PreparedSubtitleExport,
211
+ *,
212
+ segments: str,
213
+ ) -> ExportedSubtitleFile:
214
+ job = prepared.job
215
+ metadata = job.metadata
216
+
217
+ if segments == "cue":
218
+ write_cue_jsonl_file(
219
+ job.output_path,
220
+ prepared.cues,
221
+ video_id=str(metadata.video_id),
222
+ language=str(metadata.language),
223
+ source=metadata.source,
224
+ )
225
+ else:
226
+ sentences = prepared.sentences or ()
227
+ write_sentence_jsonl_file(
228
+ job.output_path,
229
+ sentences,
230
+ video_id=str(metadata.video_id),
231
+ language=str(metadata.language),
232
+ source=metadata.source,
233
+ )
234
+
235
+ return ExportedSubtitleFile(
236
+ input_path=job.input_path,
237
+ output_path=job.output_path,
238
+ video_id=str(metadata.video_id),
239
+ language=str(metadata.language),
240
+ source=metadata.source,
241
+ segment_count=prepared.segment_count,
242
+ )
243
+
244
+
245
+ def _metadata_for_path(path: Path, *, options: ExportSubtitlesOptions) -> _SubtitleFileMetadata:
246
+ inferred = _infer_metadata_from_filename(path)
247
+ metadata = _SubtitleFileMetadata(
248
+ video_id=options.video_id or inferred.video_id,
249
+ language=options.language or inferred.language,
250
+ source=inferred.source,
251
+ )
252
+ if metadata.video_id and metadata.language:
253
+ return metadata
254
+
255
+ raise YtcapError(
256
+ ErrorCode.INVALID_INPUT,
257
+ (
258
+ f"could not infer video id and language from subtitle file '{path.name}'; "
259
+ "expected VIDEO_ID.lang[.source].srt/vtt"
260
+ ),
261
+ exit_code=2,
262
+ )
263
+
264
+
265
+ def _infer_metadata_from_filename(path: Path) -> _SubtitleFileMetadata:
266
+ parts = path.stem.split(".")
267
+ if len(parts) < 2:
268
+ return _SubtitleFileMetadata(video_id=None, language=None, source="unknown")
269
+
270
+ if len(parts) >= 3:
271
+ source_candidate = parts[-1].lower()
272
+ source = source_candidate if source_candidate in KNOWN_SOURCES else "unknown"
273
+ return _SubtitleFileMetadata(
274
+ video_id=".".join(parts[:-2]),
275
+ language=parts[-2],
276
+ source=source,
277
+ )
278
+
279
+ return _SubtitleFileMetadata(video_id=parts[0], language=parts[1], source="unknown")
280
+
281
+
282
+ def _parse_subtitle_file(path: Path) -> list[SubtitleCue]:
283
+ suffix = path.suffix.lower()
284
+ if suffix == ".srt":
285
+ return parse_srt_file(path)
286
+ if suffix == ".vtt":
287
+ return parse_vtt_file(path)
288
+ _raise_unsupported_file(path)
289
+
290
+
291
+ def _validate_segments(segments: str) -> None:
292
+ if segments in SUPPORTED_SEGMENTS:
293
+ return
294
+ supported = ", ".join(SUPPORTED_SEGMENTS)
295
+ raise YtcapError(
296
+ ErrorCode.INVALID_INPUT,
297
+ f"unsupported segment type '{segments}'; supported segments: {supported}",
298
+ exit_code=2,
299
+ )
300
+
301
+
302
+ def _validate_supported_file(path: Path) -> None:
303
+ if _is_supported_subtitle_file(path):
304
+ return
305
+ _raise_unsupported_file(path)
306
+
307
+
308
+ def _is_supported_subtitle_file(path: Path) -> bool:
309
+ return path.suffix.lower() in SUPPORTED_SUBTITLE_SUFFIXES
310
+
311
+
312
+ def _raise_unsupported_file(path: Path) -> None:
313
+ supported = ", ".join(SUPPORTED_SUBTITLE_SUFFIXES)
314
+ raise YtcapError(
315
+ ErrorCode.INVALID_INPUT,
316
+ f"unsupported subtitle file extension '{path.suffix}'; supported extensions: {supported}",
317
+ exit_code=2,
318
+ )