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 +5 -0
- ytcap/__main__.py +9 -0
- ytcap/app/__init__.py +3 -0
- ytcap/app/existing_outputs.py +80 -0
- ytcap/app/export_subtitles.py +318 -0
- ytcap/app/process_batch.py +296 -0
- ytcap/app/process_playlist.py +368 -0
- ytcap/app/process_video.py +227 -0
- ytcap/cli.py +78 -0
- ytcap/commands/__init__.py +1 -0
- ytcap/commands/batch.py +73 -0
- ytcap/commands/common.py +28 -0
- ytcap/commands/export.py +51 -0
- ytcap/commands/inspect.py +67 -0
- ytcap/commands/playlist.py +115 -0
- ytcap/commands/video.py +96 -0
- ytcap/errors.py +55 -0
- ytcap/exporters/__init__.py +1 -0
- ytcap/exporters/failed_writer.py +40 -0
- ytcap/exporters/json_writer.py +46 -0
- ytcap/exporters/jsonl_writer.py +140 -0
- ytcap/exporters/output_paths.py +117 -0
- ytcap/logging_config.py +19 -0
- ytcap/models/__init__.py +1 -0
- ytcap/models/subtitle.py +39 -0
- ytcap/models/video_metadata.py +110 -0
- ytcap/services/__init__.py +3 -0
- ytcap/services/batch_parser.py +47 -0
- ytcap/services/subtitle_format.py +20 -0
- ytcap/services/subtitle_parser.py +189 -0
- ytcap/services/subtitle_segmenter.py +138 -0
- ytcap/services/subtitle_selector.py +70 -0
- ytcap/services/ytdlp_adapter.py +349 -0
- ytcap-0.1.0.dist-info/METADATA +302 -0
- ytcap-0.1.0.dist-info/RECORD +39 -0
- ytcap-0.1.0.dist-info/WHEEL +5 -0
- ytcap-0.1.0.dist-info/entry_points.txt +2 -0
- ytcap-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- ytcap-0.1.0.dist-info/top_level.txt +1 -0
ytcap/__init__.py
ADDED
ytcap/__main__.py
ADDED
ytcap/app/__init__.py
ADDED
|
@@ -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
|
+
)
|