feat(cli): добавлен пункт Transcribe в меню «Отправить» проводника

- Зачем:
  - запуск транскрипции правой кнопкой из проводника Windows без
    терминала, для пользователей без прав администратора.
- Что:
  - новый модуль context_menu.py: установка/удаление Transcribe.cmd
    в папке SendTo (OEM-кодировка, CRLF, без реестра).
  - флаги --install-menu / --uninstall-menu в cli.py с ранней
    валидацией до load_config; files стал необязательным аргументом.
  - раздел в README.md: установка, использование, ручное удаление
    через shell:sendto, известные ограничения.
- Проверка:
  - uv run pytest — 191 passed, 1 skipped.
  - uv run transcribe --install-menu и правый клик → Отправить →
    Transcribe на файле с кириллицей в имени.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dmitriy Dementiev
2026-07-09 19:29:07 +03:00
co-authored by Claude Fable 5
parent 966efc3bfe
commit f18ad6ea2a
5 changed files with 345 additions and 1 deletions
+23
View File
@@ -144,6 +144,29 @@ transcribe *.mp4 --force
- При ошибке в одном файле остальные продолжают обрабатываться - При ошибке в одном файле остальные продолжают обрабатываться
- `--output` несовместим с несколькими файлами - `--output` несовместим с несколькими файлами
### Контекстное меню проводника (Windows)
Установить пункт `Transcribe` в меню «Отправить»:
```bash
uv run transcribe --install-menu
```
Использование: выделите один или несколько аудио/видеофайлов в проводнике, откройте контекстное меню правой кнопкой. В Windows 11 выберите «Показать дополнительные параметры» или нажмите Shift+F10, затем «Отправить» → «Transcribe». Несколько выделенных файлов передаются в один процесс и обрабатываются одним батчем.
Удалить пункт меню:
```bash
uv run transcribe --uninstall-menu
```
Если что-то пошло не так, пункт можно удалить вручную: Win+R → `shell:sendto` → удалить `Transcribe.cmd`.
Известные ограничения:
- После переноса или пересоздания проекта/venv выполните `--install-menu` заново: внутри `Transcribe.cmd` хранится абсолютный путь к `transcribe.exe`.
- Очень большой мультивыбор с суммарной длиной путей ≳8000 символов упирается в лимит командной строки cmd.exe. Обрабатывайте такие файлы частями.
### Опции CLI ### Опции CLI
| Опция | Сокращение | По умолчанию | Описание | | Опция | Сокращение | По умолчанию | Описание |
+40 -1
View File
@@ -9,6 +9,8 @@ from rich.console import Console
from rich.status import Status from rich.status import Status
from .config import apply_device_defaults, load_config, resolve_defaults 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 .formatter import format_transcript, write_transcript from .formatter import format_transcript, write_transcript
from .transcriber import ( from .transcriber import (
Segment, Segment,
@@ -45,7 +47,7 @@ def _format_device_info(device_used: str) -> str:
@app.command() @app.command()
def main( def main(
files: list[Path] = typer.Argument(..., help="Пути к аудио/видеофайлам"), files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
model: str | None = typer.Option( model: str | None = typer.Option(
None, "--model", "-m", show_default=False, help="Модель Whisper [по умолч.: medium]" None, "--model", "-m", show_default=False, help="Модель Whisper [по умолч.: medium]"
), ),
@@ -67,11 +69,48 @@ def main(
), ),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
force: bool = typer.Option(False, "--force", "-f", 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"),
) -> None: ) -> None:
"""Транскрибирует аудио/видеофайлы в markdown с таймкодами. """Транскрибирует аудио/видеофайлы в markdown с таймкодами.
Каскад приоритетов параметров: CLI-флаги > .transcriber.toml > device-aware дефолты. Каскад приоритетов параметров: CLI-флаги > .transcriber.toml > device-aware дефолты.
""" """
files = [] if files is None else files
if install_menu or uninstall_menu:
if install_menu and uninstall_menu:
console.print("--install-menu и --uninstall-menu несовместимы.", style="red bold")
raise SystemExit(2)
if files:
console.print("Флаги меню нельзя использовать вместе с файлами.", style="red bold")
raise SystemExit(2)
if sys.platform != "win32":
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")
else:
cmd_path = uninstall_context_menu()
if cmd_path is None:
console.print("Пункт меню не был установлен.", style="yellow")
else:
console.print(f"Пункт меню удалён: \"{cmd_path}\"", style="green")
except RuntimeError as exc:
console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1)
return
if not files:
console.print(
"Укажите хотя бы один файл или используйте --install-menu/--uninstall-menu.",
style="red bold",
)
raise SystemExit(2)
try: try:
config = load_config() 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}
+57
View File
@@ -0,0 +1,57 @@
"""Установка пункта Transcribe в меню SendTo проводника Windows."""
import os
import sys
from pathlib import Path
CMD_NAME = "Transcribe.cmd"
CMD_ENCODING = "oem"
def get_sendto_dir() -> Path:
"""Возвращает путь к пользовательской папке SendTo."""
appdata = os.environ.get("APPDATA")
if appdata is None:
raise RuntimeError("Переменная окружения APPDATA не задана.")
return Path(appdata) / "Microsoft" / "Windows" / "SendTo"
def get_transcribe_exe() -> Path:
"""Возвращает путь к transcribe.exe рядом с текущим интерпретатором."""
transcribe_exe = Path(sys.executable).parent / "transcribe.exe"
if not transcribe_exe.exists():
raise RuntimeError(
f"Не найден transcribe.exe рядом с Python: {transcribe_exe}. "
"Выполните uv sync и повторите установку пункта меню."
)
return transcribe_exe
def install_menu() -> Path:
"""Создаёт или обновляет Transcribe.cmd в папке SendTo."""
sendto_dir = get_sendto_dir()
transcribe_exe = get_transcribe_exe()
cmd_path = sendto_dir / CMD_NAME
content = f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'
try:
encoded_content = content.encode(CMD_ENCODING)
except UnicodeEncodeError as exc:
raise RuntimeError(
"Путь к transcribe.exe содержит символы, которые нельзя записать "
"в OEM-кодировке cmd.exe. Установите проект в путь без таких символов "
"и повторите --install-menu."
) from exc
sendto_dir.mkdir(parents=True, exist_ok=True)
cmd_path.write_bytes(encoded_content)
return cmd_path
def uninstall_menu() -> Path | None:
"""Удаляет Transcribe.cmd из папки SendTo, если он существует."""
cmd_path = get_sendto_dir() / CMD_NAME
if not cmd_path.exists():
return None
cmd_path.unlink()
return cmd_path
+97
View File
@@ -921,3 +921,100 @@ def test_cli_threads_negative_rejected(tmp_path):
audio.write_bytes(b"fake") audio.write_bytes(b"fake")
out = runner.invoke(app, [str(audio), "--threads", "-1"]) out = runner.invoke(app, [str(audio), "--threads", "-1"])
assert out.exit_code != 0 assert out.exit_code != 0
# === SendTo context menu flags ===
def test_cli_install_menu_success(tmp_path):
cmd_path = tmp_path / "Transcribe.cmd"
with (
patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install,
patch("local_transcriber.cli.load_config") as mock_load_config,
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
out = runner.invoke(app, ["--install-menu"])
assert out.exit_code == 0
assert "Пункт меню установлен" in out.output
assert cmd_path.name in out.output
mock_install.assert_called_once_with()
mock_load_config.assert_not_called()
def test_cli_uninstall_menu_success(tmp_path):
cmd_path = tmp_path / "Transcribe.cmd"
with (
patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall,
patch("local_transcriber.cli.load_config") as mock_load_config,
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
out = runner.invoke(app, ["--uninstall-menu"])
assert out.exit_code == 0
assert "Пункт меню удалён" in out.output
assert cmd_path.name in out.output
mock_uninstall.assert_called_once_with()
mock_load_config.assert_not_called()
def test_cli_uninstall_menu_missing_is_success():
with (
patch("local_transcriber.cli.uninstall_context_menu", return_value=None),
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
out = runner.invoke(app, ["--uninstall-menu"])
assert out.exit_code == 0
assert "не был установлен" in out.output
def test_cli_menu_flags_are_mutually_exclusive():
out = runner.invoke(app, ["--install-menu", "--uninstall-menu"])
assert out.exit_code == 2
assert "несовместимы" in out.output
def test_cli_menu_flag_with_file_is_rejected(tmp_path):
audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake")
out = runner.invoke(app, [str(audio), "--install-menu"])
assert out.exit_code == 2
assert "нельзя использовать вместе с файлами" in out.output
def test_cli_no_files_and_no_menu_flags_is_rejected():
out = runner.invoke(app, [])
assert out.exit_code == 2
assert "Укажите хотя бы один файл" in out.output
def test_cli_menu_flags_available_only_on_windows():
with patch("local_transcriber.cli.sys") as mock_sys:
mock_sys.platform = "linux"
out = runner.invoke(app, ["--install-menu"])
assert out.exit_code == 1
assert "только на Windows" in out.output
def test_cli_menu_runtime_error_has_no_verbose_hint():
with (
patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")),
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
out = runner.invoke(app, ["--install-menu"])
assert out.exit_code == 1
assert "нет APPDATA" in out.output
assert "--verbose" not in out.output
+128
View File
@@ -0,0 +1,128 @@
import sys
from pathlib import Path
import pytest
from local_transcriber import context_menu
def _prepare_exe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
scripts_dir = tmp_path / "venv" / "Scripts"
scripts_dir.mkdir(parents=True)
python_exe = scripts_dir / "python.exe"
transcribe_exe = scripts_dir / "transcribe.exe"
python_exe.write_bytes(b"")
transcribe_exe.write_bytes(b"")
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
return transcribe_exe
def test_install_menu_creates_expected_cmd(tmp_path, monkeypatch):
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
transcribe_exe = _prepare_exe(tmp_path, monkeypatch)
cmd_path = context_menu.install_menu()
assert cmd_path.name == "Transcribe.cmd"
assert cmd_path.exists()
assert cmd_path.read_bytes() == (
f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("utf-8")
)
assert b"chcp" not in cmd_path.read_bytes().lower()
@pytest.mark.skipif(sys.platform != "win32", reason="Кодировка oem доступна только на Windows")
def test_install_menu_writes_real_oem_encoding_on_windows(tmp_path, monkeypatch):
appdata = tmp_path / "AppData" / "Roaming"
scripts_dir = tmp_path / "проект" / "Scripts"
scripts_dir.mkdir(parents=True)
python_exe = scripts_dir / "python.exe"
transcribe_exe = scripts_dir / "transcribe.exe"
python_exe.write_bytes(b"")
transcribe_exe.write_bytes(b"")
monkeypatch.setenv("APPDATA", str(appdata))
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
cmd_path = context_menu.install_menu()
assert context_menu.CMD_ENCODING == "oem"
assert cmd_path.read_bytes() == (
f'@echo off\r\n"{transcribe_exe}" %*\r\npause\r\n'.encode("oem")
)
def test_install_menu_overwrites_existing_file(tmp_path, monkeypatch):
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
_prepare_exe(tmp_path, monkeypatch)
cmd_path = context_menu.install_menu()
cmd_path.write_text("old", encoding="utf-8")
second_path = context_menu.install_menu()
assert second_path == cmd_path
assert "old" not in cmd_path.read_text(encoding="utf-8")
assert "%*" in cmd_path.read_text(encoding="utf-8")
def test_install_menu_creates_missing_sendto_dir(tmp_path, monkeypatch):
appdata = tmp_path / "AppData" / "Roaming"
monkeypatch.setenv("APPDATA", str(appdata))
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
_prepare_exe(tmp_path, monkeypatch)
cmd_path = context_menu.install_menu()
assert cmd_path.parent == appdata / "Microsoft" / "Windows" / "SendTo"
assert cmd_path.parent.is_dir()
def test_install_menu_oem_encoding_error_is_runtime_error(tmp_path, monkeypatch):
appdata = tmp_path / "AppData" / "Roaming"
cmd_path = appdata / "Microsoft" / "Windows" / "SendTo" / "Transcribe.cmd"
monkeypatch.setenv("APPDATA", str(appdata))
monkeypatch.setattr(context_menu, "CMD_ENCODING", "ascii")
monkeypatch.setattr(
context_menu,
"get_transcribe_exe",
lambda: tmp_path / "测试" / "transcribe.exe",
)
with pytest.raises(RuntimeError, match="OEM"):
context_menu.install_menu()
assert not cmd_path.exists()
def test_uninstall_menu_removes_file_and_missing_is_not_error(tmp_path, monkeypatch):
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
monkeypatch.setattr(context_menu, "CMD_ENCODING", "utf-8")
_prepare_exe(tmp_path, monkeypatch)
cmd_path = context_menu.install_menu()
removed_path = context_menu.uninstall_menu()
missing_path = context_menu.uninstall_menu()
assert removed_path == cmd_path
assert not cmd_path.exists()
assert missing_path is None
def test_get_sendto_dir_requires_appdata(monkeypatch):
monkeypatch.delenv("APPDATA", raising=False)
with pytest.raises(RuntimeError, match="APPDATA"):
context_menu.get_sendto_dir()
def test_get_transcribe_exe_requires_existing_exe(tmp_path, monkeypatch):
scripts_dir = tmp_path / "venv" / "Scripts"
scripts_dir.mkdir(parents=True)
python_exe = scripts_dir / "python.exe"
python_exe.write_bytes(b"")
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
with pytest.raises(RuntimeError, match="uv sync"):
context_menu.get_transcribe_exe()