feat(diarization): добавлено разделение транскрипта по говорящим
Зачем: - локальным транскриптам нужна структура реплик для конспектов и протоколов. Что: - добавлены пословные таймкоды для всех ASR-бэкендов и сведение с Sherpa-ONNX. - реализованы CLI-флаги, деградация без потери ASR и speaker Markdown. - добавлены проверяемый кеш моделей, тесты и документация. Проверка: - `pytest` — 283 passed, 1 skipped. - `pyright` — 0 errors. - Ruff и `git diff --check` — без ошибок. - выполнены три контрольных прогона на реальных записях.
This commit is contained in:
1 parent
63ba41d906
commit
726718197f
23 files changed
+2522
-187
No files matched your search
+276
-47
@@ -11,6 +11,7 @@ from rich.status import Status
|
||||
from .config import apply_device_defaults, load_config, resolve_defaults
|
||||
from .context_menu import install_menu as install_context_menu
|
||||
from .context_menu import uninstall_menu as uninstall_context_menu
|
||||
from .diarization import build_speaker_transcript
|
||||
from .formatter import (
|
||||
LANGUAGE_DETECTED,
|
||||
LANGUAGE_FORCED,
|
||||
@@ -27,6 +28,7 @@ from .quality import (
|
||||
find_repetition_blocks,
|
||||
tail_gap,
|
||||
)
|
||||
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
|
||||
from .transcriber import (
|
||||
Segment,
|
||||
TranscribeResult,
|
||||
@@ -34,7 +36,12 @@ from .transcriber import (
|
||||
_transcribe_file,
|
||||
load_model,
|
||||
)
|
||||
from .types import UNKNOWN_LANGUAGE
|
||||
from .types import (
|
||||
UNKNOWN_LANGUAGE,
|
||||
DiarizationRun,
|
||||
SpeakerTranscript,
|
||||
StatusCallback,
|
||||
)
|
||||
from .utils import (
|
||||
build_output_path,
|
||||
detect_device,
|
||||
@@ -62,9 +69,7 @@ def _format_device_info(device_used: str) -> str:
|
||||
return "CPU"
|
||||
|
||||
|
||||
def _format_language_mode(
|
||||
requested_language: str, result: TranscribeResult
|
||||
) -> str:
|
||||
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
|
||||
"""Описывает источник языка, не выдавая профиль модели за детектор."""
|
||||
if requested_language != "auto":
|
||||
return LANGUAGE_FORCED
|
||||
@@ -92,7 +97,9 @@ def _format_repetition_blocks(
|
||||
return summary
|
||||
|
||||
|
||||
def _print_quality_warnings(result: TranscribeResult, file_name: str | None = None) -> None:
|
||||
def _print_quality_warnings(
|
||||
result: TranscribeResult, file_name: str | None = None
|
||||
) -> None:
|
||||
"""Печатает предупреждения о возможной потере содержания."""
|
||||
is_batch = file_name is not None
|
||||
use_hours = result.duration > 3600
|
||||
@@ -102,8 +109,7 @@ def _print_quality_warnings(result: TranscribeResult, file_name: str | None = No
|
||||
covered = format_duration(result.segments[-1].end)
|
||||
total = format_duration(result.duration)
|
||||
message = (
|
||||
f"транскрипт покрывает {covered} из {total} — "
|
||||
"возможна потеря хвоста записи"
|
||||
f"транскрипт покрывает {covered} из {total} — возможна потеря хвоста записи"
|
||||
)
|
||||
if is_batch:
|
||||
console.print(f" {file_name}: {message}", style="yellow")
|
||||
@@ -128,6 +134,64 @@ def _print_quality_warnings(result: TranscribeResult, file_name: str | None = No
|
||||
)
|
||||
|
||||
|
||||
def _diarize_result(
|
||||
file_path: Path,
|
||||
result: TranscribeResult,
|
||||
diarizer: SpeakerDiarizer,
|
||||
on_status: StatusCallback,
|
||||
) -> tuple[SpeakerTranscript | None, str | None, DiarizationRun | None]:
|
||||
"""Запускает диаризацию и переводит ожидаемые сбои в деградацию вывода."""
|
||||
try:
|
||||
run = diarizer.process(file_path, on_status=on_status)
|
||||
transcript = build_speaker_transcript(
|
||||
result.words,
|
||||
run.intervals,
|
||||
result.duration,
|
||||
)
|
||||
if not run.intervals:
|
||||
warning = "Диаризатор не нашёл интервалов при непустом распознавании"
|
||||
elif transcript.cluster_count < 2:
|
||||
warning = "Найден только один голосовой кластер"
|
||||
else:
|
||||
warning = None
|
||||
return transcript, warning, run
|
||||
except Exception as exc:
|
||||
return None, f"Диаризация завершилась с ошибкой: {exc}", None
|
||||
|
||||
|
||||
def _print_diarization_report(
|
||||
transcript: SpeakerTranscript,
|
||||
run: DiarizationRun,
|
||||
verbose: bool,
|
||||
file_name: str | None = None,
|
||||
) -> None:
|
||||
"""Печатает метрики verbose и обязательные предупреждения сведения."""
|
||||
if verbose:
|
||||
indent = " " if file_name is not None else ""
|
||||
console.print(
|
||||
f"{indent}Диаризация: {transcript.cluster_count} кластеров, "
|
||||
f"{len(run.intervals)} интервалов, {run.elapsed_seconds:.1f} с"
|
||||
)
|
||||
|
||||
warning_prefix = f" {file_name}: " if file_name is not None else "Внимание: "
|
||||
if transcript.unassigned_word_count:
|
||||
console.print(
|
||||
f"{warning_prefix}{transcript.unassigned_word_count} слов "
|
||||
"без назначенного говорящего",
|
||||
style="yellow",
|
||||
)
|
||||
for cluster in transcript.small_clusters:
|
||||
label = (
|
||||
f"Speaker {cluster.speaker}"
|
||||
if cluster.speaker is not None
|
||||
else "кластер без номера"
|
||||
)
|
||||
console.print(
|
||||
f"{warning_prefix}малый кластер {label}: {cluster.duration:.1f} с",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
|
||||
@@ -141,26 +205,54 @@ def main(
|
||||
language: str | None = typer.Option(
|
||||
None, "--language", "-l", show_default=False, help="Язык [по умолч.: ru]"
|
||||
),
|
||||
output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"),
|
||||
output: Path | None = typer.Option(
|
||||
None, "--output", "-o", help="Путь к выходному файлу"
|
||||
),
|
||||
device: str | None = typer.Option(
|
||||
None, "--device", "-d", show_default=False,
|
||||
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]"
|
||||
None,
|
||||
"--device",
|
||||
"-d",
|
||||
show_default=False,
|
||||
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]",
|
||||
),
|
||||
compute_type: str | None = typer.Option(
|
||||
None, "--compute-type", show_default=False,
|
||||
None,
|
||||
"--compute-type",
|
||||
show_default=False,
|
||||
help=(
|
||||
"Тип вычислений [по умолч.: float16 (CUDA) / "
|
||||
"int8 (ONNX/OpenVINO) / float32 (CPU)]"
|
||||
),
|
||||
),
|
||||
threads: int = typer.Option(
|
||||
0, "--threads", "-t", show_default=False, min=0,
|
||||
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)"
|
||||
0,
|
||||
"--threads",
|
||||
"-t",
|
||||
show_default=False,
|
||||
min=0,
|
||||
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
|
||||
),
|
||||
diarize: bool = typer.Option(
|
||||
False,
|
||||
"--diarize",
|
||||
help="Разделить транскрипт на реплики говорящих",
|
||||
),
|
||||
speakers: int | None = typer.Option(
|
||||
None,
|
||||
"--speakers",
|
||||
min=1,
|
||||
help="Известное число говорящих; автоматически включает --diarize",
|
||||
),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"),
|
||||
install_menu: bool = typer.Option(False, "--install-menu", help="Установить пункт Transcribe в SendTo"),
|
||||
uninstall_menu: bool = typer.Option(False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"),
|
||||
force: bool = typer.Option(
|
||||
False, "--force", "-f", help="Перезаписать существующие транскрипты"
|
||||
),
|
||||
install_menu: bool = typer.Option(
|
||||
False, "--install-menu", help="Установить пункт Transcribe в SendTo"
|
||||
),
|
||||
uninstall_menu: bool = typer.Option(
|
||||
False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"
|
||||
),
|
||||
) -> None:
|
||||
"""Транскрибирует аудио/видеофайлы в markdown с таймкодами.
|
||||
|
||||
@@ -170,25 +262,31 @@ def main(
|
||||
|
||||
if install_menu or uninstall_menu:
|
||||
if install_menu and uninstall_menu:
|
||||
console.print("--install-menu и --uninstall-menu несовместимы.", style="red bold")
|
||||
console.print(
|
||||
"--install-menu и --uninstall-menu несовместимы.", style="red bold"
|
||||
)
|
||||
raise SystemExit(2)
|
||||
if files:
|
||||
console.print("Флаги меню нельзя использовать вместе с файлами.", style="red bold")
|
||||
console.print(
|
||||
"Флаги меню нельзя использовать вместе с файлами.", style="red bold"
|
||||
)
|
||||
raise SystemExit(2)
|
||||
if sys.platform != "win32":
|
||||
console.print("Пункт меню SendTo доступен только на Windows.", style="red bold")
|
||||
console.print(
|
||||
"Пункт меню SendTo доступен только на Windows.", style="red bold"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
if install_menu:
|
||||
cmd_path = install_context_menu()
|
||||
console.print(f"Пункт меню установлен: \"{cmd_path}\"", style="green")
|
||||
console.print(f'Пункт меню установлен: "{cmd_path}"', style="green")
|
||||
else:
|
||||
cmd_path = uninstall_context_menu()
|
||||
if cmd_path is None:
|
||||
console.print("Пункт меню не был установлен.", style="yellow")
|
||||
else:
|
||||
console.print(f"Пункт меню удалён: \"{cmd_path}\"", style="green")
|
||||
console.print(f'Пункт меню удалён: "{cmd_path}"', style="green")
|
||||
except RuntimeError as exc:
|
||||
console.print(f"Ошибка: {exc}", style="red bold")
|
||||
raise SystemExit(1)
|
||||
@@ -203,7 +301,12 @@ def main(
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type}
|
||||
cli_values = {
|
||||
"model": model,
|
||||
"language": language,
|
||||
"device": device,
|
||||
"compute_type": compute_type,
|
||||
}
|
||||
defaults = resolve_defaults(cli_values, config)
|
||||
|
||||
resolved_device = detect_device(defaults["device"])
|
||||
@@ -218,13 +321,33 @@ def main(
|
||||
|
||||
is_batch = len(expanded) > 1
|
||||
if is_batch and output is not None:
|
||||
console.print("--output несовместим с несколькими файлами.", style="red bold")
|
||||
console.print(
|
||||
"--output несовместим с несколькими файлами.", style="red bold"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if is_batch:
|
||||
_run_batch(expanded, defaults, verbose, force, ct_explicit, cpu_threads=threads)
|
||||
_run_batch(
|
||||
expanded,
|
||||
defaults,
|
||||
verbose,
|
||||
force,
|
||||
ct_explicit,
|
||||
cpu_threads=threads,
|
||||
diarize=diarize or speakers is not None,
|
||||
speakers=speakers,
|
||||
)
|
||||
else:
|
||||
_run_single(expanded[0], defaults, output, verbose, ct_explicit, cpu_threads=threads)
|
||||
_run_single(
|
||||
expanded[0],
|
||||
defaults,
|
||||
output,
|
||||
verbose,
|
||||
ct_explicit,
|
||||
cpu_threads=threads,
|
||||
diarize=diarize or speakers is not None,
|
||||
speakers=speakers,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\nПрервано пользователем.", style="yellow")
|
||||
raise SystemExit(130)
|
||||
@@ -250,9 +373,7 @@ def main(
|
||||
console.print_exception()
|
||||
else:
|
||||
console.print(f"Ошибка: {exc}", style="red bold")
|
||||
console.print(
|
||||
"Запустите с --verbose для полного traceback.", style="dim"
|
||||
)
|
||||
console.print("Запустите с --verbose для полного traceback.", style="dim")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@@ -263,6 +384,8 @@ def _run_single(
|
||||
verbose: bool,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
diarize: bool = False,
|
||||
speakers: int | None = None,
|
||||
) -> None:
|
||||
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
|
||||
start = time.monotonic()
|
||||
@@ -279,12 +402,18 @@ def _run_single(
|
||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||
|
||||
model_obj, actual_device, backend, model_path = load_model(
|
||||
defaults["model"], resolved_device, defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg), strict_device=strict,
|
||||
defaults["model"],
|
||||
resolved_device,
|
||||
defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
compute_type_explicit=compute_type_explicit,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
actual_ct = getattr(backend, "actual_compute_type", defaults["compute_type"]) or defaults["compute_type"]
|
||||
actual_ct = (
|
||||
getattr(backend, "actual_compute_type", defaults["compute_type"])
|
||||
or defaults["compute_type"]
|
||||
)
|
||||
console.print(
|
||||
f"Модель: [bold]{defaults['model']}[/bold] "
|
||||
f"Устройство: [bold]{actual_device}[/bold] "
|
||||
@@ -296,6 +425,18 @@ def _run_single(
|
||||
style="dim",
|
||||
)
|
||||
|
||||
speaker_diarizer = None
|
||||
if diarize:
|
||||
if not backend.word_timestamps_available:
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
speaker_diarizer = load_speaker_diarizer(
|
||||
speakers=speakers,
|
||||
threads=cpu_threads,
|
||||
on_status=lambda message: console.print(message),
|
||||
)
|
||||
|
||||
with Status("Подготавливаю запуск...", console=console) as status:
|
||||
tfr = _transcribe_file(
|
||||
model=model_obj,
|
||||
@@ -313,6 +454,31 @@ def _run_single(
|
||||
)
|
||||
|
||||
result = tfr.result
|
||||
speaker_transcript = None
|
||||
diarization_warning = None
|
||||
diarization_degraded = False
|
||||
if speaker_diarizer is not None and result.segments:
|
||||
with Status("Определяю говорящих...", console=console) as status:
|
||||
speaker_transcript, diarization_warning, diarization_run = _diarize_result(
|
||||
validated_file,
|
||||
result,
|
||||
speaker_diarizer,
|
||||
on_status=(
|
||||
(lambda message: console.print(message))
|
||||
if verbose
|
||||
else status.update
|
||||
),
|
||||
)
|
||||
diarization_degraded = diarization_warning is not None
|
||||
if diarization_run is not None and speaker_transcript is not None:
|
||||
_print_diarization_report(
|
||||
speaker_transcript,
|
||||
diarization_run,
|
||||
verbose,
|
||||
)
|
||||
|
||||
if diarization_warning is not None:
|
||||
console.print(f"Внимание: {diarization_warning}", style="yellow")
|
||||
|
||||
if tfr.actual_device != resolved_device:
|
||||
if requested_device == "auto":
|
||||
@@ -328,9 +494,10 @@ def _run_single(
|
||||
)
|
||||
|
||||
if len(result.segments) == 0:
|
||||
console.print(
|
||||
f"Речь не обнаружена в файле {validated_file.name}", style="yellow"
|
||||
)
|
||||
message = f"Речь не обнаружена в файле {validated_file.name}"
|
||||
if speaker_diarizer is not None:
|
||||
message += "; диаризация не запускалась"
|
||||
console.print(message, style="yellow")
|
||||
|
||||
device_info = _format_device_info(result.device_used)
|
||||
language_mode = _format_language_mode(defaults["language"], result)
|
||||
@@ -341,13 +508,17 @@ def _run_single(
|
||||
model_name=defaults["model"],
|
||||
device_info=device_info,
|
||||
language_mode=language_mode,
|
||||
speaker_transcript=speaker_transcript,
|
||||
diarization_warning=diarization_warning,
|
||||
)
|
||||
write_transcript(content, output_path)
|
||||
|
||||
elapsed = time.monotonic() - start
|
||||
console.print(f"Транскрипт сохранён: \"{output_path}\"", style="green")
|
||||
console.print(f'Транскрипт сохранён: "{output_path}"', style="green")
|
||||
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
|
||||
_print_quality_warnings(result)
|
||||
if diarization_degraded:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _run_batch(
|
||||
@@ -357,6 +528,8 @@ def _run_batch(
|
||||
force: bool,
|
||||
compute_type_explicit: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
diarize: bool = False,
|
||||
speakers: int | None = None,
|
||||
) -> None:
|
||||
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
|
||||
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
|
||||
@@ -379,9 +552,7 @@ def _run_batch(
|
||||
to_process.append(validated)
|
||||
|
||||
if not to_process:
|
||||
console.print(
|
||||
f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок"
|
||||
)
|
||||
console.print(f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок")
|
||||
if invalid > 0:
|
||||
raise SystemExit(1)
|
||||
return
|
||||
@@ -391,8 +562,11 @@ def _run_batch(
|
||||
resolved_device = detect_device(requested_device)
|
||||
strict = requested_device != "auto"
|
||||
model_obj, actual_device, backend, model_path = load_model(
|
||||
defaults["model"], resolved_device, defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg), strict_device=strict,
|
||||
defaults["model"],
|
||||
resolved_device,
|
||||
defaults["compute_type"],
|
||||
on_status=lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
compute_type_explicit=compute_type_explicit,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
@@ -416,8 +590,21 @@ def _run_batch(
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
speaker_diarizer = None
|
||||
if diarize:
|
||||
if not backend.word_timestamps_available:
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
speaker_diarizer = load_speaker_diarizer(
|
||||
speakers=speakers,
|
||||
threads=cpu_threads,
|
||||
on_status=lambda message: console.print(message),
|
||||
)
|
||||
|
||||
# Phase 3: Transcribe
|
||||
processed = 0
|
||||
degraded = 0
|
||||
failed = 0
|
||||
batch_start = time.monotonic()
|
||||
|
||||
@@ -439,9 +626,13 @@ def _run_batch(
|
||||
file_path=file,
|
||||
model_name=defaults["model"],
|
||||
compute_type=defaults["compute_type"],
|
||||
language=defaults["language"] if defaults["language"] != "auto" else None,
|
||||
language=defaults["language"]
|
||||
if defaults["language"] != "auto"
|
||||
else None,
|
||||
on_segment=on_segment if verbose else None,
|
||||
on_status=status.update if not verbose else lambda msg: console.print(msg),
|
||||
on_status=status.update
|
||||
if not verbose
|
||||
else lambda msg: console.print(msg),
|
||||
strict_device=strict,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
@@ -459,11 +650,44 @@ def _run_batch(
|
||||
|
||||
result = tfr.result
|
||||
language_mode = _format_language_mode(defaults["language"], result)
|
||||
speaker_transcript = None
|
||||
diarization_warning = None
|
||||
file_degraded = False
|
||||
|
||||
if speaker_diarizer is not None and result.segments:
|
||||
with Status("Определяю говорящих...", console=console) as status:
|
||||
speaker_transcript, diarization_warning, diarization_run = (
|
||||
_diarize_result(
|
||||
file,
|
||||
result,
|
||||
speaker_diarizer,
|
||||
on_status=(
|
||||
(lambda message: console.print(message))
|
||||
if verbose
|
||||
else status.update
|
||||
),
|
||||
)
|
||||
)
|
||||
file_degraded = diarization_warning is not None
|
||||
if diarization_run is not None and speaker_transcript is not None:
|
||||
_print_diarization_report(
|
||||
speaker_transcript,
|
||||
diarization_run,
|
||||
verbose,
|
||||
file_name=file.name,
|
||||
)
|
||||
|
||||
if diarization_warning is not None:
|
||||
console.print(
|
||||
f" {file.name}: {diarization_warning}",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
if len(result.segments) == 0:
|
||||
console.print(
|
||||
f" Речь не обнаружена: {file.name}", style="yellow"
|
||||
)
|
||||
message = f" Речь не обнаружена: {file.name}"
|
||||
if speaker_diarizer is not None:
|
||||
message += "; диаризация не запускалась"
|
||||
console.print(message, style="yellow")
|
||||
|
||||
device_info = _format_device_info(result.device_used)
|
||||
|
||||
@@ -473,6 +697,8 @@ def _run_batch(
|
||||
model_name=defaults["model"],
|
||||
device_info=device_info,
|
||||
language_mode=language_mode,
|
||||
speaker_transcript=speaker_transcript,
|
||||
diarization_warning=diarization_warning,
|
||||
)
|
||||
write_transcript(content, build_output_path(file))
|
||||
file_elapsed = time.monotonic() - file_start
|
||||
@@ -482,6 +708,8 @@ def _run_batch(
|
||||
style="green",
|
||||
)
|
||||
processed += 1
|
||||
if file_degraded:
|
||||
degraded += 1
|
||||
_print_quality_warnings(result, file.name)
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
@@ -495,10 +723,11 @@ def _run_batch(
|
||||
total_failed = invalid + failed
|
||||
batch_elapsed = time.monotonic() - batch_start
|
||||
console.print(
|
||||
f"\nИтого: {processed} обработано, {skipped} пропущено, {total_failed} ошибок"
|
||||
f"\nИтого: {processed} обработано, {skipped} пропущено, "
|
||||
f"{degraded} с деградацией, {total_failed} ошибок"
|
||||
f" Время: {batch_elapsed:.1f}с"
|
||||
)
|
||||
if total_failed > 0:
|
||||
if total_failed > 0 or degraded > 0:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user