diff --git a/docs/specs/README.md b/docs/specs/README.md index 203e208..7011a1a 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -15,7 +15,9 @@ ## Записи - [architecture.md](architecture.md) — общее устройство: компоненты, - поток, машина состояний, хранилище, конфигурация. + транспорты, хранилище, раскладка, деплой. +- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний, + переходы, сопоставление состояний qBittorrent. - [recognition.md](recognition.md) — распознавание контента и модель уверенности. - [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 4b610f9..1122649 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -39,54 +39,18 @@ qBittorrent, определяет содержимое (фильм или сер | `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) | | `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo | | `store` | SQLite: загрузки, распознавание, подсказки, ссылки | -| `httpapi` | REST + веб-UI (server-rendered, htmx) | +| `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) | | `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги | | `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) | | `config` | загрузка TOML-конфига | ## Поток и машина состояний -``` -ingest → downloading → completed → recognizing ──┬─ авто ────────────────→ linking → done - │ │ │ └─ review ⇄ recognizing ─→ linking → done - │ │ └─ moving/checking (ещё не готов) - │ └─ stuck (не качается дольше таймаута) - └─ failed ⇄ retry - -done → undo → reverted -reverted/cancelled → «Привязать заново» → recognizing (ручная перепривязка, всегда через review) -review → «Позже» → deferred → review -любой → «Отклонить» → cancelled -``` - -- **ingest** — приняли источник + контекст, отдали в qBittorrent - (категория `jellybit`), записали в БД с ключом идемпотентности. -- **downloading / completed** — `worker` поллит qBittorrent по категории - (`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте - (не `moving`/`checking*`), см. «Завершение в qBittorrent». -- **recognizing** — `recognize` строит план и оценку уверенности - ([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM → - review (не failed). -- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл - `review ⇄ recognizing` — перераспознавание по подсказке. -- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. -- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin (опц., - см. «Пересканирование Jellyfin»); доступен **undo** → `reverted` (убрать - созданные ссылки). -- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить», - ошибка (ретраибельна), не качается дольше таймаута. -- **reverted / cancelled → recognizing** — «Привязать заново»: после отката - или отклонения можно перезапустить распознавание для той же раздачи. - Перепривязка всегда идёт через review с ручным подтверждением (авто-раскладку - не делаем), и требует, чтобы раздача всё ещё была в qBittorrent. -- **review → recognizing** — кроме «Уточнить» (подсказка + перераспознавание) - есть «Распознать заново»: повторный прогон распознавания без новой подсказки, - по уже накопленному контексту и подсказкам. - -Все переходы и команды идут через `worker` под per-download блокировкой — -два транспорта не гонятся за одно состояние. Состояние персистентно в -SQLite; на старте `worker` сверяет категорию qBittorrent с БД и -продолжает. +Жизненный цикл загрузки (ingest → downloading → … → done/reverted), +полный граф состояний с переходами и сопоставление состояний qBittorrent — +в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами +владеет `worker`, он же сериализует команды транспортов под per-download +блокировкой, а состояние персистентно в SQLite. ## Транспорты @@ -115,8 +79,10 @@ SQLite. Схема покрывает приём, цикл ревью и отк `idempotency_key`, состояние, `error_code`/`error_msg`, тайминги. (infohash может появиться позже приёма — для magnet без метаданных.) - `recognition` — попытки распознавания: `download_id`, `attempt_no`, - `is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`), - `provider_id`, `confidence`, причины-не-авто, сырой ответ LLM. + `is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`), + `provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и + структурированный `plan` (каноничный JSON `recognize.Plan` — файл → + роль/сезон/серия для превью и применения). - `hint` — накопленные подсказки человека (`download_id`, текст, время). - `override` — запиненные ручные правки полей (перераспознавание не затирает). @@ -139,82 +105,19 @@ qBittorrent. Идемпотентность — **только для актив ## Конфигурация -TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный -`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar -(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**, -владелец `1000:1000`, не коммитится. Пример: +TOML. Полный список параметров с комментариями — в +[`config.example.toml`](../../config.example.toml) (источник истины, не +дублируем его здесь). Реальный `config.toml` рендерится при деплое +Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под +ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится. -```toml -[qbittorrent] -url = "http://qbit:8989" # по имени сервиса в общей docker-сети -username = "admin" -password = "" -category = "jellybit" -savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении) -# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично, -# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся: -# самый длинный совпавший префикс save_path (ключ) меняется на хост-префикс -# (значение), совпадение — по границам сегментов. Напр. {"/data" = "/srv/media"}. -path_map = {} - -[paths] -# хост-пути (видны внутри контейнера через mount /srv/media) -downloads = "/srv/media/downloads" -movies = "/srv/media/movies" -series = "/srv/media/series" - -[llm] -# type — дискриминатор реализации; пока поддерживается "openai-compat" -type = "openai-compat" -# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal, -# не 127.0.0.1; либо вынести LLM в контейнер общей сети. -base_url = "http://host.docker.internal:1234/v1" -api_key = "" -model = "qwen2.5-32b-instruct" -proxy = "" # опц. HTTP-прокси (для удалённых эндпоинтов) -timeout = "120s" -max_retries = 3 # непарсящийся ответ после ретраев → review - -[metadata.tmdb] -enabled = false # включается ключом; без матча авто не делаем -api_key = "" -proxy = "" # опц. HTTP-прокси для доступа к базе -timeout = "10s" - -[metadata.tvdb] -enabled = false -api_key = "" -proxy = "" -timeout = "10s" - -[jellyfin] -enabled = false # включить пересканирование медиатеки после раскладки -url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети -api_key = "" # API-ключ Jellyfin (Dashboard → API Keys) -proxy = "" # опц. HTTP-прокси -timeout = "10s" - -[worker] -poll_interval = "5s" # как часто опрашивать qBittorrent -stuck_after = "1h" # не качается дольше → stuck -magnet_timeout = "30m" # magnet без метаданных дольше → failed - -[recognition] -auto_confidence_threshold = 0.85 - -[telegram] -enabled = false -token = "" -allowed_user_ids = [] # пусто = запрет всем (fail-closed) - -[http] -listen = ":8080" -trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN) - -[log] -level = "info" -format = "json" -``` +Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull), +`[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]` +(провайдер распознавания, см. [recognition.md](recognition.md)), +`[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц. +пересканирование), `[worker]` (интервал поллинга и таймауты, см. +[workflow.md](workflow.md)), `[recognition]` (порог уверенности), +`[telegram]`, `[http]`, `[log]`. ## Логирование @@ -222,26 +125,6 @@ format = "json" Каждая загрузка проходит со сквозным идентификатором; решения распознавания (почему авто/ревью) и операции с файлами логируются явно. -## Завершение в qBittorrent - -`worker` опрашивает qBittorrent по категории и сопоставляет состояния: - -- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`queuedUP`/ - `forcedUP` — и **только** когда нет `moving`/`checkingUP`. -- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`queuedDL`/ - `checkingDL`/`forcedDL`. -- **застряло:** `metaDL` дольше `magnet_timeout`, `stalledDL` дольше - `stuck_after` → `stuck`/`failed`. -- **ошибка:** `error`/`missingFiles` → `failed`. - -Пути файлов берём из API (`save_path` + относительные имена из -`/torrents/files`, уже включающие корневую папку торрента), не из -константы (обычно это уже хост-путь). «Incomplete»-каталог в -qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы -там, по завершении qBit переносит их в `/srv/media/downloads` (состояние -`moving` — дожидаемся окончания переноса и только потом берём финальный -путь). - ## Раскладка файлов `layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям @@ -259,8 +142,13 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила: доводит начатое (идемпотентно) либо откатывается. - **Undo** удаляет только ссылки своего `apply_batch_id` и только если путь под `paths.movies`/`series` — источник недосягаем. -- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV` — - падаем с понятной ошибкой; по построению этого не должно случаться. +- **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и + цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит. + Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС + (`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а + копирует файл (через временный файл + атомарный `rename`) и пишет в лог + `Warn` (статус ссылки — `copied`): задача доходит до конца ценой + дублирования места. Источник при этом всё равно не трогаем. ### Пути и контейнеры — единая песочница `/srv/media` diff --git a/docs/specs/jellyfin-layout.md b/docs/specs/jellyfin-layout.md index d3135e0..9916005 100644 --- a/docs/specs/jellyfin-layout.md +++ b/docs/specs/jellyfin-layout.md @@ -51,8 +51,11 @@ inode общий — диск не дублируется. же inode → готово; другой файл → коллизия → review). Инварианты и undo — в [architecture.md](architecture.md) → «Раскладка файлов». -Требование: целевой и исходный каталоги — на одной ФС/одном mount'е -(внутри контейнера это обеспечивает единая песочница `/srv/media`). +Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е +(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда +работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без +поддержки жёстких ссылок), `layout` не падает, а копирует файл с +предупреждением в лог — см. architecture.md → «Раскладка файлов». ## Крайние случаи diff --git a/docs/specs/recognition.md b/docs/specs/recognition.md index 472348c..5b8999b 100644 --- a/docs/specs/recognition.md +++ b/docs/specs/recognition.md @@ -5,7 +5,8 @@ По доступным сигналам определить: фильм или сериал; каноническое название и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых базах — провайдер и его id. На выходе — план раскладки, оценка уверенности -и решение «авто или review». +и решение «авто или review» (как оно встраивается в машину состояний — +[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`). ## Сигналы @@ -33,8 +34,10 @@ пред-парс, возвращает структурированный план в нашей схеме. Хорошо берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под контекст модели. -3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году, - берём официальный id и каноническое имя, собираем кандидатов. +3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по + названию+году, берём официальный id и каноническое имя, собираем + кандидатов. TVMaze — без ключа, только сериалы; внешний id + (TVDB/IMDb) из `externals` идёт в имя папки. 4. **Оценка уверенности** и решение: авто или review. ## Структура ответа LLM (предварительная) @@ -54,8 +57,8 @@ notes пояснения, неоднозначности Сезон/серия — **на файле**: так выражаются мультисезонные паки, спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет. `provider_hint` — только подсказка для поиска; итоговые `provider` -(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и -хранятся отдельно. +(`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой +и хранятся отдельно. ## Провайдер LLM @@ -67,13 +70,13 @@ notes пояснения, неоднозначности Chat Completions API (`base_url` + `api_key` + `model`). Подходят локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые провайдеры (DeepSeek, Qwen и др.). -- **Структурированный вывод надёжно:** просим JSON по схеме - (`response_format` со схемой где поддерживается; иначе json-режим или - tool-call); на приёме срезаем ```-ограждения и извлекаем JSON, - **валидируем в Go**, ретраим со схемой-в-промпте до `llm.max_retries`; - если так и не распарсилось — уходим в **review** (не в `failed`) с - причиной «ответ LLM не разобран». Серверы заметно различаются по - поддержке строгих схем, особенно мелкие локальные модели. +- **Структурированный вывод надёжно:** просим JSON-режим + (`response_format: {"type":"json_object"}`) — это поддерживают и мелкие + локальные модели, в отличие от строгих JSON Schema. На приёме срезаем + ```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы; + при ошибке разбора ретраим, передавая модели саму ошибку и схему в + промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в + **review** (не в `failed`) с причиной «ответ LLM не разобран». - Новые типы (напр. нативный `anthropic`) добавляются, не трогая `recognize`. @@ -82,7 +85,7 @@ notes пояснения, неоднозначности Авто-раскладка — только если выполнено **всё**: 1. **Подтверждённый матч в базе** — единственный сильный результат - TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или + TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или база выключена) → всегда review.** Это и закрывает основной кейс (рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал». 2. **Структурная валидация** без предупреждений: diff --git a/docs/specs/review-ux.md b/docs/specs/review-ux.md index ea6088e..e627f82 100644 --- a/docs/specs/review-ux.md +++ b/docs/specs/review-ux.md @@ -2,7 +2,8 @@ Что происходит, когда система не уверена в распознавании и не раскладывает файлы автоматически. Когда именно наступает ревью — см. -[recognition.md](recognition.md); конвенции целевых имён — +[recognition.md](recognition.md); место состояния `review` в общем потоке — +[workflow.md](workflow.md); конвенции целевых имён — [jellyfin-layout.md](jellyfin-layout.md). Главный принцип: ревью — это **петля «догадка → подсказка человека → @@ -82,9 +83,13 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review - **🔁 Уточнить** → бот просит подсказку ответом → перераспознаёт → редактирует то же сообщение новым планом. Петля коррекции прямо в чате. -- **🔢 Выбрать в базе** → кнопки по кандидатам (название · год · id). -- Точечное переназначение файлов в чат не помещается → **🌐 Открыть в - вебе** (deep-link на ту же страницу). +- Точечное переназначение файлов и выбор кандидата базы в чат не + помещаются → **🌐 В вебе** (deep-link на ту же страницу, строится из + `telegram.web_base_url`). + +> Реально в боте сейчас: ✅ Применить, 📺↔🎬 Тип, 🔁 Уточнить, 🕗 Позже, +> 🌐 В вебе, ❌ Отклонить. Кнопки «🔢 Выбрать в базе» в чате пока нет — +> выбор кандидата и ручной ввод id делаются в вебе. ## Разделение труда @@ -122,7 +127,7 @@ Telegram = одобрить / подсказать / выбрать кандид - **«Позже»** паркует загрузку в `deferred` (вернётся в review по действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo** после применения → `reverted` (удаляет только ссылки своего батча, под - `media`). Полная карта состояний — в [architecture.md](architecture.md). + `media`). Полная карта состояний — в [workflow.md](workflow.md). - После отката или отклонения доступна **«Привязать заново»**: перезапускает распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и снова приводит в review — раскладка всегда требует ручного подтверждения, @@ -135,8 +140,12 @@ Telegram = одобрить / подсказать / выбрать кандид ## Объём по версиям -- **Ф3 (первая версия):** подсказка + перераспознавание; из ручного — - переключатель типа, выбор кандидата базы, пометка файла «игнор». Undo — - есть. -- **Ф5:** полный редактор маппинга «файл → серия», ручной режим, - подтверждение в Telegram с reply-подсказкой и эскалацией в веб. +- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать + заново», переключатель типа, выбор кандидата базы / ручной ввод id / + «без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже», + Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой + («Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб; + пинги о входе в review и готовности. +- **Ф5 (на будущее):** полный редактор маппинга «файл → серия» + (правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM, + выбор кандидата базы и ввод id прямо в Telegram. diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md new file mode 100644 index 0000000..c72a9a4 --- /dev/null +++ b/docs/specs/workflow.md @@ -0,0 +1,121 @@ +# Жизненный цикл загрузки и машина состояний + +Как загрузка проходит путь от приёма источника до разложенных файлов: +состояния, переходы и то, что их вызывает. Кто владеет переходами и общее +устройство — в [architecture.md](architecture.md); детали распознавания — +в [recognition.md](recognition.md); действия человека в ревью — в +[review-ux.md](review-ux.md). + +## Граф состояний + +```mermaid +stateDiagram-v2 + [*] --> downloading: ingest (источник отдан в qBittorrent) + + downloading --> completed: файлы на месте + downloading --> stuck: stalledDL дольше stuck_after + downloading --> failed: metaDL дольше magnet_timeout / error + + completed --> recognizing + + recognizing --> linking: авто (матч в базе + валидация) + recognizing --> review: нужно подтверждение / ответ LLM не разобран + + review --> linking: Применить + review --> recognizing: Уточнить / Распознать заново + review --> deferred: Позже + review --> cancelled: Отклонить + deferred --> review: любое действие (та же поверхность) + + linking --> done + linking --> review: коллизия цели + linking --> failed: ошибка ФС + + done --> reverted: Undo + + reverted --> recognizing: Привязать заново + cancelled --> recognizing: Привязать заново + + stuck --> downloading: Retry + failed --> downloading: Retry + + done --> [*] + cancelled --> [*] + reverted --> [*] + + note right of cancelled + «Отклонить» доступно из любого + нетерминального состояния + end note +``` + +Условно-терминальные состояния — `done`, `cancelled`, `failed`, +`reverted`: задача в них останавливается, но из `failed`/`stuck` есть +**Retry**, а из `reverted`/`cancelled` — **Привязать заново**. `stuck` +восстановимо ретраем. + +## Состояния и переходы + +- **ingest → downloading** — приняли источник + контекст, отдали в + qBittorrent (категория `qbittorrent.category`), записали в БД с ключом + идемпотентности. См. [architecture.md](architecture.md) → «Транспорты». +- **downloading / completed** — `worker` поллит qBittorrent + (`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте + (не `moving`/`checking*`), см. «Завершение в qBittorrent» ниже. +- **recognizing** — `recognize` строит план и оценку уверенности + ([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM → + review (не failed). +- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл + `review ⇄ recognizing` — перераспознавание по подсказке. «Уточнить» — + подсказка + перераспознавание; «Распознать заново» — повторный прогон + без новой подсказки, по уже накопленному контексту и подсказкам. +- **deferred** — «Позже» паркует задачу; принимает те же команды, что и + `review`, и возвращается в поверхность ревью по любому действию. +- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. Коллизия + цели возвращает в review, ошибка ФС → failed. См. + [architecture.md](architecture.md) → «Раскладка файлов». +- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin + (опц., см. [architecture.md](architecture.md) → «Пересканирование + Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки). +- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка + (ретраибельна); «Отклонить». +- **reverted / cancelled → recognizing** — «Привязать заново»: после + отката или отклонения можно перезапустить распознавание для той же + раздачи. Перепривязка всегда идёт через review с ручным подтверждением + (авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в + qBittorrent. + +Все переходы и команды идут через `worker` под per-download блокировкой — +два транспорта не гонятся за одно состояние. Состояние персистентно в +SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет** +раздачи с нашей категорией (`qbittorrent.category`) **или** тегом +(`qbittorrent.tag`), которых ещё нет в БД, заводя для них задачу в +состоянии `downloading`. Категория ставится на добавляемые нами раздачи +(push, задаёт savepath); тег позволяет подхватить уже существующую +раздачу, не трогая её категорию и файлы (pull). + +## Завершение в qBittorrent + +`worker` опрашивает qBittorrent и сопоставляет его состояния с нашими: + +- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/ + `queuedUP`/`forcedUP` (имена `paused*`/`stopped*` различаются между qBit + v4 и v5 — поддержаны оба). +- **переходное, ждём:** `moving`/`checkingUP`/`checkingResumeData`/ + `allocating` — остаёмся в `downloading`, пока qBit не закончит перенос/ + проверку (готовность не объявляем, даже если флаги «UP»). +- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/ + `queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`. +- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше + `magnet_timeout` → `failed`; `stalledDL` дольше `stuck_after` → `stuck` + (восстановимо ретраем). Возраст считаем от создания задачи. +- **ошибка:** `error`/`missingFiles` → `failed`. + +Пути файлов берём из API (`save_path` + относительные имена из +`/torrents/files`, уже включающие корневую папку торрента), не из +константы (обычно это уже хост-путь). «Incomplete»-каталог в +qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы +там, по завершении qBit переносит их в `/srv/media/downloads` (состояние +`moving` — дожидаемся окончания переноса и только потом берём финальный +путь). Подробнее о путях и песочнице — [architecture.md](architecture.md) +→ «Пути и контейнеры».