segmenta-archiver 0.1.2__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.
- segmenta/__init__.py +1 -0
- segmenta/cli.py +477 -0
- segmenta/merger.py +516 -0
- segmenta/thumbnailer.py +439 -0
- segmenta_archiver-0.1.2.dist-info/METADATA +10 -0
- segmenta_archiver-0.1.2.dist-info/RECORD +9 -0
- segmenta_archiver-0.1.2.dist-info/WHEEL +4 -0
- segmenta_archiver-0.1.2.dist-info/entry_points.txt +2 -0
- segmenta_archiver-0.1.2.dist-info/licenses/LICENSE +21 -0
segmenta/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
segmenta/cli.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .merger import (
|
|
9
|
+
create_output_folder_name,
|
|
10
|
+
create_output_folder_name_from_template,
|
|
11
|
+
detect_available_hevc_encoders,
|
|
12
|
+
detect_group_resolution_label,
|
|
13
|
+
group_files_by_source_and_date,
|
|
14
|
+
merge_and_transcode,
|
|
15
|
+
quality_label_for_codec,
|
|
16
|
+
resolve_encoder_choice,
|
|
17
|
+
scan_and_sort_ts_files,
|
|
18
|
+
)
|
|
19
|
+
from .thumbnailer import Thumbnailer, ThumbnailerParams
|
|
20
|
+
|
|
21
|
+
ASCII_LOGO = r"""
|
|
22
|
+
_____ _
|
|
23
|
+
/ ____| | |
|
|
24
|
+
| (___ ___ __ _ _ __ ___ ___ _ __ | |_ __ _
|
|
25
|
+
\___ \ / _ \/ _` | '_ ` _ \ / _ \ '_ \| __/ _` |
|
|
26
|
+
____) | __/ (_| | | | | | | __/ | | | || (_| |
|
|
27
|
+
|_____/ \___|\__, |_| |_| |_|\___|_| |_|\__\__,_|
|
|
28
|
+
__/ |
|
|
29
|
+
|___/
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
help="Turn messy stream segments into a polished personal archive.",
|
|
35
|
+
pretty_exceptions_show_locals=False,
|
|
36
|
+
rich_markup_mode="rich",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SOURCE_LABEL_MAP = {
|
|
41
|
+
"platform": "Platform",
|
|
42
|
+
"twitch": "Twitch",
|
|
43
|
+
"youtube-live": "YouTube Live",
|
|
44
|
+
"kick": "Kick",
|
|
45
|
+
"tiktok-live": "TikTok Live",
|
|
46
|
+
"facebook-gaming": "Facebook Gaming",
|
|
47
|
+
"facebook-live": "Facebook Live",
|
|
48
|
+
"instagram-live": "Instagram Live",
|
|
49
|
+
"trovo": "Trovo",
|
|
50
|
+
"rumble": "Rumble",
|
|
51
|
+
"caffeine": "Caffeine",
|
|
52
|
+
"picarto": "Picarto",
|
|
53
|
+
"dlive": "DLive",
|
|
54
|
+
"bigo-live": "Bigo Live",
|
|
55
|
+
"vimeo-livestream": "Vimeo Livestream",
|
|
56
|
+
"streamyard": "StreamYard",
|
|
57
|
+
"chaturbate": "Chaturbate",
|
|
58
|
+
"stripchat": "Stripchat",
|
|
59
|
+
"jerkmate": "Jerkmate",
|
|
60
|
+
"camsoda": "CamSoda",
|
|
61
|
+
"livejasmin": "LiveJasmin",
|
|
62
|
+
"bongacams": "BongaCams",
|
|
63
|
+
"myfreecams": "MyFreeCams",
|
|
64
|
+
"flirt4free": "Flirt4Free",
|
|
65
|
+
"cams-com": "Cams.com",
|
|
66
|
+
"imlive": "ImLive",
|
|
67
|
+
"streamate": "Streamate",
|
|
68
|
+
"luckycrush": "LuckyCrush",
|
|
69
|
+
"cam4": "CAM4",
|
|
70
|
+
"custom": "Custom",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
SourceOption = Literal[
|
|
74
|
+
"platform",
|
|
75
|
+
"twitch",
|
|
76
|
+
"youtube-live",
|
|
77
|
+
"kick",
|
|
78
|
+
"tiktok-live",
|
|
79
|
+
"facebook-gaming",
|
|
80
|
+
"facebook-live",
|
|
81
|
+
"instagram-live",
|
|
82
|
+
"trovo",
|
|
83
|
+
"rumble",
|
|
84
|
+
"caffeine",
|
|
85
|
+
"picarto",
|
|
86
|
+
"dlive",
|
|
87
|
+
"bigo-live",
|
|
88
|
+
"vimeo-livestream",
|
|
89
|
+
"streamyard",
|
|
90
|
+
"chaturbate",
|
|
91
|
+
"stripchat",
|
|
92
|
+
"jerkmate",
|
|
93
|
+
"camsoda",
|
|
94
|
+
"livejasmin",
|
|
95
|
+
"bongacams",
|
|
96
|
+
"myfreecams",
|
|
97
|
+
"flirt4free",
|
|
98
|
+
"cams-com",
|
|
99
|
+
"imlive",
|
|
100
|
+
"streamate",
|
|
101
|
+
"luckycrush",
|
|
102
|
+
"cam4",
|
|
103
|
+
"custom",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
EncoderOption = Literal["gpu", "auto", "cpu", "nvenc", "qsv", "amf"]
|
|
107
|
+
OnExistsOption = Literal["prompt", "skip", "overwrite", "rename"]
|
|
108
|
+
PresetOption = Literal[
|
|
109
|
+
"ultrafast",
|
|
110
|
+
"superfast",
|
|
111
|
+
"veryfast",
|
|
112
|
+
"faster",
|
|
113
|
+
"fast",
|
|
114
|
+
"medium",
|
|
115
|
+
"slow",
|
|
116
|
+
"slower",
|
|
117
|
+
"veryslow",
|
|
118
|
+
"placebo",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def print_encoder_inventory(available_encoders: set[str]) -> None:
|
|
123
|
+
print(ASCII_LOGO)
|
|
124
|
+
ordered = ["hevc_nvenc", "hevc_qsv", "hevc_amf", "libx265"]
|
|
125
|
+
typer.echo("Detected HEVC encoders:")
|
|
126
|
+
for encoder_name in ordered:
|
|
127
|
+
status = "yes" if encoder_name in available_encoders else "no"
|
|
128
|
+
typer.echo(f"- {encoder_name}: {status}")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
auto_selected, auto_fallback = resolve_encoder_choice("gpu", available_encoders)
|
|
132
|
+
typer.echo(
|
|
133
|
+
"Auto/gpu selection order: hevc_nvenc -> hevc_qsv -> hevc_amf -> libx265"
|
|
134
|
+
)
|
|
135
|
+
if auto_fallback:
|
|
136
|
+
typer.echo(f"Current auto/gpu choice: {auto_selected} (CPU fallback)")
|
|
137
|
+
else:
|
|
138
|
+
typer.echo(f"Current auto/gpu choice: {auto_selected}")
|
|
139
|
+
except ValueError as exc:
|
|
140
|
+
typer.echo(f"Current auto/gpu choice: unavailable ({exc})")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def print_template_variables() -> None:
|
|
144
|
+
typer.echo("Available --name-template variables:")
|
|
145
|
+
typer.echo("- {source_label} -> source/platform label")
|
|
146
|
+
typer.echo("- {source} -> source/channel identifier from filename")
|
|
147
|
+
typer.echo("- {show} -> show/session label")
|
|
148
|
+
typer.echo("- {month_abbr} -> month abbreviation, e.g. Dec")
|
|
149
|
+
typer.echo("- {day} -> day of month, e.g. 14")
|
|
150
|
+
typer.echo("- {year} -> year, e.g. 2025")
|
|
151
|
+
typer.echo("- {date_iso} -> ISO date, e.g. 2025-12-14")
|
|
152
|
+
typer.echo("- {resolution} -> detected height label, e.g. 1080p")
|
|
153
|
+
typer.echo(
|
|
154
|
+
"Example template: "
|
|
155
|
+
"[{source_label}] {source} {show} - {month_abbr} {day} {year} [{resolution}]"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_source_label(source: SourceOption, source_label: str | None) -> str:
|
|
160
|
+
if source_label is not None:
|
|
161
|
+
cleaned = source_label.strip()
|
|
162
|
+
if not cleaned:
|
|
163
|
+
raise ValueError("--source-label cannot be empty")
|
|
164
|
+
return cleaned
|
|
165
|
+
|
|
166
|
+
if source == "custom":
|
|
167
|
+
raise ValueError("--source custom requires --source-label")
|
|
168
|
+
|
|
169
|
+
return SOURCE_LABEL_MAP[source]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def version_callback(value: bool) -> None:
|
|
173
|
+
if not value:
|
|
174
|
+
return
|
|
175
|
+
typer.echo(f"segmenta {__version__}")
|
|
176
|
+
raise typer.Exit()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def choose_output_folder(
|
|
180
|
+
base_output_dir: Path,
|
|
181
|
+
folder_name: str,
|
|
182
|
+
on_exists: OnExistsOption,
|
|
183
|
+
) -> Path | None:
|
|
184
|
+
target_dir = base_output_dir / folder_name
|
|
185
|
+
if not target_dir.exists():
|
|
186
|
+
typer.echo(f"\nOutput folder: {target_dir}")
|
|
187
|
+
return target_dir
|
|
188
|
+
|
|
189
|
+
typer.echo(f"\nOutput folder already exists: {target_dir}")
|
|
190
|
+
if on_exists == "skip":
|
|
191
|
+
typer.echo("Skipping...")
|
|
192
|
+
return None
|
|
193
|
+
if on_exists == "overwrite":
|
|
194
|
+
return target_dir
|
|
195
|
+
if on_exists == "rename":
|
|
196
|
+
counter = 1
|
|
197
|
+
while target_dir.exists():
|
|
198
|
+
target_dir = base_output_dir / f"{folder_name} ({counter})"
|
|
199
|
+
counter += 1
|
|
200
|
+
typer.echo(f"Using new folder: {target_dir.name}")
|
|
201
|
+
return target_dir
|
|
202
|
+
|
|
203
|
+
response = typer.prompt(
|
|
204
|
+
"\nOptions: [s]kip, [o]verwrite, [r]ename\nChoose action",
|
|
205
|
+
default="s",
|
|
206
|
+
).lower()
|
|
207
|
+
if response == "s":
|
|
208
|
+
typer.echo("Skipping...")
|
|
209
|
+
return None
|
|
210
|
+
if response == "o":
|
|
211
|
+
return target_dir
|
|
212
|
+
if response == "r":
|
|
213
|
+
counter = 1
|
|
214
|
+
while target_dir.exists():
|
|
215
|
+
target_dir = base_output_dir / f"{folder_name} ({counter})"
|
|
216
|
+
counter += 1
|
|
217
|
+
typer.echo(f"Using new folder: {target_dir.name}")
|
|
218
|
+
return target_dir
|
|
219
|
+
|
|
220
|
+
typer.echo("Invalid option, skipping")
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command(
|
|
225
|
+
help="Turn messy stream segments into a polished personal archive.",
|
|
226
|
+
)
|
|
227
|
+
def main(
|
|
228
|
+
input_folder: Path | None = typer.Argument(None),
|
|
229
|
+
output_dir: Path | None = typer.Option(None, "--output-dir", "-o"),
|
|
230
|
+
source: SourceOption = typer.Option("platform", "--source"),
|
|
231
|
+
source_label: str | None = typer.Option(None, "--source-label"),
|
|
232
|
+
show_label: str = typer.Option("Session", "--show-label"),
|
|
233
|
+
name_template: str | None = typer.Option(None, "--name-template"),
|
|
234
|
+
encoder: EncoderOption = typer.Option("gpu", "--encoder"),
|
|
235
|
+
crf: int = typer.Option(22, "--crf"),
|
|
236
|
+
cq: int = typer.Option(22, "--cq"),
|
|
237
|
+
keep_ts: bool = typer.Option(False, "--keep-ts"),
|
|
238
|
+
delete_original: bool = typer.Option(
|
|
239
|
+
False,
|
|
240
|
+
"--delete-original",
|
|
241
|
+
help="Delete processed source .ts files after successful run.",
|
|
242
|
+
),
|
|
243
|
+
thumbnail: bool = typer.Option(
|
|
244
|
+
True,
|
|
245
|
+
"--thumbnail/--no-thumbnail",
|
|
246
|
+
help="Generate a preview thumbnail sheet after each successful output.",
|
|
247
|
+
),
|
|
248
|
+
preset: PresetOption = typer.Option("medium", "--preset"),
|
|
249
|
+
on_exists: OnExistsOption = typer.Option("prompt", "--on-exists"),
|
|
250
|
+
list_encoders: bool = typer.Option(False, "--list-encoders"),
|
|
251
|
+
print_template_vars: bool = typer.Option(False, "--print-template-vars"),
|
|
252
|
+
version: bool = typer.Option(
|
|
253
|
+
False,
|
|
254
|
+
"--version",
|
|
255
|
+
help="Show Segmenta version and exit.",
|
|
256
|
+
callback=version_callback,
|
|
257
|
+
is_eager=True,
|
|
258
|
+
),
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Turn messy stream segments into a polished personal archive."""
|
|
261
|
+
if crf < 0 or crf > 51:
|
|
262
|
+
typer.echo("CRF must be between 0 and 51")
|
|
263
|
+
raise typer.Exit(code=1)
|
|
264
|
+
if cq < 0 or cq > 51:
|
|
265
|
+
typer.echo("CQ must be between 0 and 51")
|
|
266
|
+
raise typer.Exit(code=1)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
available_encoders = detect_available_hevc_encoders()
|
|
270
|
+
except RuntimeError as exc:
|
|
271
|
+
typer.echo(str(exc))
|
|
272
|
+
raise typer.Exit(code=1)
|
|
273
|
+
|
|
274
|
+
if list_encoders:
|
|
275
|
+
print_encoder_inventory(available_encoders)
|
|
276
|
+
raise typer.Exit(code=0)
|
|
277
|
+
if print_template_vars:
|
|
278
|
+
print(ASCII_LOGO)
|
|
279
|
+
print_template_variables()
|
|
280
|
+
raise typer.Exit(code=0)
|
|
281
|
+
|
|
282
|
+
if input_folder is None:
|
|
283
|
+
typer.echo(
|
|
284
|
+
"Missing INPUT_FOLDER. Or use --list-encoders/--print-template-vars."
|
|
285
|
+
)
|
|
286
|
+
raise typer.Exit(code=1)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
resolved_source_label = resolve_source_label(source, source_label)
|
|
290
|
+
except ValueError as exc:
|
|
291
|
+
typer.echo(str(exc))
|
|
292
|
+
raise typer.Exit(code=1)
|
|
293
|
+
|
|
294
|
+
if not input_folder.exists() or not input_folder.is_dir():
|
|
295
|
+
typer.echo(f"Input folder does not exist: {input_folder}")
|
|
296
|
+
raise typer.Exit(code=1)
|
|
297
|
+
|
|
298
|
+
input_folder = input_folder.resolve()
|
|
299
|
+
if output_dir is None:
|
|
300
|
+
output_dir = input_folder
|
|
301
|
+
else:
|
|
302
|
+
output_dir = output_dir.resolve()
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
video_codec, fell_back_to_cpu = resolve_encoder_choice(
|
|
306
|
+
encoder, available_encoders
|
|
307
|
+
)
|
|
308
|
+
except ValueError as exc:
|
|
309
|
+
typer.echo(str(exc))
|
|
310
|
+
raise typer.Exit(code=1)
|
|
311
|
+
|
|
312
|
+
if fell_back_to_cpu:
|
|
313
|
+
typer.echo("No supported GPU HEVC encoder found, using CPU encoder libx265.")
|
|
314
|
+
if video_codec != "libx265" and preset != "medium":
|
|
315
|
+
typer.echo(
|
|
316
|
+
"Note: --preset applies only to CPU/libx265 and is ignored for GPU modes."
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
parsed_files = scan_and_sort_ts_files(input_folder)
|
|
320
|
+
if not parsed_files:
|
|
321
|
+
typer.echo(f"No valid .ts files found in {input_folder}")
|
|
322
|
+
raise typer.Exit(code=1)
|
|
323
|
+
|
|
324
|
+
grouped = group_files_by_source_and_date(parsed_files)
|
|
325
|
+
sorted_group_keys = sorted(grouped.keys(), key=lambda key: (key[1], key[0]))
|
|
326
|
+
print(ASCII_LOGO)
|
|
327
|
+
typer.echo(f"Found {len(sorted_group_keys)} output group(s).")
|
|
328
|
+
typer.echo(
|
|
329
|
+
f"Selected encoder: {video_codec} ({quality_label_for_codec(video_codec, crf, cq)})"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
success_count = 0
|
|
333
|
+
skipped_count = 0
|
|
334
|
+
failed_count = 0
|
|
335
|
+
processed_ts_files: set[Path] = set()
|
|
336
|
+
output_paths: list[Path] = []
|
|
337
|
+
preview_paths: list[Path] = []
|
|
338
|
+
|
|
339
|
+
thumbnailer: Thumbnailer | None = None
|
|
340
|
+
if thumbnail:
|
|
341
|
+
thumbnailer = Thumbnailer(
|
|
342
|
+
ThumbnailerParams(
|
|
343
|
+
columns=3,
|
|
344
|
+
rows=9,
|
|
345
|
+
tile_width=400,
|
|
346
|
+
background_color="black",
|
|
347
|
+
header_font_color="white",
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
for source_name, scene_date in sorted_group_keys:
|
|
352
|
+
group_files = grouped[(source_name, scene_date)]
|
|
353
|
+
resolution_label = detect_group_resolution_label(group_files)
|
|
354
|
+
try:
|
|
355
|
+
if name_template:
|
|
356
|
+
folder_name = create_output_folder_name_from_template(
|
|
357
|
+
source=source_name,
|
|
358
|
+
scene_date=scene_date,
|
|
359
|
+
resolution_label=resolution_label,
|
|
360
|
+
source_label=resolved_source_label,
|
|
361
|
+
show_label=show_label,
|
|
362
|
+
name_template=name_template,
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
folder_name = create_output_folder_name(
|
|
366
|
+
source=source_name,
|
|
367
|
+
scene_date=scene_date,
|
|
368
|
+
resolution_label=resolution_label,
|
|
369
|
+
source_label=resolved_source_label,
|
|
370
|
+
show_label=show_label,
|
|
371
|
+
)
|
|
372
|
+
except ValueError as exc:
|
|
373
|
+
typer.echo(str(exc))
|
|
374
|
+
raise typer.Exit(code=1)
|
|
375
|
+
|
|
376
|
+
target_dir = choose_output_folder(output_dir, folder_name, on_exists)
|
|
377
|
+
if target_dir is None:
|
|
378
|
+
skipped_count += 1
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
output_file = target_dir / f"{target_dir.name}.mp4"
|
|
383
|
+
|
|
384
|
+
typer.echo(f"\nGroup: {source_name} {scene_date.isoformat()}")
|
|
385
|
+
typer.echo(f"Files to merge: {len(group_files)}")
|
|
386
|
+
typer.echo(f"First timestamp: {group_files[0].timestamp}")
|
|
387
|
+
typer.echo(f"Last timestamp: {group_files[-1].timestamp}")
|
|
388
|
+
typer.echo(f"Output file: {output_file.name}")
|
|
389
|
+
|
|
390
|
+
success = merge_and_transcode(
|
|
391
|
+
parsed_files=group_files,
|
|
392
|
+
output_path=output_file,
|
|
393
|
+
video_codec=video_codec,
|
|
394
|
+
crf=crf,
|
|
395
|
+
cq=cq,
|
|
396
|
+
preset=preset,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if not success:
|
|
400
|
+
failed_count += 1
|
|
401
|
+
if output_file.exists():
|
|
402
|
+
output_file.unlink(missing_ok=True)
|
|
403
|
+
typer.echo("Transcoding failed for this group.")
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
success_count += 1
|
|
407
|
+
output_paths.append(output_file)
|
|
408
|
+
processed_ts_files.update(parsed.path for parsed in group_files)
|
|
409
|
+
|
|
410
|
+
typer.echo(f"Successfully created: {output_file}")
|
|
411
|
+
typer.echo(f"File size: {output_file.stat().st_size / 1024 / 1024:.2f} MB")
|
|
412
|
+
|
|
413
|
+
if thumbnail and thumbnailer is not None:
|
|
414
|
+
preview_source = output_file.resolve()
|
|
415
|
+
if not preview_source.exists():
|
|
416
|
+
typer.secho(
|
|
417
|
+
f"Warning: preview source file not found: {preview_source}",
|
|
418
|
+
fg=typer.colors.YELLOW,
|
|
419
|
+
)
|
|
420
|
+
continue
|
|
421
|
+
preview_file = target_dir / f"{target_dir.name}_preview.jpg"
|
|
422
|
+
typer.echo(f"Generating preview from: {preview_source.name}")
|
|
423
|
+
|
|
424
|
+
def preview_progress(done: int, total: int) -> None:
|
|
425
|
+
if total <= 0:
|
|
426
|
+
return
|
|
427
|
+
bar_width = 30
|
|
428
|
+
ratio = min(max(done / total, 0.0), 1.0)
|
|
429
|
+
filled = int(ratio * bar_width)
|
|
430
|
+
bar = "#" * filled + "-" * (bar_width - filled)
|
|
431
|
+
line = f"\r[{bar}] {done:2d}/{total:2d} frames"
|
|
432
|
+
if done >= total:
|
|
433
|
+
line += "\n"
|
|
434
|
+
sys.stdout.write(line)
|
|
435
|
+
sys.stdout.flush()
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
thumbnailer.create_and_save_preview_thumbnails_for(
|
|
439
|
+
preview_source,
|
|
440
|
+
preview_file,
|
|
441
|
+
progress_callback=preview_progress,
|
|
442
|
+
)
|
|
443
|
+
preview_paths.append(preview_file)
|
|
444
|
+
typer.echo(f"Preview created: {preview_file}")
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
typer.secho(
|
|
447
|
+
f"Warning: preview generation failed for {output_file.name}: {exc}",
|
|
448
|
+
fg=typer.colors.YELLOW,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
typer.echo("\nRun summary:")
|
|
452
|
+
typer.echo(f"- groups total: {len(sorted_group_keys)}")
|
|
453
|
+
typer.echo(f"- groups succeeded: {success_count}")
|
|
454
|
+
typer.echo(f"- groups skipped: {skipped_count}")
|
|
455
|
+
typer.echo(f"- groups failed: {failed_count}")
|
|
456
|
+
for output_path in output_paths:
|
|
457
|
+
typer.echo(f"- output: {output_path}")
|
|
458
|
+
for preview_path in preview_paths:
|
|
459
|
+
typer.echo(f"- preview: {preview_path}")
|
|
460
|
+
|
|
461
|
+
if processed_ts_files and delete_original and not keep_ts:
|
|
462
|
+
for ts_file in sorted(processed_ts_files):
|
|
463
|
+
if ts_file.exists():
|
|
464
|
+
ts_file.unlink()
|
|
465
|
+
typer.echo("Deleted original processed .ts files")
|
|
466
|
+
elif delete_original and keep_ts:
|
|
467
|
+
typer.echo("--keep-ts is set; source .ts files were preserved.")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
if __name__ == "__main__":
|
|
471
|
+
app()
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def run() -> None:
|
|
475
|
+
if "--help" in sys.argv or "-h" in sys.argv:
|
|
476
|
+
print(ASCII_LOGO)
|
|
477
|
+
app()
|