diff --git a/README.md b/README.md index d4f1cab..04e07c2 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,46 @@ for turn in r["turns"]: Заодно постобработка приводит типографику к принятому виду: все виды тире заменяются на дефис, кавычки отбиваются пробелом. +## Обновления + +Сервис проверяет новую версию при каждом запуске и обновляет только папку `app` - +это десятки килобайт. Python, библиотеки, ffmpeg и модели остаются на месте, +перекидывать весь пакет заново не нужно. + +Настройка в `config.toml`: + +```toml +[update] +enabled = true +server = "https://git.netranking.ru" +repo = "bryzgalov/talkscore-asr" +token = "" # токен Gitea с правом чтения; для публичного репозитория не нужен +``` + +Репозиторий приватный, поэтому токен обязателен. Создать его: Gitea → Settings → +Applications → Generate Token, достаточно права `read:repository`. + +Как это работает: + +1. `start.bat` перед запуском сервиса спрашивает у Gitea последний релиз +2. если версия там новее, скачивает `app-<версия>.zip` и сверяет контрольную сумму +3. откладывает текущий код, ставит новый и проверяет, что он импортируется +4. если проверка не прошла, возвращает предыдущую версию + +Нет сети или Gitea недоступен - сервис просто запускается на текущей версии. +`config.toml` и `replacements.txt` обновление не трогает: они ваши. +Чтобы выключить проверку совсем, поставьте `enabled = false`. + +### Выпуск новой версии + +```bash +uv run --with requests python build/release.py 0.2.0 -m "что изменилось" +``` + +Скрипт проставит версию в `app/version.py`, соберёт архив только из кода +и опубликует релиз с контрольной суммой. Целевая машина подхватит его +при следующем запуске. + ## Автозапуск Чтобы сервис поднимался при старте Windows, создайте задачу в планировщике: diff --git a/app/pipeline.py b/app/pipeline.py index be36dea..b8db12b 100644 --- a/app/pipeline.py +++ b/app/pipeline.py @@ -70,11 +70,24 @@ class Pipeline: self.models_dir = Path(models_dir) self.threads = threads self.replacements_path = Path(replacements_path) - self.ffmpeg = find_ffmpeg(Path(base_dir)) + self.base_dir = Path(base_dir) + self._ffmpeg: str | None = None self._asr = None self._diar_cache: dict[int, object] = {} self._replacements: dict[str, str] = {} + @property + def ffmpeg(self) -> str: + """Ищется при первом обращении, а не в конструкторе. + + Иначе отсутствие ffmpeg роняло бы сам импорт модуля - и вместо внятного + сообщения пользователь получал бы стек при старте, а обновление кода + не могло бы проверить работоспособность новой версии. + """ + if self._ffmpeg is None: + self._ffmpeg = find_ffmpeg(self.base_dir) + return self._ffmpeg + def warmup(self) -> None: """Грузит модели заранее, чтобы первый запрос не ждал.""" import onnx_asr diff --git a/app/updater.py b/app/updater.py index 505433d..f06dc12 100644 --- a/app/updater.py +++ b/app/updater.py @@ -9,6 +9,7 @@ Python, библиотеки, ffmpeg и модели остаются на ме """ import hashlib import io +import os import logging import shutil import subprocess @@ -72,11 +73,12 @@ def _safe_members(archive: zipfile.ZipFile) -> list[str]: def _default_verify(base: Path) -> bool: """Проверяет, что обновлённый код хотя бы импортируется.""" + env = dict(os.environ) + env.update({"PYTHONPATH": str(base), "TALKSCORE_ASR_HOME": str(base)}) try: result = subprocess.run( [sys.executable, "-c", "import app.main"], - cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, - env={"PYTHONPATH": str(base), "PATH": "", "TALKSCORE_ASR_HOME": str(base)}, + cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env, ) if result.returncode != 0: log.error("новая версия не импортируется: %s", @@ -194,7 +196,10 @@ def main() -> int: else: print(" Установлена последняя версия") except Exception as exc: # noqa: BLE001 - падение обновления не должно мешать работе - print(f" Обновиться не удалось ({type(exc).__name__}: {exc}), запускаюсь на текущей версии") + # Полный стек requests занимает пол-экрана и пугает, оставляем суть. + reason = str(exc).split("(Caused by")[0].strip()[:160] or type(exc).__name__ + print(f" Обновиться не удалось: {reason}") + print(" Запускаюсь на текущей версии") return 0 diff --git a/app/version.py b/app/version.py index 3dc1f76..b3f4756 100644 --- a/app/version.py +++ b/app/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.2" diff --git a/tests/test_api.py b/tests/test_api.py index ce82a2c..9db85bb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -30,7 +30,8 @@ def client(tmp_path, monkeypatch): import app.config as cfg monkeypatch.setattr(cfg, "BASE_DIR", tmp_path) import app.main as main - monkeypatch.setattr(main.pipeline, "ffmpeg", "/bin/true", raising=False) + # ffmpeg ищется лениво, поэтому подменяем внутреннее поле, а не свойство + monkeypatch.setattr(main.pipeline, "_ffmpeg", "/bin/true", raising=False) main._state["ready"] = True main._worker_stop.set() # воркер не должен разбирать очередь в тестах diff --git a/tests/test_updater.py b/tests/test_updater.py index 3ccdb77..af838bd 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -126,3 +126,35 @@ class TestInstall: z.writestr("app/main.py", "код") with pytest.raises(UpdateError, match="путь"): install_update(buf.getvalue(), base, verify=lambda _: True) + + +class TestPipelineImportSafety: + def test_pipeline_constructs_without_ffmpeg(self, tmp_path, monkeypatch): + """Отсутствие ffmpeg не должно ронять импорт: иначе непонятно, что чинить.""" + import sys + import types + + for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"): + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) + monkeypatch.setattr("shutil.which", lambda _: None) + + from app.pipeline import Pipeline + + p = Pipeline(models_dir=tmp_path, threads=1, + replacements_path=tmp_path / "r.txt", base_dir=tmp_path) + assert p is not None # конструктор не упал + + def test_ffmpeg_lookup_fails_only_when_used(self, tmp_path, monkeypatch): + import sys + import types + + for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"): + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) + monkeypatch.setattr("shutil.which", lambda _: None) + + from app.pipeline import ModelsMissing, Pipeline + + p = Pipeline(models_dir=tmp_path, threads=1, + replacements_path=tmp_path / "r.txt", base_dir=tmp_path) + with pytest.raises(ModelsMissing, match="ffmpeg"): + _ = p.ffmpeg