docs: перевод документации на канон av-dev
- Раскладка docs/ приведена к канону 2: заведены passport/architecture/ database/security/review и research; docs/specs, drafts, backlog, review/ и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи, 6 целей, слаги на английский). - Нарративы specs удалены как дубли openspec-спек после поимённой сверки; остаток заведён задачами (редактор маппинга ревью, крайние случаи именования), отказ от сущности title промоутнут в ADR. - Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon вместо er-schema.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"canon": 2,
|
||||
"migrations": "internal/store/migrations"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# Документация jellybit
|
||||
|
||||
Три раздела с разной ролью — не путать:
|
||||
|
||||
- **[specs/](specs/)** — спецификации. Описывают **целевое и текущее**
|
||||
устройство системы. Живые и изменяемые: правим по мере развития,
|
||||
держим в соответствии с кодом. Отвечают на вопрос «как устроено».
|
||||
|
||||
- **[adr/](adr/)** — Architecture Decision Records. **Неизменяемый**
|
||||
журнал значимых решений, пишется **постфактум**. Хранит главное —
|
||||
*почему* так сделано. Передумали → не правим старую запись, заводим
|
||||
новую. Процесс — в [adr/README.md](adr/README.md).
|
||||
|
||||
- **[drafts/](drafts/)** — черновики: заметки, мысли, планы на будущее,
|
||||
ещё не принятые решения. Не источник истины и ни к чему не обязывают.
|
||||
Когда черновик становится реальностью — его место в specs (как
|
||||
устроено) и/или adr (почему решили).
|
||||
|
||||
Рядом лежат ещё два прикладных раздела: **[conventions/](conventions/)** —
|
||||
как мы пишем код (то, что не выражается правилом линтера), и
|
||||
**[review/](review/journal.md)** — журнал дефектов, проскочивших ревью:
|
||||
эвал-сет для калибровки конвейера
|
||||
[review-pipeline](../.claude/skills/review-pipeline/SKILL.md).
|
||||
@@ -1,6 +1,6 @@
|
||||
# Авто-раскладка только при подтверждённом матче в метабазе
|
||||
|
||||
- Дата: 2026-06-13
|
||||
- **Дата:** 2026-06-13
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -21,7 +21,7 @@ jellybit распознаёт содержимое релиза через LLM
|
||||
каноническое имя + `provider_id`. Но русские релизы и аниме часто в них
|
||||
отсутствуют.
|
||||
- Безопасность раскладки уже держится на валидации пути, не на промпте
|
||||
(см. [recognition.md](../specs/recognition.md)); решение «авто vs review» —
|
||||
(см. [recognition](../../openspec/specs/recognition/spec.md)); решение «авто vs review» —
|
||||
второй слой защиты, на уровне доверия результату.
|
||||
|
||||
## Рассмотренные варианты
|
||||
@@ -53,8 +53,8 @@ LLM не противоречат по типу/названию/году. Не
|
||||
подтверждает) и убирает целый класс тихих ошибок «модель уверенно
|
||||
ошиблась». Review здесь — не наказание, а штатный режим для всего, что
|
||||
база не подтвердила (петля «догадка → подсказка → перераспознавание», см.
|
||||
[review-ux.md](../specs/review-ux.md)). Полная модель уверенности — в
|
||||
[recognition.md](../specs/recognition.md).
|
||||
[review](../../openspec/specs/review/spec.md)). Полная модель уверенности — в
|
||||
[recognition](../../openspec/specs/recognition/spec.md).
|
||||
|
||||
## Последствия
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Docker как единица деплоя, образ собирается на сервере
|
||||
|
||||
- Дата: 2026-06-13
|
||||
- Статус: заменено на ADR-2026-07-24-local-image-build
|
||||
- **Дата:** 2026-06-13
|
||||
- **Статус:** заменено на ADR-2026-07-24-local-image-build
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Go и доставка одним бинарём
|
||||
|
||||
- Дата: 2026-06-13
|
||||
- **Дата:** 2026-06-13
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Хардлинки вместо копирования и симлинков
|
||||
|
||||
- Дата: 2026-06-13
|
||||
- **Дата:** 2026-06-13
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Отдельную сущность «тайтл» не вводим
|
||||
|
||||
- **Дата:** 2026-07-02
|
||||
- **Источник:** черновик `docs/drafts/logical-title-model.md` (разбор от
|
||||
2026-07-01, переработан 2026-07-02; удалён при переводе проекта на канон,
|
||||
полный текст — в истории git). Первый производный change —
|
||||
[openspec/changes/archive/2026-07-02-ulid-identity/design.md](../../openspec/changes/archive/2026-07-02-ulid-identity/design.md).
|
||||
|
||||
## Решение
|
||||
|
||||
Логический тайтл (фильм или сериал, складывающийся из нескольких загрузок во
|
||||
времени) остаётся **вычисляемой группой**, а не хранимой сущностью: доменная
|
||||
идентичность — `download` (ULID + множество инфохэшей), связь с диском —
|
||||
`file_link` с владением целевым путём, а «второй сезон в ту же папку» решается
|
||||
**правилом сходимости папки** при построении плана раскладки.
|
||||
|
||||
## Почему
|
||||
|
||||
Разбор шёл от операций, и у тайтла их не нашлось:
|
||||
|
||||
> У `title` при разборе **не нашлось ни одной собственной операции**: сходимость
|
||||
> папки — правило при построении плана; merge докачивания — per-path логика;
|
||||
> удаление целиком — цикл по вычисляемой группе. Сущность без собственных
|
||||
> операций — это линза, а линзу достаточно вычислять, не хранить.
|
||||
|
||||
Второй аргумент — у папки уже есть дом, и вычисляемый якорь **корректнее**
|
||||
хранимого:
|
||||
|
||||
> «Папка — это title-уровневое состояние, ей нужен дом» разбивается о то, что
|
||||
> дом у папки уже есть — файловая система и `dst_path` живых `file_link`'ов.
|
||||
> Реестр дублировал бы то, что и так записано в БД в N экземплярах. Причём
|
||||
> вычисляемый якорь корректнее хранимого: если все файлы сериала снесли, живых
|
||||
> ссылок нет — и новая загрузка честно создаёт свежую папку; хранимый
|
||||
> `title.folder` указывал бы в пустоту.
|
||||
|
||||
Третий — отказ **устраняет**, а не решает хвост развилок: жизненный цикл тайтла
|
||||
(рождение, смерть, пустой тайтл), слияние тайтлов, ad-hoc тайтл без провайдера,
|
||||
обратная миграция существующих строк, отдельный title-лог.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **L2 — `title` с ключом `(provider, provider_id)`.** Отвергнут: привязывает
|
||||
долгоживущую сущность к провайдеру, который может смениться.
|
||||
- **L2-min — `title` со своим ULID + `title_external_id`** (провайдерные id
|
||||
множеством-атрибутом, симметрично `download_infohash`). Схема красивая и
|
||||
решает смену провайдера, ad-hoc тайтлы и слияние. Отвергнут именно по
|
||||
аргументу выше: собственных операций нет, а сущность тянет жизненный цикл,
|
||||
миграцию и четыре развилки.
|
||||
- **L3 — title-центричная медиатека (модель sonarr).** Отвергнут осознанно: мы
|
||||
не ходим в индексеры, не мониторим тайтлы и не ведём профили качества —
|
||||
контент приносит пользователь. Это граница домена,
|
||||
[passport.md](../passport.md) → «Что целью не является».
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Идентичность осталась одноуровневой: `download` — мост между раздачей в
|
||||
qBittorrent и файлами на диске, и каждая сущность цепочки отвечает на свои
|
||||
операции.
|
||||
- `+` Главная боль («второй сезон должен лечь в ту же папку») закрыта дешёвым
|
||||
правилом при построении плана — реализовано change'ем
|
||||
`2026-07-10-series-folder-convergence`, требования влиты в
|
||||
[openspec/specs/file-layout](../../openspec/specs/file-layout/spec.md).
|
||||
- `+` Устранён, а не отложен, хвост развилок вокруг жизненного цикла тайтла.
|
||||
- `−` Группировка тайтла в UI и «удалить тайтл целиком» придётся каждый раз
|
||||
**вычислять** по `(provider, provider_id)` и общей папке; дешёвого хранимого
|
||||
ключа для этого нет.
|
||||
- `−` Рассинхрон «несколько живых папок с одним `(provider, provider_id)`»
|
||||
разрешается только уходом в review — in-app лечения нет, чинится руками на
|
||||
диске.
|
||||
- `−` Слияние загрузок при перезаливе «той же вещи» осталось открытым: когда
|
||||
несколько инфохэшей считать одной загрузкой, а когда разными, — вопрос
|
||||
переехал в задачу про merge-раскладку.
|
||||
|
||||
## Триггер пересмотра
|
||||
|
||||
Записан отдельно, чтобы не гонять этот круг заново:
|
||||
|
||||
> Сущность `title` возвращается в обсуждение, только когда появится **операция
|
||||
> или состояние, которому реально негде жить** в `download` + `file_link` —
|
||||
> например, «переименовать сериал целиком с переносом ссылок» как регулярное
|
||||
> действие или заметки уровня группы. До того — вычисляем.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Конвейер ревью: гейт, generative-проходы и обязательный триаж
|
||||
|
||||
- Дата: 2026-07-23
|
||||
- **Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Образ собирается локально и едет на сервер через docker save/load
|
||||
|
||||
- Дата: 2026-07-24
|
||||
- **Дата:** 2026-07-24
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
+34
-47
@@ -1,64 +1,51 @@
|
||||
# Architecture Decision Records (ADR)
|
||||
# Журнал решений
|
||||
|
||||
Журнал значимых архитектурных решений по jellybit. Одна запись — одно
|
||||
решение. ADR пишем **постфактум**, когда решение принято и зафиксировано
|
||||
в коде/проекте: идеи и неподтверждённые планы живут в `docs/drafts`, а не
|
||||
в ADR. Записи **неизменяемы**: передумали → не правим старую, заводим
|
||||
новую и помечаем старую.
|
||||
Одна запись — одно решение. **ADR это промоут поверх архивного `design.md`**,
|
||||
а не второе сочинение: запись цитирует решение и ссылается на
|
||||
`openspec/changes/archive/<id>/design.md`.
|
||||
|
||||
Главная ценность записи — сохранить **почему**: намерение и причинность.
|
||||
Это важнее аккуратности оформления и полноты остальных секций.
|
||||
Главная ценность записи — сохранить **почему**: намерение и причинность. Это
|
||||
важнее аккуратности оформления и полноты остальных секций.
|
||||
|
||||
Формат и процесс унаследованы от соседнего проекта umbar.
|
||||
## Когда заводить
|
||||
|
||||
## Когда заводить ADR
|
||||
Верно одно из трёх:
|
||||
|
||||
- Выбор технологии или инструмента.
|
||||
- Структурные решения (хранилище, организация компонентов, протоколы).
|
||||
- Решения с долгосрочными последствиями или дорогим откатом.
|
||||
- **Намеренный отказ** от очевидного подхода — чтобы потом не
|
||||
переоткрывать «а почему мы не сделали X».
|
||||
<!-- копия: adr-когда-заводить из av-dev-pm/skills/canon/references/canon.md -->
|
||||
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
|
||||
- **намеренный отказ** от очевидного подхода;
|
||||
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
|
||||
«заменено на».
|
||||
<!-- /копия: adr-когда-заводить -->
|
||||
|
||||
Не заводить для рутины (бамп версии зависимости, добавление эндпоинта по
|
||||
накатанной схеме) и того, что и так видно из кода и git.
|
||||
Не заводить для рутины и для того, что видно из кода и `git log`.
|
||||
|
||||
## Соглашения
|
||||
|
||||
- **Имя файла = идентификатор:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`.
|
||||
Идентификатор — имя без `.md`. Slug — латиницей.
|
||||
- **Дата** — когда решение реально принято.
|
||||
- Несколько ADR за один день различаются по slug.
|
||||
- **Заголовок в файле:** `# Человеческий заголовок` (без даты и ID — они
|
||||
в имени файла и в строке «Дата»).
|
||||
- Секция **«Рассмотренные варианты» — опциональна**: оставляй её, только
|
||||
если альтернативы реально рассматривались.
|
||||
- Шаблон новой записи — [`template.md`](template.md).
|
||||
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, слаг английский, дата — когда решение
|
||||
реально принято. Идентификатор записи — имя файла без `.md`; несколько
|
||||
записей за один день различаются слагом.
|
||||
- **Заголовок в файле** — `# Человеческий заголовок`, без даты и id: они в
|
||||
имени файла и в поле меты.
|
||||
- Секция «Рассмотренные варианты» **опциональна**: оставляй, только если
|
||||
альтернативы реально рассматривались.
|
||||
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
|
||||
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
|
||||
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
|
||||
источником, а не абзацем в теле.
|
||||
- Замена: в новой записи — строка «Заменяет ADR-…», в старой — поле статуса,
|
||||
тело не трогаем (это часть истории), в таблице ниже правится статус.
|
||||
|
||||
## Статусы
|
||||
|
||||
Активная запись статуса **не имеет**. Статус появляется, только когда
|
||||
запись теряет силу, и значений всего два:
|
||||
|
||||
- `заменено на ADR-ГГГГ-ММ-ДД-slug` — решение пересмотрено новой ADR.
|
||||
- `устарело` — решение потеряло смысл и замены нет.
|
||||
|
||||
## Замена и устаревание
|
||||
|
||||
1. Заводим новую ADR; в её «Контексте» — строка
|
||||
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
|
||||
2. В старой ADR добавляем строку `- Статус: заменено на ADR-…` сразу под
|
||||
датой. Тело не трогаем — это часть истории.
|
||||
3. Обновляем статус старой записи в индексе ниже.
|
||||
|
||||
## Список записей
|
||||
## Записи
|
||||
|
||||
Новые сверху.
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| ---------- | ---------------------------------------------------------------- | ------ |
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-07-24 | [Локальная сборка образа + доставка docker save/load](ADR-2026-07-24-local-image-build.md) | — |
|
||||
| 2026-07-23 | [Конвейер ревью: гейт, generative-проходы и триаж](ADR-2026-07-23-review-pipeline-generative.md) | — |
|
||||
| 2026-07-02 | [Отдельную сущность «тайтл» не вводим](ADR-2026-07-02-no-title-entity.md) | — |
|
||||
| 2026-06-13 | [Авто-раскладка только при матче в метабазе](ADR-2026-06-13-auto-link-requires-db-match.md) | — |
|
||||
| 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | заменено на ADR-2026-07-24-local-image-build |
|
||||
| 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | заменено на ADR-2026-07-24-local-image-build |
|
||||
| 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — |
|
||||
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — |
|
||||
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — |
|
||||
|
||||
+20
-24
@@ -1,36 +1,32 @@
|
||||
# Краткий заголовок решения
|
||||
|
||||
- Дата: ГГГГ-ММ-ДД
|
||||
<!-- Строку статуса добавляют позже, только если запись потеряла силу:
|
||||
- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug
|
||||
- Статус: устарело
|
||||
У активной записи строки статуса нет. -->
|
||||
- **Дата:** ГГГГ-ММ-ДД
|
||||
- **Источник:** openspec/changes/archive/<id>/design.md
|
||||
|
||||
## Контекст
|
||||
<!-- Статус ставится тем же полем и только при пересмотре:
|
||||
- **Статус:** заменено на ADR-ГГГГ-ММ-ДД-slug
|
||||
- **Статус:** устарело
|
||||
У активной записи поля нет. -->
|
||||
|
||||
Что вынудило принять решение: проблема, силы и ограничения (ресурсы,
|
||||
стоимость, время на поддержку, существующая архитектура). Пиши так, чтобы
|
||||
через год было понятно «почему это вообще делалось» без чтения переписки.
|
||||
## Решение
|
||||
|
||||
Что именно решено — одной фразой.
|
||||
|
||||
## Почему
|
||||
|
||||
Намерение и причина. **Цитата из источника, а не пересказ.** Пиши так, чтобы
|
||||
через год было понятно без чтения переписки.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
<!-- Опциональная секция. Оставь, только если варианты реально
|
||||
рассматривались. Если решение было единственным очевидным — удали
|
||||
её, а причину объясни в «Решении». -->
|
||||
рассматривались. Если решение было единственным очевидным — удали её,
|
||||
а причину объясни в «Почему». -->
|
||||
|
||||
- **Вариант A** — суть, плюсы и минусы.
|
||||
- **Вариант B** — суть, плюсы и минусы.
|
||||
- **Вариант C** — если отвергнут сразу, коротко почему.
|
||||
|
||||
## Решение
|
||||
|
||||
Что именно сделано и — главное — **почему**: какое намерение и какая
|
||||
причина за этим стоят. Если варианты рассматривались — почему выбран
|
||||
этот, а не остальные.
|
||||
- **Вариант A** — суть, почему отвергнут.
|
||||
- **Вариант B** — суть, почему отвергнут.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` что стало лучше, какие возможности открылись.
|
||||
- `-` чем платим: новые ограничения, риски, регулярная нагрузка на
|
||||
поддержку.
|
||||
- Что нужно сделать как следствие (если есть).
|
||||
- `+` что стало лучше.
|
||||
- `−` чем платим: ограничения, риски, нагрузка на поддержку.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# Архитектура
|
||||
|
||||
Обзор: как сложено и где что работает. **Поведение системы здесь не
|
||||
описывается** — его нормативный дом `openspec/specs/`; ниже компоненты только
|
||||
ссылаются на свои capability. Инварианты и их severity — в
|
||||
[CLAUDE.md](../CLAUDE.md), схема хранилища — в [database.md](database.md),
|
||||
периметр — в [security.md](security.md).
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Один статический бинарь.** Доставка — образом с готовым бинарём внутри. См.
|
||||
[ADR-2026-06-13-go-single-binary](adr/ADR-2026-06-13-go-single-binary.md).
|
||||
- **Источник неприкосновенен.** Только `mkdir`, `link(2)` и `unlink` *своих*
|
||||
целевых ссылок. См. [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md).
|
||||
- **Выход распознавания недоверенный.** Безопасность держится на валидации
|
||||
целевого пути, а не на промпте — [security.md](security.md).
|
||||
- **Единое ядро, тонкие транспорты.** Логика приёма — в use-case `ingest`;
|
||||
переходами состояний владеет `worker`. HTTP API, веб-UI, Telegram и CLI лишь
|
||||
складывают команды, `worker` их сериализует.
|
||||
- **Опциональные внешние зависимости.** Метабазы (TMDB/TVDB/TVMaze) и триггер
|
||||
Jellyfin включаются конфигом; без них сервис работает, но авто-раскладка без
|
||||
подтверждённого матча не делается —
|
||||
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md).
|
||||
- **Минимум компонентов.** В духе umbar — без зоопарка сервисов.
|
||||
|
||||
## Компоненты
|
||||
|
||||
`cmd/jellybit` — точка входа и сборка зависимостей; всё остальное — `internal/*`.
|
||||
|
||||
| Пакет | Ответственность | Capability |
|
||||
| --- | --- | --- |
|
||||
| `ingest` | use-case приёма загрузки, общий для всех транспортов | [ingest](../openspec/specs/ingest/spec.md) |
|
||||
| `magnet`, `torrent` | разбор magnet-ссылки и байтов `.torrent`, извлечение инфохэшей | [ingest](../openspec/specs/ingest/spec.md) |
|
||||
| `worker` | владелец машины состояний: поллинг qBittorrent, сериализация команд, фоновая сверка | [download-tracking](../openspec/specs/download-tracking/spec.md), [state-reconciliation](../openspec/specs/state-reconciliation/spec.md), [live-status](../openspec/specs/live-status/spec.md) |
|
||||
| `qbt` | клиент qBittorrent WebUI API (сессия, добавление, опрос, удаление) | [download-tracking](../openspec/specs/download-tracking/spec.md) |
|
||||
| `recognize` | пред-парс имени, вызов LLM, разбор плана, модель уверенности | [recognition](../openspec/specs/recognition/spec.md) |
|
||||
| `llm` | провайдер LLM за интерфейсом (дискриминатор `[llm].type`) | [recognition](../openspec/specs/recognition/spec.md) |
|
||||
| `metadata` | интерфейс метабаз + TMDB/TVDB/TVMaze (опц.) | [metadata-match](../openspec/specs/metadata-match/spec.md) |
|
||||
| `naming` | единая логика целевых имён и отображаемого имени раздачи | [file-layout](../openspec/specs/file-layout/spec.md), [ingest](../openspec/specs/ingest/spec.md) |
|
||||
| `layout` | санитизация путей, хардлинкер, copy-fallback, undo, владение путём | [file-layout](../openspec/specs/file-layout/spec.md), [state-reconciliation](../openspec/specs/state-reconciliation/spec.md) |
|
||||
| `store` | SQLite: загрузки, распознавания, подсказки, кандидаты, ссылки | [identity](../openspec/specs/identity/spec.md) |
|
||||
| `ident` | генерация и нормализация ULID | [identity](../openspec/specs/identity/spec.md) |
|
||||
| `httpapi` | REST + веб-UI на htmx (server-rendered партиалы) | [web-ui](../openspec/specs/web-ui/spec.md), [review](../openspec/specs/review/spec.md) |
|
||||
| `tgbot` | Telegram: приём, парсер сообщений торрент-бота, карточки, пинги | [notifications](../openspec/specs/notifications/spec.md), [review](../openspec/specs/review/spec.md) |
|
||||
| `jellyfin` | триггер пересканирования медиатеки (опц.) | [file-layout](../openspec/specs/file-layout/spec.md) |
|
||||
| `config` | загрузка и валидация TOML на старте | — |
|
||||
| `logging`, `logctx` | slog-настройка и протяжка корреляции через контекст | [identity](../openspec/specs/identity/spec.md) |
|
||||
| `archrules` | собственный анализатор архитектурных правил (часть гейта) | — |
|
||||
|
||||
Транспорты приёма — HTTP API, веб-UI, Telegram и CLI (`jellybit add`) — ведут в
|
||||
один `ingest`; действия пользователя (apply / refine / reject / defer / undo /
|
||||
retry / delete / dismiss) идут командами к `worker`.
|
||||
|
||||
## Внешние границы и форматы
|
||||
|
||||
- **qBittorrent WebUI API** — единственный способ качать: источник (magnet, URL,
|
||||
`.torrent`) **отдаём ему**, сами по пользовательскому URL не ходим (SSRF
|
||||
исключён). Пути берём из API (`save_path` + относительные имена из
|
||||
`/torrents/files`), не из константы.
|
||||
- **LLM** — OpenAI-совместимый Chat Completions (`[llm].type = "openai-compat"`),
|
||||
структурированный вывод через `response_format: json_object`; валидация ответа
|
||||
своя, в Go.
|
||||
- **Метабазы** — TMDB, TVDB, TVMaze (последняя без ключа, только сериалы).
|
||||
- **Jellyfin** — один вызов `POST /Library/Refresh`, авторизация заголовком
|
||||
`X-Emby-Token`.
|
||||
- **Telegram Bot API** — приём сообщений и исходящие карточки/пинги.
|
||||
- **Сообщение торрент-бота** — чужой текстовый формат, разбирается парсером
|
||||
`tgbot`; наблюдения по формату — в
|
||||
[research/torrent-bot-message.md](research/torrent-bot-message.md).
|
||||
|
||||
## Эксплуатация
|
||||
|
||||
- **Где работает, что рядом, кто перезапускает:** домашний медиа-сервер umbar
|
||||
(Intel N150), docker в общей сети с qBittorrent и Jellyfin. Перезапускает
|
||||
docker по `restart`-политике и плейбук umbar при редеплое; человек — руками,
|
||||
когда всё плохо. Оператор один и он же владелец.
|
||||
- **Внешние зависимости поимённо и чем каждая отказывает:**
|
||||
|
||||
| Зависимость | Обязательна | Как отказывает |
|
||||
| --- | --- | --- |
|
||||
| qBittorrent | да | недоступен (весь цикл встаёт, тик поллинга краснеет); отдаёт раздачу без файлов; теряет раздачу (пропажа источника); переходные состояния `moving`/`checking*` выглядят как готовность |
|
||||
| LLM-эндпоинт | да | недоступен; отвечает медленно (минуты); отдаёт не-JSON или JSON не по схеме; отдаёт правдоподобную выдумку — самый неприятный случай, потому что молчаливый |
|
||||
| TMDB/TVDB/TVMaze | нет | недоступны; лимит запросов; пустой результат (норма для русского контента); несколько равнозначных кандидатов |
|
||||
| Jellyfin | нет | недоступен — скан просто не случится, состояние задачи не страдает |
|
||||
| Telegram Bot API | нет | недоступен — уведомление теряется, состояние задачи не страдает |
|
||||
| Диск `/srv/media` | да | переполнен (особенно на copy-fallback); ФС без хардлинков; файл исчез между проверкой и `link(2)` |
|
||||
| SQLite | да | `database is locked` при конкурентной записи; файл тома не смонтирован |
|
||||
|
||||
- **Кто заметит отказ и когда:** владелец — по отсутствию ожидаемого пинга и по
|
||||
задаче, застрявшей в промежуточном состоянии; логи в stdout контейнера.
|
||||
Автоматического алертинга нет, метрик нет — только уведомления в Telegram о
|
||||
падении загрузки и о рассинхроне.
|
||||
- **Характер потока:** непрерывный фон (тик поллинга qBittorrent, по умолчанию
|
||||
5 с, и периодическая сверка) плюс редкие события по запросу человека. Объём —
|
||||
единицы загрузок в день, десятки одновременно; ориентир масштаба и его аудит —
|
||||
задача в беклоге.
|
||||
|
||||
## Единые точки проекта
|
||||
|
||||
Материал для вопроса «не появился ли второй способ делать то, что уже делается».
|
||||
|
||||
| Что | Где |
|
||||
| --- | --- |
|
||||
| Время | `store.Now()` — единственный источник, всегда UTC; формат хранения — RFC 3339 |
|
||||
| Идентификаторы | `internal/ident` — генерация и нормализация ULID; `ident.Parse` на каждой входной границе |
|
||||
| Целевые имена и превью раскладки | `internal/naming` — одна логика для превью в UI и для реального применения |
|
||||
| Разбор источника | `internal/magnet` и `internal/torrent`; инфохэш извлекается только здесь |
|
||||
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
|
||||
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
|
||||
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки |
|
||||
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
|
||||
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
|
||||
| Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям |
|
||||
|
||||
## Деплой
|
||||
|
||||
Работает в docker в одной среде с qBittorrent и Jellyfin — см.
|
||||
[ADR-2026-07-24-local-image-build](adr/ADR-2026-07-24-local-image-build.md).
|
||||
|
||||
Сборка: статический бинарь (`GOOS=linux GOARCH=amd64 CGO_ENABLED=0`) и **полный
|
||||
образ** собираются локально на control-хосте (`task image` упаковывает бинарь в
|
||||
`distroless/static`). Образ едет на сервер через `docker save`/`load` (роль
|
||||
`app_image` в umbar), там и запускается. Go-тулчейн и `docker build` на сервере
|
||||
не нужны.
|
||||
|
||||
Разделение ответственности: **jellybit** (этот репозиторий) даёт бинарь и
|
||||
`Dockerfile`; **umbar** — оркестрацию (доставка, docker compose,
|
||||
`playbook-jellybit.yml`, рендер секретов).
|
||||
|
||||
Параметры запуска:
|
||||
|
||||
- **Общая docker-сеть** (external, напр. `media-net`) — адресация по именам
|
||||
(`http://qbit:8989`, `http://jellyfin:8096`). Веб-UI публикуется на хост
|
||||
(`8080:8080`) для LAN. qBit валидирует Host-заголовок — в umbar выставлен
|
||||
`WebUI\ServerDomains=*`; LLM на хосте достаётся через `host.docker.internal`.
|
||||
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь umbar.
|
||||
- **mount `/srv/media`** — единая песочница (см. ниже).
|
||||
- **mount конфига** `/srv/applications/jellybit/config` → `/config` (ro),
|
||||
`config.toml` с правами `0600`; рендерится плейбуком umbar, бекапу не подлежит.
|
||||
- **mount данных** `/srv/applications/jellybit/data` → `/data`, SQLite
|
||||
`/data/jellybit.db`. **Бекапить обязательно** — без него редеплой стирает всё
|
||||
in-flight состояние.
|
||||
- **healthcheck** зовёт сам бинарь (`jellybit healthcheck`): в distroless нет
|
||||
shell и curl.
|
||||
|
||||
### Единая песочница `/srv/media`
|
||||
|
||||
Весь медиа-стек лежит под одним каталогом и монтируется **идентично**
|
||||
(`/srv/media:/srv/media`) во все медиа-приложения:
|
||||
|
||||
```
|
||||
/srv/media/
|
||||
incomplete/ ← qBit качает сюда
|
||||
downloads/ ← готовые раздачи (источник хардлинка)
|
||||
movies/ series/ ← библиотека Jellyfin (цель хардлинка)
|
||||
```
|
||||
|
||||
Так как всё под одним mount'ом, работают и **хардлинк** (downloads →
|
||||
movies/series), и **мгновенный move** qBit (incomplete → downloads) — границ
|
||||
между точками монтирования (`EXDEV`) нет. Путь из qBittorrent уже равен
|
||||
хост-пути, трансляция не нужна (`path_map` — фолбэк, обычно пуст). Секреты и
|
||||
чужие приложения (`/srv/applications`) в песочницу не попадают. Библиотеки
|
||||
Jellyfin указывают на `movies`/`series`, а не на корень — иначе в индекс попадут
|
||||
`downloads`/`incomplete`.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Пока нет. Решённое разъехалось по ADR и capability-спекам; то, что требует
|
||||
работы, живёт задачами в [tasks/BACKLOG.md](tasks/BACKLOG.md).
|
||||
@@ -1,13 +0,0 @@
|
||||
# Кладбище беклога
|
||||
|
||||
Задачи, покинувшие беклог **без реализации**: выкинутые, отменённые решением,
|
||||
слитые в другие. Причина отказа переживает саму задачу — иначе та же идея
|
||||
вернётся через квартал тем же текстом через инбокс Tududi.
|
||||
|
||||
Реализованные сюда **не** попадают: у них остаётся коммит, спека, ADR. Ведётся
|
||||
скиллом `backlog`; формат строки — в его `references/task-format.md`.
|
||||
|
||||
Запись здесь не запрещает завести задачу заново: изменился контекст — заводим и
|
||||
ссылаемся на строку кладбища, объясняя, что изменилось.
|
||||
|
||||
<!-- Формат: - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … -->
|
||||
@@ -1,58 +0,0 @@
|
||||
# Беклог
|
||||
|
||||
Единый список будущих задач по проекту: то, что уже решили сделать, и идеи,
|
||||
которые ещё надо обдумать. Это **источник истины по беклогу** — одна задача = один
|
||||
файл в этом каталоге. Не план реализации и не спецификация: принятое и
|
||||
реализованное переезжает в [`docs/specs`](../specs)/[`docs/adr`](../adr), а сам
|
||||
пункт беклога удаляется.
|
||||
|
||||
Приоритет — грубая оценка «ценность / стоимость», не обязательство к порядку.
|
||||
Спекулятивные пункты (ещё без решения «делаем») помечены префиксом `[idea]` в
|
||||
названии — их сперва надо проработать. Пункты, помеченные _(ревью 2026-07-08)_,
|
||||
пришли из тщательного ревью ingest/worker/жизненного цикла (см. общий тег в теле).
|
||||
|
||||
Tududi (проект `jellybit`) больше **не** держит беклог — он служит только
|
||||
инбоксом сырых идей. Прежде чем идея станет задачей, её оформляют файлом здесь.
|
||||
|
||||
## Высокий
|
||||
|
||||
- [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — повторная заливка сериала целиком должна доложить недостающие эпизоды merge-раскладкой, не трогая существующие ссылки — блокирует типовой сценарий свежих сериалов
|
||||
- [Ретеншн и очистка БД](retention-ochistka-bd.md) — терминальные задачи и сырые ответы LLM копятся вечно — без авточистки список загрузок и БД деградируют по мере эксплуатации
|
||||
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — смена модели или правка промпта распознавания сейчас вслепую — нет корпуса кейсов и метрики точности, регрессии не видно
|
||||
|
||||
## Средний
|
||||
|
||||
- [Словарь единого языка (ubiquitous language)](ubiquitous-language-slovar.md) — наименования домена расходятся между спеками, UI и кодом — нет единого глоссария (на нём же стоит агент-ревьювер наименований)
|
||||
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](agenty-revyuvery-kachestva.md) — Конвейер `review-pipeline` переработан (гейт, generative-проходы, триаж); осталась калибровка проходов и ревьювер наименований (ждёт словарь единого языка)
|
||||
- [[idea] Сила совпадения кандидата и пересмотр распознавания/матчинга](sila-sovpadeniya-kandidata.md) — у кандидата метабазы нет метрики силы совпадения — список кандидатов на ревью нечем отсортировать по уверенности (сперва проработать процесс матчинга)
|
||||
- [История переходов загрузки](istoriya-perehodov-zagruzki.md) — хранится только текущий статус загрузки — разбор «как сюда попали» идёт по логам сервера, нет таблицы истории переходов
|
||||
- [Привязка уведомлений к источнику в ботах (мульти-бот)](uvedomleniya-multi-bot.md) — пинги и ревью должен получать автор загрузки в своём транспорте — нет привязки загрузки к источнику/отправителю (нужно для мульти-бота)
|
||||
- [[idea] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — сложные раздачи (все сезоны разом, паки, смешанная нумерация) целостно не проработаны — распознавание/ревью/раскладка заточены под один сезон
|
||||
- [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — аниме со сквозной нумерацией (#137) не раскладывается в SxxEyy, который ждёт Jellyfin — нужен пересчёт абсолютной нумерации
|
||||
- [Бэкап SQLite](backup-sqlite.md) — architecture требует бекапить data-том, но стратегия не описана — сбой или редеплой стирают всё in-flight состояние (проще, пока БД маленькая)
|
||||
- [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис — недоступность qBittorrent/LLM/метабазы видна лишь по застрявшим задачам, нет readiness и бейджа в UI
|
||||
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
|
||||
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — правки человека (матч/тип/нумерация) не переиспользуются — few-shot из прошлых ревью поднял бы точность на «своих» трекерах без смены модели
|
||||
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
||||
- [`addReq` не пересобирается из свежего `source_type` перед `Add` (окно namer'а)](catched-source-type-namer-okno.md) — При апгрейде magnet→.torrent в окне namer'а добавится magnet из устаревшего снимка; самоисцеляется через magnet_timeout→failed→Retry _(аудит 2026-07-17)_
|
||||
|
||||
## Низкий
|
||||
|
||||
- [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
|
||||
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — живые обновления на htmx-поллинге дают задержку и холостые запросы — SSE убрал бы то и другое (поллинг работает, поэтому улучшение, не блокер)
|
||||
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
|
||||
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
|
||||
- [[idea] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — несколько проходов распознавания с консенсусом подняли бы точность ценой стоимости/латентности — проработать, когда включать и как мерджить расхождения
|
||||
- [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
|
||||
- [Фетч .torrent по URL — остаток «единого окна»](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
||||
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — раздача-образ диска (VIDEO_TS/BDMV) сейчас разбирается пофайлово вместо раскладки каталога целиком — редкий, но реальный случай
|
||||
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — copy-fallback при невозможности хардлинка может упереться в переполненный диск посреди раскладки — нет проверки места до копирования
|
||||
- [Кэш метабаз (и опционально LLM)](kesh-metabaz.md) — повторные и ретраящиеся прогоны бьют TMDB/TVDB/TVMaze одним запросом — кэш с TTL сэкономил бы лимиты и ускорил «Распознать заново»
|
||||
- [[idea] guessit как сервис-спутник](guessit-sputnik.md) — go-ptn слабее питоновского guessit — если точности пред-парса не хватит, завернуть guessit в сервис-спутник рядом с бинарём
|
||||
- [[idea] Завершение загрузки через webhook](webhook-zavershenie-zagruzki.md) — завершение сейчас ловим поллингом qBittorrent — webhook реагировал бы быстрее, но связывает нас с его конфигом (решим по опыту эксплуатации)
|
||||
- [Авторизация веб-UI (на будущее)](avtorizaciya-web-ui.md) — для v1 решено без авторизации (доверенная LAN, опц. allowlist подсетей) — задел на случай, если понадобится защита
|
||||
- [Современный Web-UI как PWA](web-ui-pwa.md) — текущий server-rendered UI функционален — PWA (устанавливаемое, удобное с телефона) это улучшение большого объёма, не блокер
|
||||
- [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](review-f4-f5-infohash-identity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
|
||||
- [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](review-ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
|
||||
- [Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`](dismiss-cancel-user-dismiss-marker.md) — Функционально ок (Cancel даёт cancelled), но маркер user_dismiss в error_code теряется; расхождение с буквой спеки _(аудит 2026-07-17)_
|
||||
@@ -1,43 +0,0 @@
|
||||
# Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Набор сабагентов-ревьюверов поверх ревью-процесса из CLAUDE.md. Развивает
|
||||
ревью-процесс OpenSpec в сторону воспроизводимых автопроверок, не заменяя
|
||||
человеческое ревью.
|
||||
|
||||
## Сделано (2026-07-10)
|
||||
|
||||
- Заведены два кастомных ревьювера в `.claude/agents/`: `jellybit-review-specs`
|
||||
(оптика спек/требований) и `jellybit-review-code` (архитектура, инварианты,
|
||||
конвенции, стиль, дублирование).
|
||||
- Оба подключены как чекпоинт в скилл `.claude/skills/task-pipeline`.
|
||||
|
||||
## Сделано (2026-07-23) — переработка конвейера
|
||||
|
||||
Конвейер пересобран по типу проходов, а не по ролям: скилл
|
||||
`.claude/skills/review-pipeline` (гейт → сверка со спекой в обе стороны →
|
||||
generative-проходы → архитектура → враждебные постановки → триаж), профили
|
||||
`quick`/`standard`/`deep`/`design`, контракт находок, границы покрытия,
|
||||
храповик «находка → конвенция → правило → удаление», журнал проскочивших
|
||||
дефектов и процедура калибровки. Подробности — ADR
|
||||
[ADR-2026-07-23-review-pipeline-generative](../adr/ADR-2026-07-23-review-pipeline-generative.md)
|
||||
и отчёт о миграции в `references/migration-2026-07.md` скилла.
|
||||
|
||||
Открытый вопрос «дробить ли `jellybit-review-code` на узкие оптики» закрыт:
|
||||
**не дробим** — декорреляция внимания без декорреляции суждения почти не
|
||||
добавляет recall, но линейно удорожает триаж.
|
||||
|
||||
## Осталось
|
||||
|
||||
- **Ревьювер наименований** (соответствие словарю единого языка) — отдельной
|
||||
оптикой не выделен: зависит от задачи «Словарь единого языка (ubiquitous
|
||||
language)», без глоссария проверять не по чему. Завести после неё.
|
||||
- **Калибровка проходов** по процедуре
|
||||
`.claude/skills/review-pipeline/references/calibration.md` — ни один проход
|
||||
ещё не замерен инъекцией. До замера ничего не удаляем и промпты не правим.
|
||||
- **Заполнить журнал** `docs/review/journal.md` случаями, которые уже
|
||||
проскочили ревью, — они станут первыми пробами калибровки.
|
||||
|
||||
Связано: CLAUDE.md (ревью-процесс, конвенции), docs/conventions, «Словарь
|
||||
единого языка», скиллы `review-pipeline`/`task-pipeline`/`task-batch`.
|
||||
+44
-24
@@ -1,33 +1,53 @@
|
||||
# Конвенции кода
|
||||
|
||||
Кросс-каттинг правила того, **как** мы пишем код (логирование, ошибки,
|
||||
именование) — в отличие от `docs/specs/` и `openspec/specs/`, которые
|
||||
описывают, **что** система делает.
|
||||
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
|
||||
система делает, и от [../architecture.md](../architecture.md), который
|
||||
описывает, как она сложена.
|
||||
|
||||
**Прозой здесь остаётся только то, что не выражается правилом.** Как только
|
||||
свойство удаётся проверить машиной, оно уезжает в `.golangci.yml` или в
|
||||
`internal/archrules`, а формулировка отсюда **удаляется** (остаётся пометка
|
||||
«механизировано» со ссылкой на линтер). Процедура — [промоут находка →
|
||||
конвенция → правило → удаление](../../.claude/skills/review-pipeline/references/promote.md).
|
||||
Причина: файл на несколько сотен строк размазывает внимание по тривиальному —
|
||||
и модель, и человек добросовестно проверят именование и не дойдут до формы
|
||||
решения.
|
||||
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
|
||||
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
|
||||
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
|
||||
размазывает внимание по тривиальному — и модель, и человек добросовестно
|
||||
проверят именование и не дойдут до формы решения. Процедура промоута —
|
||||
`references/promote.md` скилла `av-dev-pipeline:review-pipeline`.
|
||||
|
||||
Конвенции **не** переносятся в OpenSpec: это не capability. Короткие
|
||||
инварианты дублируются в [CLAUDE.md](../../CLAUDE.md) (агент читает его
|
||||
всегда) и кратко в `openspec/config.yaml` → `context` (подмешивается в
|
||||
генерацию артефактов); детали — здесь. Обоснование «почему» — в `docs/adr/`.
|
||||
Обоснование «почему именно так» живёт в [../adr/](../adr/README.md); инварианты
|
||||
с severity — в [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
## Записи
|
||||
|
||||
- [logging.md](logging.md) — логирование: уровни, поля, что не логируем.
|
||||
- [config.md](config.md) — конфигурация: TOML, секреты через деплой
|
||||
(Ansible+Vault), валидация на старте.
|
||||
- [logging.md](logging.md) — логирование: уровень по адресату, единственный
|
||||
логирующий чекпоинт, поля, `ext.*`, что не логируем.
|
||||
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`,
|
||||
трансляция на внешней границе.
|
||||
- [database.md](database.md) — БД и идентификаторы: TEXT ULID PK через
|
||||
`internal/ident` (без AUTOINCREMENT), lowercase + нормализация на границах,
|
||||
естественные ключи у деталей.
|
||||
трансляция доменной ошибки на внешней границе, sentinel против типизированной.
|
||||
- [config.md](config.md) — конфигурация: TOML, секреты рендерит деплой в файл
|
||||
`0600`, самодокументируемый `config.example.toml`, валидация на старте.
|
||||
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
|
||||
ULID через `internal/ident`, `ident.Parse` на входной границе, естественные
|
||||
ключи у деталей.
|
||||
- [web-ui.md](web-ui.md) — веб-UI на htmx: единый партиал = страница = фрагмент,
|
||||
ветвление `isHTMX`, деградация без JS, ошибка = 200 + фрагмент, самозавершающийся
|
||||
поллинг, вендоринг/кэш статики.
|
||||
ветвление по `isHTMX`, деградация без JS, ошибка на htmx-пути = 200 +
|
||||
фрагмент, самозавершающийся поллинг, вендоринг и кэш статики.
|
||||
|
||||
## Механизировано
|
||||
|
||||
Проверяется `task gate`; прозой не дублируется и в промптах ревью не
|
||||
пересказывается.
|
||||
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| `msg` лога — константная категория, данные в полях, единый стиль ключ-значение | `.golangci.yml` → `sloglint` (`static-msg`, `kv-only`, `no-mixed-args`) |
|
||||
| В stdout напрямую не пишем (`fmt.Print*`) | `.golangci.yml` → `forbidigo` |
|
||||
| Конфигурация только из TOML, `os.Getenv` для конфига не используем | `.golangci.yml` → `forbidigo` |
|
||||
| Время только через `store.Now()` — `time.Now` запрещён вне `internal/{ident,store}` | `.golangci.yml` → `forbidigo` |
|
||||
| Сравнение ошибок через `errors.Is`/`As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||
| Ошибки — только stdlib (`github.com/pkg/errors`, `cockroachdb/errors` запрещены) | `.golangci.yml` → `depguard` |
|
||||
| Опечатки в тексте | `.golangci.yml` → `misspell` |
|
||||
| Транспорты не зависят друг от друга | `internal/archrules` → `TestТранспортыНеЗависятДругОтДруга` |
|
||||
| Ядро не зависит от транспортов | `internal/archrules` → `TestЯдроНеЗависитОтТранспортов` |
|
||||
| Миграции без `AUTOINCREMENT` и без серверного времени | `internal/archrules` → `TestМиграцииБезAutoincrementИСерверногоВремени` |
|
||||
| Ошибки не матчатся по тексту сообщения | `internal/archrules` → `TestОшибкиНеМатчатсяПоТексту` |
|
||||
| Покрытие изменённых строк, секреты в диффе, миграция без правки `database.md` | `scripts/gate.py`, `scripts/diff-coverage.py`, `docs.py check` |
|
||||
|
||||
Непойманное место механизации означает, что проход по конвенциям будет
|
||||
добросовестно проверять уже проверенное.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Конвенция: база данных и идентификаторы
|
||||
|
||||
Как мы устраиваем таблицы и ключи в SQLite. Актуальная схема —
|
||||
[../specs/database.md](../specs/database.md); обоснование выбора ULID —
|
||||
[../database.md](../database.md); обоснование выбора ULID —
|
||||
`openspec/changes/ulid-identity/design.md` (после архивации — в истории git).
|
||||
|
||||
**Механизировано:** `AUTOINCREMENT` и `DEFAULT (datetime('now'))` в новых
|
||||
@@ -52,4 +52,4 @@
|
||||
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
|
||||
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
|
||||
id, backfill). При изменении структуры обновляем ER-схему
|
||||
[../specs/database.md](../specs/database.md) в том же change.
|
||||
[../database.md](../database.md) в том же change.
|
||||
|
||||
@@ -97,7 +97,7 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания,
|
||||
сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это
|
||||
**операторская поверхность владельца**: сервис однопользовательский в
|
||||
доверенной LAN (см. [architecture.md](../specs/architecture.md)), эти поля —
|
||||
доверенной LAN (см. [architecture.md](../architecture.md)), эти поля —
|
||||
диагностический контекст для того, кто разбирает задачу. Здесь сырой текст
|
||||
ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но:
|
||||
- **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Схема базы данных
|
||||
# Схема хранилища
|
||||
|
||||
Актуальная схема SQLite-хранилища: таблицы, поля и связи. Это **живой**
|
||||
документ — его поддерживаем в соответствии с миграциями.
|
||||
Актуальная схема SQLite: таблицы, поля и связи. Это **живой** документ — его
|
||||
поддерживаем в соответствии с миграциями.
|
||||
|
||||
> **Поддержка вместе с миграциями.** Источник истины по схеме —
|
||||
> `internal/store/migrations/*.sql` (goose). При **каждой** новой миграции,
|
||||
> меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму
|
||||
> в том же change. Расхождение схемы с миграциями считаем багом
|
||||
> документации.
|
||||
> в том же change. Расхождение схемы с миграциями считаем багом документации;
|
||||
> его же ловит `docs.py check` в гейте.
|
||||
>
|
||||
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
||||
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
|
||||
@@ -16,10 +16,12 @@
|
||||
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла),
|
||||
> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON).
|
||||
|
||||
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
||||
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
||||
Назначение таблиц и роль компонентов — [architecture.md](architecture.md).
|
||||
Значения `state` и легальные переходы — нормативно в
|
||||
[download-tracking](../openspec/specs/download-tracking/spec.md) и
|
||||
[state-reconciliation](../openspec/specs/state-reconciliation/spec.md).
|
||||
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
|
||||
(`internal/ident`) — см. [конвенцию](../conventions/database.md). Метки времени
|
||||
(`internal/ident`) — см. [конвенцию](conventions/database.md). Метки времени
|
||||
(`created_at`/`updated_at`) — TEXT в RFC 3339, UTC (суффикс `Z`); пишет
|
||||
приложение (`store.Now`/`FormatTime`), без `DEFAULT` на колонках.
|
||||
|
||||
@@ -42,7 +44,7 @@ erDiagram
|
||||
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
|
||||
TEXT context "NOT NULL DEFAULT ''"
|
||||
TEXT parsed_context "NOT NULL DEFAULT ''; структура имени из контекста (naming, JSON), базовый слой display_name (миграция 0011)"
|
||||
TEXT state "NOT NULL; см. workflow.md; активность выводится только из state"
|
||||
TEXT state "NOT NULL; активность выводится только из state"
|
||||
TEXT error_code "nullable"
|
||||
TEXT error_msg "nullable"
|
||||
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||||
@@ -116,7 +118,7 @@ erDiagram
|
||||
TEXT src_path "NOT NULL; исходный файл раздачи"
|
||||
TEXT dst_path "NOT NULL; целевой хардлинк"
|
||||
TEXT kind "NOT NULL; video|subtitle|..."
|
||||
TEXT status "NOT NULL; linked|..."
|
||||
TEXT status "NOT NULL; linked|copied|exists|collision|superseded"
|
||||
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
|
||||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||
}
|
||||
@@ -137,8 +139,6 @@ erDiagram
|
||||
- `download` 1 — 0..1 `download_torrent` — байты исходного `.torrent` (только
|
||||
у `source_type=torrent`); нужны воркеру для добавления раздачи файлом и для
|
||||
повторного добавления при retry, поэтому живут весь срок строки загрузки.
|
||||
`ON DELETE CASCADE` — страховка на будущий delete-путь (сейчас загрузки не
|
||||
удаляются).
|
||||
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
||||
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
||||
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
||||
@@ -157,3 +157,55 @@ erDiagram
|
||||
> Enum-поля (`source_type`, `state`, `provider`, `kind`, `status`, флаги
|
||||
> `0/1`) на уровне SQLite — обычный `TEXT`/`INTEGER` без `CHECK`; допустимые
|
||||
> значения держит код (`internal/store`).
|
||||
|
||||
## Представление данных
|
||||
|
||||
Чем физически лежит запись и что происходит при чтении и записи.
|
||||
|
||||
- **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких
|
||||
внешних файлов: строка читается и пишется целиком обычным запросом.
|
||||
- **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический
|
||||
`recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (список
|
||||
причин не-авто) и `download.parsed_context` (структура имени из контекста).
|
||||
Читаются целиком и разбираются в Go; частичного чтения и обновления поля
|
||||
внутри JSON нет, SQL по содержимому этих полей не делается.
|
||||
- **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это
|
||||
самое крупное поле в базе и главный кандидат на рост: у каждой попытки
|
||||
распознавания свой ответ, попытки не вытесняются, ретеншена нет
|
||||
(задача в беклоге).
|
||||
- **`download_torrent.data`** — единственный BLOB: исходные байты `.torrent`
|
||||
(обычно десятки КБ, у больших раздач — сотни). Читается целиком при
|
||||
добавлении в qBittorrent и при retry.
|
||||
- **Истории переходов нет** — хранится только текущий `state`; «как сюда
|
||||
попали» восстанавливается по логам (задача в беклоге).
|
||||
- **Терминальные загрузки не удаляются**, `file_link` со статусом `superseded`
|
||||
тоже остаются — база монотонно растёт по числу обработанных раздач.
|
||||
|
||||
## Настройки с числовым значением
|
||||
|
||||
СУБД (`internal/store`, DSN при открытии):
|
||||
|
||||
| Настройка | Значение | Зачем |
|
||||
| --- | --- | --- |
|
||||
| `journal_mode` | `WAL` | читатели не блокируют писателя |
|
||||
| `busy_timeout` | 5000 мс | ждать снятия блокировки, а не падать сразу `database is locked` |
|
||||
| `foreign_keys` | `ON` | `ON DELETE CASCADE` работает только с этим |
|
||||
| `_txlock` | `immediate` | явная транзакция открывается как write с самого начала; на этом держатся guarded-методы инварианта «одна активная загрузка на infohash» |
|
||||
| Размер пула | по умолчанию `database/sql` | явно не ограничен; писателя SQLite сериализует сама |
|
||||
|
||||
Времена и пороги, влияющие на объём и частоту работы с базой (значения по
|
||||
умолчанию, `config.example.toml` — источник истины по полям):
|
||||
|
||||
| Параметр | По умолчанию | Что означает |
|
||||
| --- | --- | --- |
|
||||
| `[worker].poll_interval` | `5s` | частота опроса qBittorrent, а значит и фонового чтения/записи состояния |
|
||||
| `[worker].stuck_after` | `1h` | простой раздачи, после которого она считается зависшей |
|
||||
| `[worker].magnet_timeout` | `24h` | страховочный предел ожидания метаданных magnet |
|
||||
| `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent |
|
||||
| `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника |
|
||||
| `[recognition].auto_confidence_threshold` | `0.85` | порог авто-раскладки (доп. проверка к матчу в базе) |
|
||||
| `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом |
|
||||
| `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе |
|
||||
|
||||
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
|
||||
кэша метабаз нет — всё три пункта в беклоге.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Конвенции кода: бэклог
|
||||
|
||||
Кандидаты в [docs/conventions/](../conventions/README.md), ещё не принятые.
|
||||
Пишем по мере реального трения, а не вперёд; принятое переезжает в
|
||||
`docs/conventions/`. Уже приняты: `logging.md`, `config.md`, `errors.md`.
|
||||
|
||||
## Tier 2 — кандидаты в отдельный доку
|
||||
|
||||
Завести, когда паттерн подтвердит второй-третий проект (или поймаем трение
|
||||
в jellybit).
|
||||
|
||||
### Раскладка пакетов и направление зависимостей
|
||||
|
||||
`cmd/<bin>` (точка входа) + `internal/<компонент>` по доменам. Домен не
|
||||
импортирует транспорт; зависимости направлены внутрь, к домену. Без свалок
|
||||
`util`/`common`/`helpers`. Стыкуется с «тонкие транспорты, единое ядро» из
|
||||
архитектуры.
|
||||
|
||||
### context.Context
|
||||
|
||||
Первый параметр функции; не хранить в структурах; в `Value` только
|
||||
request-scoped данные, не зависимости. Перенос логгера/корреляции через ctx
|
||||
(уже реализовано — `internal/logctx`, см. `logging.md`). Дедлайны/отмена
|
||||
протягиваются сквозь стадии.
|
||||
|
||||
### Внешние клиенты (HTTP к зависимостям)
|
||||
|
||||
Таймаут на **каждый** исходящий вызов (не полагаться на дефолт); не
|
||||
`http.DefaultClient`; ретраи с backoff и потолком попыток; HTTP-прокси из
|
||||
конфига (`proxy`-поля уже есть). Прямое продолжение `ext.*`-логирования
|
||||
(`logging.md`) и трансляции ошибок (`errors.md`). Кандидат — общий
|
||||
конструктор клиента вместо копипасты в qbt/llm/jellyfin/metadata.
|
||||
|
||||
### Тесты
|
||||
|
||||
Table-driven; фикстуры в `testdata/`; `t.Parallel()` где безопасно; выбрать
|
||||
и зафиксировать stdlib `testing` vs `testify`; разделение быстрых и
|
||||
интеграционных (уже есть `*_integration_test.go` + env-гейты). Что считаем
|
||||
обязательным к покрытию (валидация конфига, распознавание, раскладка).
|
||||
|
||||
## Tier 3 — тонкий бюллетень или мелочь
|
||||
|
||||
Не тянет на отдельный доку: строка-инвариант в `CLAUDE.md` или стек-специфика.
|
||||
|
||||
### БД и миграции (SQLite + goose)
|
||||
|
||||
Миграции forward-only; запросы только параметризованные (без склейки строк);
|
||||
явные транзакции для многошаговых изменений; context-aware запросы. Сильно
|
||||
стек-специфично — возможно, в `CLAUDE.md`, не в общий доку.
|
||||
|
||||
### Конкурентность
|
||||
|
||||
Каждая горутина знает, **как** останавливается (ctx/закрытие канала); без
|
||||
утечек; `errgroup` для связанных задач; фоновые процессы гасятся при
|
||||
shutdown. Актуально для воркера/фоновых задач, не для всего проекта.
|
||||
|
||||
### CLI
|
||||
|
||||
Данные — в `stdout`, логи и диагностика — в `stderr`; осмысленные коды
|
||||
возврата. Для CLI-подмножества проектов (у jellybit — диагностические
|
||||
команды `add`/`recognize`/`healthcheck`).
|
||||
|
||||
### Время
|
||||
|
||||
Явный TZ всегда; хранение и логи — в UTC; бизнес-логика в `Europe/Moscow`.
|
||||
Уже частично в `CLAUDE.md` и `logging.md` — при желании свести в один
|
||||
короткий инвариант.
|
||||
@@ -1,293 +0,0 @@
|
||||
# Черновик: идентичность загрузки и группировка тайтла (без сущности title)
|
||||
|
||||
> **Статус:** черновик-размышление (explore), не источник истины и не принятое
|
||||
> решение. Начат 2026-07-01; **переработан 2026-07-02** после второго захода
|
||||
> обсуждения. Когда/если решим делать — переезжает в OpenSpec change(и) и
|
||||
> `docs/specs`/`docs/adr`.
|
||||
>
|
||||
> **Итог разбора:** отдельную сущность `title` **не вводим**. Все целевые
|
||||
> сценарии решаются идентичностью загрузки (ULID + множество инфохэшей),
|
||||
> **правилом сходимости папки** при раскладке и вычисляемой группировкой.
|
||||
> Отвергнутые варианты и триггер пересмотра — в §7.
|
||||
|
||||
## 1. Зачем это
|
||||
|
||||
Сейчас домен идентифицирует загрузку **инфохэшем**, а целевые файлы принадлежат
|
||||
**отдельной загрузке** по целевому пути. Этого хватает для базового потока, но
|
||||
плохо ложится на то, что один логический тайтл (фильм/сериал) складывается из
|
||||
**нескольких загрузок** во времени: сезоны, докачивание серий, перезаливы.
|
||||
|
||||
Калибровка по реальным болям (зафиксирована в обсуждении 2026-07-02):
|
||||
|
||||
- **боль:** второй сезон должен лечь в ту же папку сериала;
|
||||
- **боль:** докачивание/перезалив серий (E01–10 вместо E01–05) — доложить
|
||||
недостающее;
|
||||
- **боль:** удалить тайтл целиком одним действием (включая опц. раздачи);
|
||||
- **не боль:** апгрейд качества — из приоритета выпадает (коллизия по-прежнему
|
||||
уходит в review, coexist через Jellyfin-версии доступен).
|
||||
|
||||
Связано с беклогом: «Идентичность загрузки: ULID + множество инфохэшей»,
|
||||
«Проблема второго сезона», «Раздачи с докачиванием», «История переходов
|
||||
загрузки», «Удаление средствами jellybit (path 2)».
|
||||
|
||||
## 2. Что уже есть (текущая модель)
|
||||
|
||||
```
|
||||
download (INTEGER id PK, AUTOINCREMENT)
|
||||
├─ source_type, source_ref, display_name, context
|
||||
├─ infohash (nullable), idempotency_key (UNIQUE если NOT NULL)
|
||||
├─ state, error_code/msg, source_miss_count, source_added_at
|
||||
└─ created_at / updated_at
|
||||
│
|
||||
├─(1—N)→ recognition (is_current, media_type, title, year,
|
||||
│ provider, provider_id, confidence, plan JSON, …)
|
||||
│ └─(1—N)→ metadata_candidate (provider, provider_id, url, chosen)
|
||||
├─(1—N)→ hint / override
|
||||
└─(1—N)→ file_link (apply_batch_id, src_path, dst_path, kind, status)
|
||||
```
|
||||
|
||||
Ключевые инварианты сегодня:
|
||||
|
||||
- **Идентичность загрузки = infohash** (`idempotency_key`), дедуп через
|
||||
`FindActiveByInfohash`. Воркер сопоставляет по трём хешам (hash/v1/v2).
|
||||
- **Владение целевым путём:** один `dst_path` — один владелец-`file_link`.
|
||||
`SupersedeForeignLinks(downloadID, dstPaths)` при раскладке помечает
|
||||
`status='superseded'` у ссылок **других** загрузок на те же пути
|
||||
(last-writer-owns). Статусы: `linked|copied|exists|collision|superseded`.
|
||||
- **Источник неприкосновенен**, **существующее не перезаписываем**
|
||||
(`collision` → review), **откат снимает лишний хардлинк, а не последнюю
|
||||
копию** (`nlink<=1` → отказ).
|
||||
- **Сверка «источник × цель»** двигает рассинхрон в
|
||||
`target_missing`/`orphaned`/`deleted`.
|
||||
|
||||
Владеют **путями**, а не «папкой сериала» — поэтому разные сезоны (разные пути)
|
||||
уже сосуществуют без конфликтов, супересида между ними нет.
|
||||
|
||||
## 3. Что не решено сегодня
|
||||
|
||||
- **Сходимость папки.** Папка строится каждый раз заново из выхода
|
||||
распознавания (`internal/layout/name.go`): `"Название (Год) [tmdbid-123]"`.
|
||||
Совпадение `provider_id` **не гарантирует** совпадение строки папки: LLM
|
||||
может дать «Fargo» и «Фарго», год сезона вместо года сериала — и второй
|
||||
сезон уедет в соседнюю папку при верном матче. Это ядро «проблемы второго
|
||||
сезона»: она **не про группировку, а про сходимость папки**.
|
||||
- **Докачивание** — «просто новая загрузка», упирающаяся в коллизию цели →
|
||||
review, без логики «доложить недостающее».
|
||||
- **«Удалить сериал целиком»** — ручной сбор всех причастных загрузок.
|
||||
- **Идентичность на infohash хрупкая** (v1/v2/гибрид, перезаливы) — см. §6.
|
||||
|
||||
## 4. Итог разбора: почему БЕЗ сущности title
|
||||
|
||||
Главный аргумент: **download — мост между раздачей в qBittorrent и набором
|
||||
файлов на диске**, и каждая сущность цепочки отвечает на свои операции:
|
||||
|
||||
```
|
||||
qBittorrent ──1:1── download ──владение──▶ файлы на диске
|
||||
(раздача) (мост) (пути)
|
||||
pause/cancel/retry FSM, ULID undo/relay, per-path
|
||||
```
|
||||
|
||||
У `title` при разборе **не нашлось ни одной собственной операции**: сходимость
|
||||
папки — правило при построении плана; merge докачивания — per-path логика;
|
||||
удаление целиком — цикл по вычисляемой группе. Сущность без собственных
|
||||
операций — это линза, а линзу достаточно вычислять, не хранить.
|
||||
|
||||
Второе: «папка — это title-уровневое состояние, ей нужен дом» (аргумент за
|
||||
хранимый title) разбивается о то, что **дом у папки уже есть** — файловая
|
||||
система и `dst_path` живых `file_link`'ов. Реестр дублировал бы то, что и так
|
||||
записано в БД в N экземплярах. Причём вычисляемый якорь **корректнее**
|
||||
хранимого: если все файлы сериала снесли, живых ссылок нет — и новая загрузка
|
||||
честно создаёт свежую папку; хранимый `title.folder` указывал бы в пустоту.
|
||||
|
||||
Третье: отказ от сущности **устраняет** (а не решает) целый хвост развилок:
|
||||
жизненный цикл тайтла (рождение/смерть/пустой тайтл), слияние тайтлов, ad-hoc
|
||||
тайтл без провайдера, обратная миграция существующих строк, title-лог.
|
||||
|
||||
## 5. Целевая модель
|
||||
|
||||
Три элемента: стабильная идентичность загрузки, правило сходимости папки,
|
||||
вычисляемая группировка. Плюс опциональная история переходов.
|
||||
|
||||
### 5.1 Идентичность: ULID + download_infohash
|
||||
|
||||
```
|
||||
download download_infohash
|
||||
id TEXT PK (ULID, генерим download_id FK→download
|
||||
при приёме) infohash TEXT
|
||||
…остальное как сейчас, kind v1|v2
|
||||
минус idempotency_key UNIQUE(infohash) ← дедуп переезжает сюда
|
||||
```
|
||||
|
||||
- `download.id` = ULID — публичный стабильный ключ домена; переживает
|
||||
перезаливы, не завязан на хеш.
|
||||
- `download_infohash` — множество хешей одной загрузки (v1/v2, в будущем —
|
||||
«этот перезалив — та же загрузка»). Поиск при приёме и в поллинге — по
|
||||
любому из хешей.
|
||||
|
||||
### 5.2 Правило сходимости папки
|
||||
|
||||
При построении плана раскладки для загрузки с **подтверждённым матчем**
|
||||
`(provider, provider_id)`:
|
||||
|
||||
```
|
||||
1. найти ЖИВЫЕ file_link'и (status IN linked|copied|exists) загрузок,
|
||||
чей current recognition имеет тот же (provider, provider_id)
|
||||
2. есть → база папки (имя+год) наследуется из существующего dst_path;
|
||||
LLM-выход для папки игнорируется ← якорь
|
||||
3. нет → папка из распознавания, как сейчас ← первая
|
||||
загрузка «печатает» имя, остальные наследуют
|
||||
```
|
||||
|
||||
- Это join по существующим таблицам (`file_link → download →
|
||||
recognition(is_current)`), **ни одной новой сущности**.
|
||||
- Правило локальное: download остаётся мостом, распознавание — недоверенным,
|
||||
безопасность — на валидации пути (инварианты не трогаем).
|
||||
- Человек/Jellyfin переименовал папку на диске → сверка переведёт ссылки в
|
||||
`target_missing` → якорь исчезает → следующая загрузка печатает заново.
|
||||
Истина — живые пути, отдельного «источника истины по папке» нет.
|
||||
- Без подтверждённого матча авто-раскладки нет (инвариант) → раскладка идёт
|
||||
через review, папку выбирает человек. Сходимость «без базы» не автоматизируем.
|
||||
|
||||
### 5.3 Вычисляемая группировка (тайтл как линза)
|
||||
|
||||
- «Из чего состоит сериал» = `GROUP BY (provider, provider_id)` текущих
|
||||
распознаваний с живыми ссылками; эквивалентно — по общей папке в `dst_path`.
|
||||
- «Удалить целиком» = перечислить загрузки группы → штатный undo каждой
|
||||
(`superseded` пропускаем — путь у другого владельца; `nlink<=1` — отказ) →
|
||||
опц. удалить раздачи из qBittorrent (осознанный выход за инвариант «источник
|
||||
неприкосновенен», только по явному подтверждению) → опц. снести опустевшую
|
||||
папку.
|
||||
- На домашнем масштабе `GROUP BY` бесплатен; денормализации не нужны.
|
||||
|
||||
### 5.4 История переходов (опционально, дёшево)
|
||||
|
||||
```
|
||||
state_transition (download_id, from_state, to_state, reason, actor, at)
|
||||
actor ∈ {worker, human, reconcile}
|
||||
```
|
||||
|
||||
Питает таймлайн на `/download/{id}` и метрики длительности стадий. Композиция
|
||||
тайтла во времени («B долил Season 02») выводима из `download` + `file_link` +
|
||||
`state_transition` — отдельный лог не нужен.
|
||||
|
||||
## 6. Разбор операций
|
||||
|
||||
### 6.1 Второй сезон
|
||||
|
||||
```
|
||||
S1 ──lay──▶ …/Fargo (2014) [tvdbid-269613]/Season 01/… (владеет A)
|
||||
S2: матч tvdb=269613 → живые ссылки A найдены → папка унаследована
|
||||
S2 ──lay──▶ …/Fargo (2014) [tvdbid-269613]/Season 02/… (владеет B)
|
||||
```
|
||||
|
||||
Пути не пересекаются → супересида нет, A не трогаем. Сходимость дало правило
|
||||
§5.2, группировку — линза §5.3.
|
||||
|
||||
Принятая цена: если S1 заматчился через один провайдер, а S2 — через другой
|
||||
(смена конфига метабаз), якорь по `(provider, provider_id)` не склеит — случай
|
||||
редкий, штатно уходит в review.
|
||||
|
||||
### 6.2 Докачивание серий (merge)
|
||||
|
||||
```
|
||||
существует: Season 01/E01..E05 (владеет A)
|
||||
C приносит: Season 01/E01..E10 (та же папка — за счёт сходимости)
|
||||
merge: E01..E05 — уже есть → не перезаписываем (владение у A)
|
||||
E06..E10 — кладём (владеет C)
|
||||
```
|
||||
|
||||
Целевая merge-логика: **доложить только недостающее**. Владение сезоном
|
||||
делится между A и C по путям — нормально в per-path модели (split-ownership
|
||||
принят как дефолт). Обе раздачи сидируют независимо.
|
||||
|
||||
### 6.3 Апгрейд качества — вне приоритета
|
||||
|
||||
Не боль. Коллизия на тот же `dst_path` по-прежнему → review; сосуществование
|
||||
версий (Jellyfin multi-version, другой `dst`) доступно без спец-логики. Явный
|
||||
replace (undo старого → lay нового → супересид) — отдельный change, если/когда
|
||||
понадобится.
|
||||
|
||||
### 6.4 Удаление (частичное и целиком)
|
||||
|
||||
Частичное (одна загрузка/сезон) — уже штатный undo. Целиком — по группе §5.3.
|
||||
Никакой «памяти о тайтле» после полного удаления не остаётся — и не должно
|
||||
(линза без содержимого не нужна; «список того, что смотрел» — дрейф в
|
||||
медиатеку, см. §7).
|
||||
|
||||
## 7. Отвергнутые варианты и триггер пересмотра
|
||||
|
||||
Разбирались и были отвергнуты (2026-07-02):
|
||||
|
||||
- **L2: `title` с ключом `(provider, provider_id)`** — привязывает
|
||||
долгоживущую сущность к провайдеру, который может смениться.
|
||||
- **L2-min: `title` со своим ULID + `title_external_id`** (провайдерные ID —
|
||||
множество-атрибут, симметрично `download_infohash`). Красивая схема: решает
|
||||
смену провайдера, ad-hoc тайтлы, слияние. Отвергнута потому, что у тайтла
|
||||
**нет собственных операций** (§4) — все сценарии закрылись правилом
|
||||
сходимости и вычисляемой группировкой, а сущность тянула жизненный цикл,
|
||||
миграцию и четыре развилки.
|
||||
- **L3 (title-центрично, медиатека)** — сонарр, осознанно не идём: не ходим в
|
||||
индексеры, не мониторим тайтлы, не ведём профили качества, контент приносит
|
||||
пользователь. См. таблицу ответственности в истории документа (git) либо
|
||||
BRIEF.
|
||||
|
||||
**Триггер пересмотра** (чтобы не гонять этот круг заново): сущность `title`
|
||||
возвращается в обсуждение, только когда появится **операция или состояние,
|
||||
которому реально негде жить** в download+file_link — например, «переименовать
|
||||
сериал целиком с переносом ссылок» как регулярное действие или заметки уровня
|
||||
группы. До того — вычисляем.
|
||||
|
||||
## 8. Идентичность: ULID vs infohash (памятка)
|
||||
|
||||
infohash надёжен как ключ конкретной метадаты-раздачи в одном инстансе
|
||||
qBittorrent, но: v1/v2/гибрид дают разные значения; перезалив/репак/докачка →
|
||||
другой хеш; один логический объект → много хешей. Поэтому доменный PK — ULID,
|
||||
а инфохэши — many-to-one атрибут (§5.1).
|
||||
|
||||
## 9. Этапность (не обязательство)
|
||||
|
||||
```
|
||||
1. ULID загрузки + download_infohash (дедуп переезжает). ← фундамент
|
||||
2. правило сходимости папки при плане раскладки. ← «второй сезон» ✓ реализовано
|
||||
3. merge-раскладка (докачивание: доложить недостающее). ← §6.2
|
||||
4. группа «тайтл» в UI (вычисляемая) + удаление целиком (path 2). ← §6.4
|
||||
(state_transition — вставить, когда захочется таймлайн/метрики)
|
||||
```
|
||||
|
||||
> Шаг 2 (правило сходимости папки) реализован — change
|
||||
> `openspec/changes/archive/2026-07-10-series-folder-convergence/`, требования
|
||||
> влиты в `openspec/specs/file-layout/`. Отличие от §5.2 черновика: живость якоря
|
||||
> определяется существованием папки на диске (`os.Lstat`), а не только статусом
|
||||
> ссылки; рассинхрон нескольких живых папок → review; in-app разрешение
|
||||
> рассинхрона осознанно вне scope (ручной фикс на диске).
|
||||
|
||||
Каждый шаг — отдельный OpenSpec change; 1–2 самодостаточны и закрывают главную
|
||||
боль.
|
||||
|
||||
## 10. Открытые вопросы (оставшиеся)
|
||||
|
||||
- **Несколько живых папок с одним `(provider, provider_id)`** (уже случившийся
|
||||
рассинхрон до внедрения сходимости): какой якорь брать — самую свежую, самую
|
||||
населённую, или отдавать в review? Скорее review: молча выбирать нехорошо.
|
||||
- **Слияние загрузок при перезаливе «той же вещи»**: когда несколько инфохэшей
|
||||
считать одной загрузкой (одна строка `download` + много `infohash`) vs
|
||||
разными загрузками? Влияет на семантику `download_infohash` и merge §6.2.
|
||||
- **Явный replace при апгрейде** — отложен целиком; вернуться, если станет
|
||||
болью.
|
||||
|
||||
## 11. Мини-словарь (для согласованности имён)
|
||||
|
||||
- **Тайтл** — логический фильм/сериал; **вычисляемая группа** загрузок по
|
||||
`(provider, provider_id)` / общей папке, не хранимая сущность.
|
||||
- **Загрузка (download)** — один приём/раздача-вклад; свой ULID; несколько
|
||||
инфохэшей; мост qBittorrent ↔ файлы.
|
||||
- **Владение путём** — `file_link` отвечает за конкретный `dst_path`.
|
||||
- **Супересид** — переход владения путём к более новой загрузке.
|
||||
- **Сходимость папки** — наследование базы папки от живых ссылок с тем же
|
||||
`(provider, provider_id)` вместо выхода LLM.
|
||||
|
||||
---
|
||||
|
||||
_Дальше по этому черновику: при желании — `opsx:propose` на шаг 1 (ULID +
|
||||
download_infohash) как фундамент; шаг 2 (сходимость папки) — следующим
|
||||
отдельным change._
|
||||
@@ -1,45 +0,0 @@
|
||||
# Дорожная карта
|
||||
|
||||
Черновик плана реализации. Ориентир, не обязательство; по ходу
|
||||
уточняется. Что реализовано и как устроено — в `docs/specs`.
|
||||
|
||||
## Фазы
|
||||
|
||||
- **Ф0 — каркас.** go.mod, раскладка пакетов, загрузка TOML-конфига,
|
||||
SQLite + миграции, slog-логи, `Dockerfile` (минимальный рантайм-образ,
|
||||
копирует готовый бинарь), golangci-lint, lefthook. Документация (этот
|
||||
этап — частично готов).
|
||||
- **Ф1 — ingest + tracking (без LLM).** `Ingest()` + добавление в
|
||||
qBittorrent (источник отдаём ему, категория `jellybit`, ключ
|
||||
идемпотентности по infohash) + `worker`-поллинг завершения
|
||||
(`savepath=/srv/media/downloads`, путь из API) + машина состояний. Наружу:
|
||||
HTTP API, список в веб-UI, `jellybit add`.
|
||||
- **Ф2 — распознавание.** `go-ptn` + LLM (structured output) → план +
|
||||
оценка уверенности. Без записи на диск.
|
||||
- **Ф3 — раскладка + минимальный review.** Хардлинки по конвенциям
|
||||
Jellyfin (санитизация пути, never-overwrite), субтитры, идемпотентность,
|
||||
**undo**. Авто только при матче в базе и чистой валидации; иначе → review
|
||||
(htmx): подсказка + перераспознавание, из ручного — тип, выбор кандидата
|
||||
базы, пометка «игнор». Полный редактор маппинга — Ф5. См.
|
||||
[review-ux.md](../specs/review-ux.md).
|
||||
- **Ф4 — метаданные.** TMDB/TVDB опционально (с HTTP-прокси на клиента),
|
||||
provider-id в именах, валидация распознавания против числа серий.
|
||||
- **Ф5 — Telegram + UX.** Бот-адаптер + парсер сообщений торрент-бота,
|
||||
подтверждение в боте (карточка + кнопки + reply-подсказка, эскалация в
|
||||
веб), полный редактор маппинга «файл → серия», триггер скана Jellyfin,
|
||||
нотификации.
|
||||
- **Ф6 — деплой.** Полный образ собирается локально на control-хосте
|
||||
(`task image`) и едет на сервер через `docker save`/`load` (роль
|
||||
`app_image` в umbar), там и запускается — `docker build` на сервере нет;
|
||||
оркестрация — `playbook-jellybit.yml` в umbar: общая docker-сеть,
|
||||
`user 1000:1000`,
|
||||
mount `/srv/media` + data-том `/srv/applications/jellybit/data`,
|
||||
healthcheck. Сопутствующие правки qBit (том `/srv/media`, savepath/temp
|
||||
под `/srv/media`, `WebUI\ServerDomains=*`).
|
||||
|
||||
## Заметки по порядку
|
||||
|
||||
- Минимальный review-экран нужен уже в Ф3 (как только появляется режим
|
||||
«спросить при сомнении»), полноценный UX — в Ф5.
|
||||
- Jellyfin в umbar ещё не развёрнут — раскладку файлов это не блокирует,
|
||||
тестируется без него; триггер скана подключаем, когда Jellyfin поднят.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Паспорт проекта
|
||||
|
||||
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
||||
устроено», [tasks/PLAN.md](tasks/PLAN.md) — «в каком порядке», паспорт —
|
||||
«зачем и для кого».
|
||||
|
||||
## Цель
|
||||
|
||||
Сократить путь «нашёл раздачу → смотрю на телевизоре» до одного действия:
|
||||
кинуть торрент с парой слов контекста и получить фильм или сериал в библиотеке
|
||||
Jellyfin, без ручного переименования и без каталога индексаторов.
|
||||
|
||||
**Потребители** — список закрытый: он определяет, что считать нужным, а что
|
||||
интересным.
|
||||
|
||||
| Кто | Что ему нужно от нас |
|
||||
| --- | --- |
|
||||
| Владелец медиасервера (единственный оператор) | одна точка входа для magnet/`.torrent` + контекста; когда система не уверена — чтобы позвали, а не замяли молча; чтобы ошибку можно было откатить |
|
||||
| Домашние зрители (через Jellyfin, о jellybit не знают) | правильные названия, годы, сезоны и серии — иначе Jellyfin подтянет чужие метаданные |
|
||||
| Jellyfin | файлы, разложенные по его конвенциям имён, и сигнал пересканировать библиотеку, когда они изменились |
|
||||
|
||||
Цель достигнута, когда:
|
||||
|
||||
- русский контент и аниме раскладываются так же буднично, как англоязычные —
|
||||
это ровно то, на чём разваливается arr-стек;
|
||||
- типовое добавление не требует ни одного ручного действия после отправки
|
||||
торрента, а нетиповое требует ровно одного — подтверждения в ревью;
|
||||
- ошибочная раскладка откатывается одной кнопкой, не задев раздачу.
|
||||
|
||||
## Что целью не является
|
||||
|
||||
Граница домена. По ней архитектурный проход судит, не перенесено ли понятие
|
||||
через границу.
|
||||
|
||||
- **Не индексатор и не поисковик по трекерам** (роль prowlarr). Раздачу находит
|
||||
человек и приносит сам — вместе с контекстом, который он и так видит глазами.
|
||||
- **Не менеджер качества релизов** (правила radarr/sonarr). Версии, репаки и
|
||||
апгрейд 1080p → 2160p не отслеживаем; коллизия уходит в ревью, а не в
|
||||
политику качества.
|
||||
- **Не подписка на выходящие серии.** Никакого monitoring: система не ищет
|
||||
ничего сама и не добавляет загрузок по своей инициативе.
|
||||
- **Не торрент-клиент.** Качает qBittorrent, мы им управляем и не подменяем его
|
||||
функциональность.
|
||||
- **Не медиасервер.** Обложки, метаданные, учёт просмотренного и сам просмотр —
|
||||
забота Jellyfin. Мы отвечаем только за то, чтобы файл лежал там, где Jellyfin
|
||||
его правильно опознает.
|
||||
- **Не хранилище медиа.** Данные живут в раздаче; мы создаём только хардлинки и
|
||||
не владеем ни одним байтом контента.
|
||||
- **Не мультипользовательский сервис.** Контур один, оператор один; разграничение
|
||||
доступа сводится к allowlist Telegram (см. [security.md](security.md)).
|
||||
|
||||
## Типовые сценарии
|
||||
|
||||
1. **Фильм через Telegram.** Переслать боту сообщение торрент-бота → magnet и
|
||||
текст сообщения становятся источником и контекстом → загрузка → распознавание
|
||||
→ при подтверждённом матче в метабазе авто-раскладка → пинг «готово».
|
||||
2. **Сезон сериала.** То же, но файлов много; они раскладываются сериями, а
|
||||
второй сезон ложится в **ту же** папку тайтла, что и первый.
|
||||
3. **Русский фильм, которого нет в базе.** Уходит в ревью: подсказка текстом и
|
||||
перераспознавание, выбор источника совпадения из списка, ручной ввод id или
|
||||
URL записи, предпросмотр целевых путей, «Применить».
|
||||
4. **Ошиблись с привязкой.** Undo снимает наши ссылки (раздача цела) →
|
||||
«Привязать заново» → правка в ревью → повторное применение.
|
||||
5. **Раздачу или файлы удалили руками.** Фоновая сверка констатирует рассинхрон
|
||||
(`target_missing`/`orphaned`/`deleted`), не теряя последнюю копию данных, и
|
||||
лечится сама, если реальность вернулась.
|
||||
|
||||
## Референсы
|
||||
|
||||
Где смотреть prior art, когда упёрлись.
|
||||
|
||||
- [Jellyfin: Movies](https://jellyfin.org/docs/general/server/media/movies) и
|
||||
[Shows](https://jellyfin.org/docs/general/server/media/shows) — целевые
|
||||
конвенции имён, источник истины по формату, в который раскладываем.
|
||||
- **arr-стек** (radarr/sonarr/prowlarr) — прежде всего как каталог того, чего мы
|
||||
намеренно **не** берём; полезен по крайним случаям именования.
|
||||
- **umbar** (`/home/av/projects/private/umbar`) — соседний проект того же
|
||||
хозяйства: форма деплоя, раскладка `/srv`, стиль «минимум компонентов».
|
||||
@@ -0,0 +1,27 @@
|
||||
# Разведка
|
||||
|
||||
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
|
||||
расходится с практикой. Источник истины — этот каталог, а не чужая документация.
|
||||
|
||||
**Каждый вывод — с числами и командой или условиями, которыми получен**, чтобы
|
||||
его можно было перепроверить. Число без провенанса проход обязан читать как
|
||||
условие, а не как замер. Число, чей источник по ссылке не подтвердился, не
|
||||
выбрасывается и не переписывается по догадке — остаётся с пометкой «расходится
|
||||
с источником: там <что нашли>».
|
||||
|
||||
## Как снималось
|
||||
|
||||
Наблюдения снимались вручную, по ходу разработки, на домашнем контуре: реальные
|
||||
сообщения торрент-бота в Telegram, реальные ответы qBittorrent WebUI API и
|
||||
LLM-эндпоинта на живых раздачах. Автоматического сбора и корпуса кейсов **нет** —
|
||||
это отдельная задача (eval-харнес распознавания), до неё числа здесь единичные
|
||||
и приведены как условия, а не как статистика.
|
||||
|
||||
Зафиксированные образцы чужих форматов лежат прямо в тестах пакета-разборщика
|
||||
(`internal/tgbot/parse_test.go`, `internal/magnet`, `internal/torrent`) — там
|
||||
они заодно и проверяются; каталог `testdata/` под них не заводился.
|
||||
|
||||
## Записи
|
||||
|
||||
- [torrent-bot-message.md](torrent-bot-message.md) — формат сообщения
|
||||
торрент-бота, из которого приходит magnet и контекст.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Формат сообщения торрент-бота
|
||||
|
||||
Основной способ добавления загрузки — переслать в jellybit сообщение стороннего
|
||||
торрент-бота (`exfreedomist`, поиск по rutracker и соседним трекерам). Из
|
||||
сообщения берутся **источник** (magnet) и **контекст** для распознавания. Формат
|
||||
чужой, ничем не документирован и может измениться без предупреждения.
|
||||
|
||||
**Провенанс.** Образец снят вручную из личного чата Telegram (сообщение
|
||||
датировано 2026-03-21) и зафиксирован в [BRIEF.md] проекта; второй образец,
|
||||
меньшего размера, живёт константой `botMessage` в
|
||||
`internal/tgbot/parse_test.go` и проверяется тестами разборщика. Статистики по
|
||||
вариантам формата нет — наблюдений всего два, и это условие, а не замер.
|
||||
|
||||
[BRIEF.md]: перенесён в [../passport.md](../passport.md); полный текст образца —
|
||||
ниже и в истории git.
|
||||
|
||||
## Образец
|
||||
|
||||
```
|
||||
[1] #6514485 [rutracker], 2026-03-21 (https://hashurl.ru/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…):
|
||||
Дюна: Часть вторая / Dune: Part Two (Дени Вильнёв / Denis Villeneuve) [2024, США, Канада, фантастика, WEB-DL 2160p, HDR10+, Dolby Vision] Dub (Bravo Records Georgia, RHS, Jaskier, HDrezka) + MVO (LostFilm, TVShows, Jaskier) + AVO (Сербин, Яроцкий) + (Ukr) + Original (Eng) + Sub (Rus, Eng, Ukr)
|
||||
|
||||
✅ (проверено) | 34.82 GB
|
||||
|
||||
magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet&dn=rutracker-topic-6514485
|
||||
|
||||
Открыть magnet в вашем клиенте (https://hashurl.ru/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…)
|
||||
или получить .torrent: /tr_5c054
|
||||
|
||||
Оцените раздачу:
|
||||
👍: /g_eabdce или 👎🏿: /r_eabdce
|
||||
|
||||
[список файлов] (https://download.exfreedomist.com/files/541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6)
|
||||
|
||||
Следить: /us_5c054
|
||||
Добавить в закладки: /mka_96423
|
||||
|
||||
cправка: /help, index (https://exfreedomist.com/stats/)
|
||||
```
|
||||
|
||||
## Что из этого наблюдается
|
||||
|
||||
- **Заголовок строки 1** — порядковый номер выдачи, `#<topic-id>`, имя трекера в
|
||||
квадратных скобках, дата раздачи и ссылка-редирект `hashurl.ru` с JWT в пути.
|
||||
Токен в ссылке **имеет срок жизни** (`exp` в payload) — как долгоживущий
|
||||
идентификатор он не годится.
|
||||
- **Строка описания** — самый ценный кусок: локализованное название, оригинальное
|
||||
название через `/`, режиссёр в скобках (тоже через `/`), затем блок в
|
||||
квадратных скобках `[год, страны, жанры, качество, HDR…]` и перечисление
|
||||
дорожек и субтитров. Именно она уходит контекстом в распознавание.
|
||||
- **Разделитель `/`** используется одновременно для пары «локализованное /
|
||||
оригинальное» и для пары «имя режиссёра кириллицей / латиницей». По позиции
|
||||
они не различаются — только по тому, что вторая пара стоит в скобках.
|
||||
- **magnet отдельной строкой**, с `dn=rutracker-topic-<id>` — то есть `dn`
|
||||
здесь **не** содержит названия фильма и как имя раздачи бесполезен.
|
||||
Инфохэш в magnet — v1, uppercase hex; нормализуем в lowercase.
|
||||
- **`.torrent` не приложен** — предлагается командой бота (`/tr_…`), то есть
|
||||
вторым шагом в чужом чате. Поэтому источник, приходящий этим путём, —
|
||||
практически всегда magnet.
|
||||
- **Размер раздачи** в человекочитаемом виде (`34.82 GB`) и отметка
|
||||
«✅ (проверено)».
|
||||
- **Команды бота** (`/g_…`, `/r_…`, `/us_…`, `/mka_…`, `/help`) и ссылка на
|
||||
список файлов — шум для распознавания, но безвредный: попадают в контекст как
|
||||
есть.
|
||||
- Весь текст — **недоверенный вход**: он полностью составляется чужим ботом по
|
||||
данным трекера и уезжает в промпт LLM. См. [../security.md](../security.md).
|
||||
|
||||
## Чего не знаем
|
||||
|
||||
- Насколько формат стабилен: наблюдений два, оба от одного бота, разница между
|
||||
ними — только в наличии строки «сохранённая копия описания раздачи».
|
||||
- Как выглядит сообщение для сериала-сезонника и для аниме — образцов не
|
||||
снимали, а именно на них строится самый сложный случай раскладки.
|
||||
- Что бот присылает при неудачном поиске и при слишком длинном описании
|
||||
(обрезка Telegram — 4096 символов на сообщение).
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
# Ревью: настройка и журнал
|
||||
|
||||
Проектная часть конвейера ревью: чем jellybit отличается от абстрактного
|
||||
Go-сервиса и что здесь уже проскакивало. Устройство самого конвейера (профили,
|
||||
стадии, контракт находок) живёт в скилле, а не здесь.
|
||||
|
||||
## Как настроен конвейер
|
||||
|
||||
### Типовые узлы
|
||||
|
||||
Рода узлов проекта и проверяемые свойства к каждому. Род, а не инвентарь
|
||||
пакетов: узел, которого ещё нет, но который проект заведёт, включён намеренно.
|
||||
|
||||
**Клиент внешнего HTTP-сервиса** (`qbt`, `llm`, `metadata`, `jellyfin`, `tgbot`)
|
||||
|
||||
- у каждого исходящего вызова свой таймаут из конфига, не дефолт транспорта;
|
||||
- `context` доходит до запроса и отменяет его, а не игнорируется;
|
||||
- ошибка зависимости отличима от ошибки нашей логики на приёме результата;
|
||||
- секреты (пароль, ключ, токен) не попадают ни в лог, ни в текст ошибки;
|
||||
- недоступность **опциональной** зависимости (метабаза, Jellyfin, Telegram) не
|
||||
двигает состояние загрузки и не краснеет ERROR-ом в фоновом цикле.
|
||||
|
||||
**Тик воркера и переход состояния**
|
||||
|
||||
- переход легален по декларативному графу, а не «просто присвоили `state`»;
|
||||
- работа идёт под per-download блокировкой; два транспорта не гонятся;
|
||||
- тик идемпотентен: повтор на том же состоянии не порождает второго эффекта;
|
||||
- отмена контекста на середине не оставляет полуприменённого состояния;
|
||||
- новое промежуточное состояние имеет выход **и** предохранитель по времени.
|
||||
|
||||
**Репозиторий `store`**
|
||||
|
||||
- запрос параметризован, время только через `store.Now()`, id через `ident`;
|
||||
- многошаговое изменение — в одной write-транзакции (`_txlock=immediate`);
|
||||
- инвариант, не выражаемый схемой («одна активная загрузка на infohash»),
|
||||
держится guarded-методом, а не проверкой в вызывающем коде;
|
||||
- миграция forward-only и сопровождается правкой [database.md](database.md).
|
||||
|
||||
**Операция с файловой системой** (`layout`)
|
||||
|
||||
- целевой путь проверяется **после** `filepath.Clean`, на принадлежность
|
||||
библиотеке;
|
||||
- существующая цель не перезаписывается ни при каком исходе;
|
||||
- под `paths.downloads` нет ни одной операции записи или удаления;
|
||||
- частичный сбой батча оставляет систему в состоянии, из которого повтор
|
||||
доводит начатое или откатывает целиком;
|
||||
- удаление снимает только свои ссылки своего батча и не снимает последнюю копию.
|
||||
|
||||
**Парсер недоверенного входа** (`magnet`, `torrent`, парсер сообщения бота,
|
||||
разбор ответа LLM)
|
||||
|
||||
- вход враждебный по умолчанию: длина, вложенность, мусорные байты, пустота;
|
||||
- разбор не паникует и не аллоцирует по числу из самого входа;
|
||||
- невалидный вход даёт доменную ошибку, а не тихий дефолт;
|
||||
- результат нормализуется на границе (lowercase hex, trim, `ident.Parse`).
|
||||
|
||||
**htmx-хендлер**
|
||||
|
||||
- один партиал обслуживает страницу и фрагмент, ветвление по `isHTMX`;
|
||||
- ошибка на htmx-пути — 200 плюс фрагмент, а не 4xx/5xx;
|
||||
- страница деградирует без JS;
|
||||
- поллинг самозавершается, когда наблюдать больше нечего.
|
||||
|
||||
### Типовые ложноположительные
|
||||
|
||||
Находки, которые здесь выглядят убедительно и всегда неверны.
|
||||
|
||||
- **«Веб-UI и REST без авторизации».** Принятое решение под сегодняшний
|
||||
периметр — [security.md](security.md). Дефектом станет только вместе с путём
|
||||
снаружи LAN.
|
||||
- **«Ошибка на htmx-пути возвращает 200».** Так и задумано —
|
||||
[conventions/web-ui.md](conventions/web-ui.md).
|
||||
- **«Решение auto/review должно опираться на `confidence` модели».** Наоборот:
|
||||
авто только при подтверждённом матче в базе —
|
||||
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md).
|
||||
Самооценка LLM плохо откалибрована и поддаётся инъекции.
|
||||
- **«Копировать надёжнее, чем хардлинк» / «взять симлинк».** Хардлинк —
|
||||
осознанный выбор ради неприкосновенности источника и недублирования диска,
|
||||
[ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md); copy — только
|
||||
фолбэк.
|
||||
- **«Целочисленный автоинкрементный ключ был бы проще».** ULID — требование
|
||||
capability `identity`; `AUTOINCREMENT` вдобавок краснит гейт.
|
||||
- **«Не хватает метрик, трейсинга, health-эндпоинтов по каждой зависимости».**
|
||||
«Минимум компонентов» — принцип проекта; глубокий healthcheck заведён задачей
|
||||
и ждёт своей очереди, а не является упущением.
|
||||
- **«Здесь нужен интерфейс, чтобы это можно было замокать».** Единственная
|
||||
реализация за интерфейсом — обычно лишний слой; см. «Честный предел» ниже.
|
||||
- **«Нет ретрая у вызова в фоновом цикле».** Тик повторится сам через
|
||||
`poll_interval`; ретрай внутри тика чаще вреден.
|
||||
- **«Оригинальное и локализованное названия дублируются — избыточность».**
|
||||
`original_title` заполняется всегда и при неуверенности дублирует `title` —
|
||||
это контракт capability `recognition`, а не недосмотр.
|
||||
|
||||
### Вопросы к проходам
|
||||
|
||||
Форма: `<имя прохода>: <вопрос> (<провенанс>)`. Журнал дефектов пока пуст,
|
||||
поэтому провенанс у всех пунктов — инвариант или ADR, а не пойманный случай;
|
||||
по мере накопления журнала список должен смещаться в сторону реальных промахов.
|
||||
|
||||
- `adversary`: можно ли, управляя только именами файлов в раздаче и текстом
|
||||
контекста, добиться целевого пути вне `paths.movies`/`series` — включая путь
|
||||
через юникод, длину сверх лимита ФС и коллизию после нормализации?
|
||||
(инвариант «целевой путь строго под библиотекой», [security.md](security.md))
|
||||
- `adversary`: есть ли последовательность команд, после которой снимается
|
||||
**последняя** копия данных — с учётом `superseded`-ссылок и гонки со сверкой?
|
||||
(инвариант «источник неприкосновенен»)
|
||||
- `adversary`: что даёт крафт-магнет с чужим или подставным инфохэшем —
|
||||
присоединение к чужой активной загрузке, отравление владения?
|
||||
(открытая задача про идентичность инфохэшей)
|
||||
- `ops`: что делает эта ветка, когда qBittorrent недоступен несколько минут
|
||||
подряд — сколько ERROR-строк в секунду и меняется ли состояние задач?
|
||||
(задача про ERROR-шторм фоновых циклов)
|
||||
- `ops`: как это ведёт себя при сотне загрузок в базе и десятках тысяч
|
||||
`file_link` — есть ли запрос без индекса и полный проход по таблице?
|
||||
(задача про масштаб 100/1000, [database.md](database.md) → «Настройки»)
|
||||
- `ops`: что остаётся на диске и в базе, если процесс убит посреди раскладки
|
||||
батча? (состояние `linking` и его восстановление)
|
||||
- `code`: логирующий чекпоинт один на операцию — или ошибка залогирована и
|
||||
возвращена вверх, где залогирована снова?
|
||||
([conventions/logging.md](conventions/logging.md))
|
||||
- `code`: новое поле конфига появилось в `config.example.toml` с описанием
|
||||
назначения, диапазона и единиц? ([conventions/config.md](conventions/config.md))
|
||||
- `specs`: не завелось ли поведение, которого спека не заказывала — тихий
|
||||
дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле?
|
||||
- `architecture`: не появился ли второй способ делать то, что уже делается —
|
||||
второе место, где генерится время или id, второй парсер источника, вторая
|
||||
логика целевых имён мимо `naming`?
|
||||
([architecture.md](architecture.md) → «Единые точки проекта»)
|
||||
|
||||
### Триггеры профиля
|
||||
|
||||
Уточняет умолчания конвейера, не отменяет их.
|
||||
|
||||
- **`deep`** — есть миграция в `internal/store/migrations/`; появляется новый
|
||||
пакет `internal/*`; меняется сигнатура публичной команды воркера; трогается
|
||||
раскладка файлов, построение целевых путей или удаление ссылок; трогается
|
||||
разбор недоверенного входа.
|
||||
- **`standard`** — меняется поведение, видимое снаружи: REST-эндпоинт,
|
||||
htmx-путь, набор или семантика состояний загрузки, формат сообщения бота,
|
||||
поле конфига.
|
||||
- **`quick`** — всё остальное: локальный багфикс, документация, тесты.
|
||||
- **Независимая реализация (`reimpl`)** запускается, когда узел одновременно
|
||||
новый и имеет внешний оракул в виде спеки: новый парсер, новый провайдер за
|
||||
существующим интерфейсом, новая стадия конвейера распознавания.
|
||||
- «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram** —
|
||||
для единственного пользователя это и есть интерфейс.
|
||||
|
||||
### Недоступно проверке
|
||||
|
||||
**Не проверит ни один проход** — принципиальная граница, по факту промаха не
|
||||
пересматривается.
|
||||
|
||||
- История инцидентов на umbar и то, что уже ломалось в проде.
|
||||
- Поведение таблицы SQLite под реальным объёмом и профилем нагрузки: реального
|
||||
профиля нет ни у кого, кроме сервера.
|
||||
- Завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее
|
||||
поведение.
|
||||
- Качество распознавания как таковое: правильно ли LLM определил фильм — вопрос
|
||||
eval-харнеса и корпуса кейсов, а не ревью кода.
|
||||
- Суждение «этой функциональности не должно существовать».
|
||||
|
||||
**Перестали проверять сознательно** — пересматривается первым, как только
|
||||
что-то проскочило.
|
||||
|
||||
- **Идиоматичность Go — с 2026-08-04.** Проектный проход `idiom` (поимённая
|
||||
сверка с положениями Effective Go, Go Code Review Comments, стайлгайдов Uber
|
||||
и Google) удалён вместе с проектными копиями агентов при переезде на плагин
|
||||
`av-dev-pipeline`, который этот проход упразднил. Способные части переселены:
|
||||
эксперимент против поведения библиотеки и драйвера — в `ops`, «не изобретаем
|
||||
ли то, что уже есть в библиотеке» — в `architecture`. **Различение
|
||||
«идиоматично против распространено» теперь не спрашивает никто.** Класс
|
||||
обратимый: портит форму кода, не данные. Пересмотр — задача
|
||||
`quality-review-agents`.
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
Запись на каждый воспроизведённый дефект, **сразу**, а не ретроспективно: со
|
||||
временем теряется не факт, а причина непоймания. Проскочившие — эвал-сет для
|
||||
калибровки конвейера, выборка по пометке.
|
||||
|
||||
Форма:
|
||||
|
||||
<!-- копия: журнал-дефектов-форма из av-dev-pipeline/skills/review-pipeline/references/review-journal.md -->
|
||||
## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]
|
||||
|
||||
- **Где:** путь:строка либо «конвейер, а не код»
|
||||
- **Симптом:** как обнаружилось, кем и когда
|
||||
- **Причина:** что на самом деле было не так
|
||||
- **Чем воспроизведён:** тест, команда, замер — с числами
|
||||
- **Почему не поймали:** только для проскочивших — какой проход обязан был найти
|
||||
и что ему помешало
|
||||
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
|
||||
проекта — либо «ничего, цена поимки выше цены дефекта»
|
||||
<!-- /копия: журнал-дефектов-форма -->
|
||||
|
||||
### Записи
|
||||
|
||||
Пока пусто. Журнал заведён 2026-07-23 вместе с переработкой конвейера
|
||||
([ADR-2026-07-23-review-pipeline-generative](adr/ADR-2026-07-23-review-pipeline-generative.md));
|
||||
случаи до этой даты не восстанавливались — восстановленная постфактум причина
|
||||
непоймания недостоверна, а именно она и нужна.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Журнал проскочивших дефектов
|
||||
|
||||
Всё, что прошло конвейер ревью и всплыло позже — на ручном просмотре, при
|
||||
отладке, на umbar в проде. Это лучший эвал-сет, который вообще возможен:
|
||||
синтетические дефекты смещены в сторону тех, которые уже умеешь придумывать, а
|
||||
журнал — каталог реальных слепых пятен.
|
||||
|
||||
**Заполнять сразу, по горячим следам.** Ретроспективные записи бесполезны:
|
||||
теряется именно то, ради чего журнал заведён, — причина непоймания. Через неделю
|
||||
остаётся «ну, не заметил».
|
||||
|
||||
Каждая запись превращается в пробу для
|
||||
[калибровки](../../.claude/skills/review-pipeline/references/calibration.md) того
|
||||
прохода, который должен был поймать дефект.
|
||||
|
||||
## Как заполнять
|
||||
|
||||
Одна запись — один дефект, новые сверху. Шаблон:
|
||||
|
||||
```markdown
|
||||
## YYYY-MM-DD — <краткое последствие>
|
||||
|
||||
- **Класс дефекта:** <поведение вне спеки / гонка / отсутствующая наблюдаемость / деградация зависимости / форма решения / …>
|
||||
- **Где всплыл:** <ручной просмотр / отладка / прод umbar / отчёт пользователя>
|
||||
- **Стоимость обнаружения:** <минуты отладки, потерянные данные, часы простоя>
|
||||
- **Что произошло:** <симптом → причина, со ссылкой на файл:строку и коммит>
|
||||
- **Какой проход должен был поймать:** <имя агента>
|
||||
- **Почему не смог:** <не было во входе / не было в чек-листе / оракул был недоступен / проход не запускался в этом профиле / принципиально недоступно>
|
||||
- **Был ли доступен оракул:** <да, какой / нет>
|
||||
- **Действие:** <проба добавлена в калибровку / конвенция / правило линтера / профиль изменён / признано неавтоматизируемым>
|
||||
```
|
||||
|
||||
Поле «Почему не смог» — главное. Если ответ «не было в чек-листе», лечится
|
||||
generative-проходом, а не удлинением чек-листа. Если «не было во входе» — лечится
|
||||
входом. Если «оракул был недоступен» — лечится гейтом. Если «принципиально
|
||||
недоступно» — запись всё равно нужна: она пополняет раздел честного предела в
|
||||
скилле и отвечает на будущий вопрос «почему ревью это не поймало».
|
||||
|
||||
## Записи
|
||||
|
||||
Пока пусто — журнал заведён 2026-07-23 вместе с переработкой конвейера.
|
||||
Накопленные до этой даты случаи вносятся по мере того, как вспоминаются, с
|
||||
пометкой «восстановлено постфактум, причина непоймания недостоверна».
|
||||
@@ -0,0 +1,108 @@
|
||||
# Модель угроз
|
||||
|
||||
## Периметр
|
||||
|
||||
**Контур доверенный: домашняя LAN, публичного интернета здесь нет — не
|
||||
выдумывай его.** Сервис слушает `:8080` на хосте umbar внутри локальной сети,
|
||||
наружу не проброшен, доменного имени и обратного прокси у него нет. Веб-UI и
|
||||
REST API работают **без авторизации** осознанно; поле `[http].trusted_subnets`
|
||||
зарезервировано, но не применяется.
|
||||
|
||||
Целевой периметр — **тот же**: выставлять jellybit в интернет не планируется.
|
||||
Если это когда-нибудь изменится, первым шагом идёт задача «Авторизация веб-UI»,
|
||||
и модель угроз пересматривается целиком, а не дополняется.
|
||||
|
||||
**Находки строятся против сегодняшнего периметра.** «Любой может открыть
|
||||
страницу и удалить загрузку» — это принятое решение, а не дефект; чтобы стать
|
||||
дефектом, ему нужен путь снаружи LAN.
|
||||
|
||||
## Недоверенный вход
|
||||
|
||||
Что приходит извне и каким каналом. Всё перечисленное контролируется не нами и
|
||||
может быть враждебным по содержанию, даже когда канал доверенный.
|
||||
|
||||
| Что | Канал | Чем опасно |
|
||||
| --- | --- | --- |
|
||||
| Имена файлов и каталогов раздачи | qBittorrent API | разделители пути, `..`, управляющие символы, юникод-омоглифы, длина сверх лимита ФС |
|
||||
| Имя торрента, поля magnet (`dn`, `tr`) | приём | то же плюс подстановка в промпт |
|
||||
| Байты `.torrent` | приём (файл на форме) | bencode-разбор недоверенных данных, размер, вложенность |
|
||||
| Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком |
|
||||
| Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя |
|
||||
| **Ответ LLM** | HTTP к эндпоинту | целиком под влиянием входа выше; названия, годы, номера сезонов и серий, из которых строится целевой путь |
|
||||
| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь |
|
||||
| Ответы qBittorrent | HTTP | пути, состояния, размеры |
|
||||
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
|
||||
|
||||
**Выход LLM не отвечает за безопасность.** Инъекция в промпт считается
|
||||
состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути.
|
||||
|
||||
## Из чего строятся пути и ключи
|
||||
|
||||
Отсюда строится выход за пределы песочницы — самое ценное место для враждебного
|
||||
прохода.
|
||||
|
||||
- **Целевой путь** = `paths.movies`/`paths.series` + имя папки тайтла + (для
|
||||
сериала) `Season NN` + имя файла + расширение. Имя папки и файла собираются в
|
||||
`internal/naming` из полей распознавания: `title`, `original_title`, `year`,
|
||||
`season`, `episode`, provider-тег вида `[tmdbid-…]`. **Все эти поля —
|
||||
недоверенный вход.**
|
||||
- **Правило:** компоненты санитизируются (убираются разделители пути, `..`,
|
||||
управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго
|
||||
под** соответствующей библиотекой, иначе операция отклоняется. Проверка на
|
||||
результате, а не на входе.
|
||||
- **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из
|
||||
`/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и
|
||||
линкуем**; писать в `paths.downloads` нельзя вообще.
|
||||
- **Ключ идентичности загрузки** — инфохэш (v1 SHA-1 / v2 SHA-256), нормализуется
|
||||
в lowercase hex фиксированной длины. Крафт-магнет с чужим или подставным
|
||||
хешем — известное направление атаки на владение (задача в беклоге).
|
||||
- **Идентификаторы сущностей** — ULID, `ident.Parse` на каждой входной границе:
|
||||
строка из запроса не доходит до SQL непроверенной.
|
||||
- **Владение целевым путём** — один путь, один владелец-`file_link`; смена
|
||||
владельца возможна только на свободном пути.
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
- **Telegram** — allowlist `telegram.allowed_user_ids`, **fail-closed**: пустой
|
||||
список запрещает всем. Это единственное реальное разграничение в системе.
|
||||
- **Веб-UI и REST** — не разграничивают ничего: любой в LAN может всё.
|
||||
Осознанно, см. «Периметр».
|
||||
- **Файловая система** — контейнер под `1000:1000`, смонтирована только
|
||||
песочница `/srv/media` и собственные каталоги `/config` (ro) и `/data`.
|
||||
`/srv/applications` целиком в контейнер не попадает.
|
||||
- **qBittorrent** — логин и пароль WebUI; docker-подсеть намеренно не входит в
|
||||
его LAN-whitelist.
|
||||
|
||||
## Что чувствительнее чего
|
||||
|
||||
1. **Медиафайлы в раздаче** — единственное, что невосстановимо. Отсюда
|
||||
инвариант «источник неприкосновенен» и гард последней копии в Undo
|
||||
(`nlink <= 1` → отказ целиком).
|
||||
2. **База `/data/jellybit.db`** — восстановима только перезапуском всей работы:
|
||||
теряется всё in-flight состояние и история привязок.
|
||||
3. **Секреты**: пароль qBittorrent, ключи LLM и метабаз, токен Telegram,
|
||||
API-ключ Jellyfin. Живут только в `config.toml` (`0600`, рендерит деплой) и
|
||||
**никогда не попадают в логи, в диагностику состояния и в ответы API** —
|
||||
правило в [conventions/logging.md](conventions/logging.md).
|
||||
4. **Библиотечные хардлинки** — восстановимы повторной раскладкой, поэтому
|
||||
ниже по шкале, хотя видны пользователю первыми.
|
||||
|
||||
## Что вне модели
|
||||
|
||||
Перечислено явно: против этого находки не строятся.
|
||||
|
||||
- **Злонамеренный участник LAN.** Сеть считается доверенной; «сосед по вайфаю
|
||||
удалил загрузку через веб-UI» — не дефект в сегодняшнем периметре.
|
||||
- **Злонамеренный оператор.** Владелец может всё по определению, включая
|
||||
удаление раздачи вместе с файлами.
|
||||
- **Компрометация соседних сервисов** — qBittorrent, Jellyfin, LLM-эндпоинта,
|
||||
хоста umbar. Если qBittorrent врёт про пути, мы проиграли раньше.
|
||||
- **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов,
|
||||
бесконечный ответ LLM — это вопросы устойчивости и ресурсов
|
||||
([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности.
|
||||
Отсутствие лимита на размер ответа LLM — известный пробел, задача в беклоге.
|
||||
- **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота.
|
||||
- **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга.
|
||||
- **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и
|
||||
метабазы; это принято сознательно, прокси в конфиге есть.
|
||||
- **Физический доступ к серверу и бекапам.**
|
||||
@@ -1,26 +0,0 @@
|
||||
# Спецификации
|
||||
|
||||
Живые документы о том, как устроена система — целевое и актуальное
|
||||
состояние. В отличие от ADR, спецификации **изменяемы**: их правят по
|
||||
мере развития проекта и держат в соответствии с кодом. В отличие от
|
||||
черновиков, описывают принятое и реализуемое, а не идеи.
|
||||
|
||||
## Соглашения
|
||||
|
||||
- Имя файла — `kebab-topic.md`, без дат (дата живёт в git-истории).
|
||||
- Одна спецификация — одна тема.
|
||||
- Если решение требует объяснения «почему именно так» с долгим следом —
|
||||
заведи ADR и сошлись на него из спецификации.
|
||||
|
||||
## Записи
|
||||
|
||||
- [architecture.md](architecture.md) — общее устройство: компоненты,
|
||||
транспорты, хранилище, раскладка, деплой.
|
||||
- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний,
|
||||
переходы, сопоставление состояний qBittorrent.
|
||||
- [recognition.md](recognition.md) — распознавание контента и модель
|
||||
уверенности.
|
||||
- [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии
|
||||
на случай, когда система не уверена.
|
||||
- [jellyfin-layout.md](jellyfin-layout.md) — конвенции именования файлов
|
||||
Jellyfin, в которые раскладываем.
|
||||
@@ -1,280 +0,0 @@
|
||||
# Архитектура
|
||||
|
||||
## Назначение
|
||||
|
||||
Jellybit принимает торрент с текстовым контекстом, скачивает его через
|
||||
qBittorrent, определяет содержимое (фильм или сериал с сезонами и
|
||||
сериями) и раскладывает файлы по конвенциям Jellyfin — хардлинками, не
|
||||
трогая исходную раздачу.
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Один статический бинарь.** Доставка — копированием на сервер. См.
|
||||
[ADR-2026-06-13-go-single-binary](../adr/ADR-2026-06-13-go-single-binary.md).
|
||||
- **Источник неприкосновенен** (жёсткий инвариант). jellybit делает
|
||||
только `mkdir`, `link(2)` и `unlink` *своих* целевых ссылок (для undo).
|
||||
Никогда не `unlink`/`rename` под `paths.downloads`. См.
|
||||
[ADR-2026-06-13-hardlinks](../adr/ADR-2026-06-13-hardlinks.md).
|
||||
- **Выход распознавания недоверенный.** Имена файлов, контекст и
|
||||
сообщение бота управляются извне. Целевой путь всегда санитизируется и
|
||||
проверяется, что он строго под `paths.movies`/`paths.series` (см.
|
||||
«Раскладка файлов»). Безопасность держится на валидации, не на промпте.
|
||||
- **Единое ядро, тонкие транспорты.** Логика приёма — в use-case
|
||||
`Ingest`; переходы состояний принадлежат `worker`. HTTP API, веб-UI и
|
||||
Telegram складывают команды, `worker` их сериализует.
|
||||
- **Опциональные внешние зависимости.** Базы метаданных (TMDB/TVDB)
|
||||
включаются конфигом; без них сервис работает на одном LLM, но
|
||||
авто-раскладка без матча в базе не делается (см. recognition.md).
|
||||
- **Минимум компонентов.** В духе umbar — без лишних сервисов.
|
||||
|
||||
## Компоненты
|
||||
|
||||
| Пакет | Ответственность |
|
||||
| ----------- | -------------------------------------------------------- |
|
||||
| `ingest` | use-case приёма загрузки, общий для всех транспортов |
|
||||
| `qbt` | клиент qBittorrent WebUI API (сессия, добавление, опрос) |
|
||||
| `worker` | владелец машины состояний; поллинг, сериализация команд |
|
||||
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
|
||||
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
|
||||
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
|
||||
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
|
||||
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
|
||||
| `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) |
|
||||
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
|
||||
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
|
||||
| `config` | загрузка TOML-конфига |
|
||||
|
||||
## Поток и машина состояний
|
||||
|
||||
Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
|
||||
полный граф состояний с переходами и сопоставление состояний qBittorrent —
|
||||
в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами
|
||||
владеет `worker`, он же сериализует команды транспортов под per-download
|
||||
блокировкой, а состояние персистентно в SQLite.
|
||||
|
||||
## Транспорты
|
||||
|
||||
Все ведут в один `Ingest(req)`; действия пользователя (apply / refine /
|
||||
reject / defer / undo) — команды к `worker`:
|
||||
|
||||
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
|
||||
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
|
||||
`http.trusted_subnets` зарезервировано, но **пока не применяется**:
|
||||
деплой только в локальную сеть без доступа из интернета, поэтому
|
||||
allowlist-middleware и авторизацию отложили — задача
|
||||
[«Авторизация веб-UI»](../backlog/avtorizaciya-web-ui.md) в беклоге.
|
||||
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
|
||||
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
|
||||
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
|
||||
- **CLI** — `jellybit add <magnet> --context "..."` для отладки.
|
||||
|
||||
Источник (magnet / `.torrent` / URL) **отдаём в qBittorrent** — он сам
|
||||
скачивает; jellybit не делает исходящих запросов на пользовательский URL
|
||||
(SSRF исключён).
|
||||
|
||||
## Хранилище
|
||||
|
||||
SQLite. Полная схема (таблицы, поля, связи) — [database.md](database.md),
|
||||
поддерживается вместе с миграциями. Схема покрывает приём, цикл ревью и
|
||||
откат:
|
||||
|
||||
- `download` — `id`, тип и значение источника, контекст, `infohash`,
|
||||
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
|
||||
(infohash может появиться позже приёма — для magnet без метаданных.)
|
||||
- `recognition` — попытки распознавания: `download_id`, `attempt_no`,
|
||||
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`),
|
||||
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и
|
||||
структурированный `plan` (каноничный JSON `recognize.Plan` — файл →
|
||||
роль/сезон/серия для превью и применения).
|
||||
- `hint` — накопленные подсказки человека (`download_id`, текст, время).
|
||||
- `override` — запиненные ручные правки полей (перераспознавание не
|
||||
затирает).
|
||||
- `metadata_candidate` — кандидаты базы для выбора (`recognition_id`,
|
||||
provider, id, название, год, выбран ли).
|
||||
- `file_link` — `download_id`, `apply_batch_id`, исходный → целевой путь,
|
||||
вид (видео/субтитры/…), статус, время. Батч нужен для точечного undo.
|
||||
|
||||
### Идентификация торрента и повторное добавление
|
||||
|
||||
Идентификатор торрента — **infohash** (v1 SHA-1 / v2 SHA-256): берём из
|
||||
magnet (`xt=urn:btih:`) или считаем из `.torrent`; этим же оперирует сам
|
||||
qBittorrent. Идемпотентность — **только для активных задач**: повторное
|
||||
добавление, пока задача в работе, присоединяется к ней. Если прежняя
|
||||
задача для этого infohash уже терминальна (`done`/`cancelled`/`failed`/
|
||||
`reverted`), новое добавление заводит **новую** задачу — перекачать тот же
|
||||
торрент спустя месяцы можно без проблем (покажем, что infohash уже
|
||||
обрабатывался, и прежний результат). Разные раздачи одного фильма (репаки)
|
||||
имеют разные infohash → разные задачи.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
TOML. Полный список параметров с комментариями — в
|
||||
[`config.example.toml`](../../config.example.toml) (источник истины, не
|
||||
дублируем его здесь). Реальный `config.toml` рендерится при деплое
|
||||
Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под
|
||||
ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится.
|
||||
|
||||
Структура секций: `[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]`.
|
||||
|
||||
## Логирование
|
||||
|
||||
Структурированный JSON через `log/slog`, в stdout (docker подбирает).
|
||||
Каждая загрузка проходит со сквозным идентификатором; решения
|
||||
распознавания (почему авто/ревью) и операции с файлами логируются явно.
|
||||
|
||||
## Раскладка файлов
|
||||
|
||||
`layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
|
||||
Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
|
||||
|
||||
- **Линкуем только файлы.** Целевые каталоги создаём `mkdir -p` (режим
|
||||
0755, владелец `1000:1000`); каталог не хардлинкуется.
|
||||
- **Путь сначала санитизируется:** из `title`/сезона/серии убираем
|
||||
разделители пути, `..`, управляющие символы; финальный
|
||||
`filepath.Clean`-путь обязан быть строго под библиотекой, иначе отказ
|
||||
(защита от traversal).
|
||||
- **Никогда не перезаписываем.** Цель существует и это тот же inode →
|
||||
готово (идемпотентно); существует и это другой файл → коллизия → review.
|
||||
- **Батч фиксируется в БД:** статус по каждому файлу; повтор после сбоя
|
||||
доводит начатое (идемпотентно) либо откатывается.
|
||||
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если
|
||||
путь под `paths.movies`/`series` — источник недосягаем.
|
||||
- **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и
|
||||
цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит.
|
||||
Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС
|
||||
(`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а
|
||||
копирует файл (через временный файл + атомарный `rename`) и пишет в лог
|
||||
`Warn` (статус ссылки — `copied`): задача доходит до конца ценой
|
||||
дублирования места. Источник при этом всё равно не трогаем.
|
||||
|
||||
### Пути и контейнеры — единая песочница `/srv/media`
|
||||
|
||||
Весь медиа-стек лежит под одним каталогом и монтируется **идентично**
|
||||
(`/srv/media:/srv/media`) во все медиа-приложения:
|
||||
|
||||
```
|
||||
/srv/media/
|
||||
incomplete/ ← qBit качает сюда
|
||||
downloads/ ← готовые раздачи (источник хардлинка)
|
||||
movies/ series/ ← библиотека Jellyfin (цель хардлинка)
|
||||
```
|
||||
|
||||
Так как всё под одним mount'ом, и **хардлинк** (downloads → movies/series),
|
||||
и **мгновенный move** qBit (incomplete → downloads) работают — нет границ
|
||||
между точками монтирования (`EXDEV`). Путь из qBittorrent
|
||||
(`save_path`/`content_path`) уже равен хост-пути, трансляция не нужна
|
||||
(`path_map` — фолбэк, обычно пуст). Секреты и чужие приложения
|
||||
(`/srv/applications`) в эту песочницу не попадают.
|
||||
|
||||
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
|
||||
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
|
||||
SQLite — отдельным mount'ом `/srv/applications/jellybit/data`, конфиг —
|
||||
отдельным `/srv/applications/jellybit/config`.
|
||||
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
|
||||
`/srv/media`, иначе в индекс попадут downloads/incomplete).
|
||||
|
||||
## Пересканирование Jellyfin
|
||||
|
||||
Когда наши библиотечные хардлинки меняются, `worker` неблокирующе просит Jellyfin
|
||||
пересканировать медиатеку, чтобы плеер не держал битые пути и быстрее подхватил
|
||||
новые файлы. Триггерят входы в `done` (файлы разложены), `reverted` (Undo снял
|
||||
ссылки) и `deleted` (Delete снял ссылки / сверка констатировала их отсутствие) —
|
||||
гейт по состоянию-цели в едином чекпоинте перехода, поэтому ловит и
|
||||
пользовательские Undo/Delete, и reconcile-производный `deleted`. Промежуточный
|
||||
рассинхрон (`target_missing`/`orphaned`) не сканируем — задача ждёт
|
||||
relink/лечения. Включается конфигом `[jellyfin]` (по умолчанию выключено); без
|
||||
него скан не дёргается.
|
||||
|
||||
- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан
|
||||
инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не
|
||||
делаем — сложнее и не в духе сервиса («минимум компонентов»).
|
||||
- **Авторизация** — API-ключ Jellyfin в заголовке `X-Emby-Token`.
|
||||
- **Неблокирующе и вне `w.mu`** (как пинги Telegram): вызов уходит в сеть в
|
||||
отдельной горутине с фоновым контекстом. Недоступность Jellyfin не влияет на
|
||||
состояние задачи — ошибка лишь логируется (`Warn`).
|
||||
- **Адресация** — по имени сервиса в общей docker-сети (`http://jellyfin:8096`).
|
||||
|
||||
## Деплой
|
||||
|
||||
Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin
|
||||
(см. [ADR-2026-07-24-local-image-build](../adr/ADR-2026-07-24-local-image-build.md)).
|
||||
Сборка: статический бинарь (`GOOS=linux GOARCH=amd64 CGO_ENABLED=0`,
|
||||
сервер на Intel N150) и **полный образ** собираются локально на control-хосте
|
||||
(`task image` упаковывает бинарь в `distroless/static`). Готовый образ едет
|
||||
на сервер через `docker save`/`load` (роль `app_image` в umbar), там и
|
||||
запускается. Go-тулчейн и `docker build` на сервере не нужны.
|
||||
|
||||
Параметры запуска (в umbar-compose):
|
||||
|
||||
- **Общая docker-сеть** (external, напр. `media-net`) — jellybit, qBit и
|
||||
(позже) Jellyfin в ней; адресуемся по именам (`http://qbit:8989`,
|
||||
`http://jellyfin:8096`). Веб-UI jellybit публикуем на хост (`8080:8080`)
|
||||
для LAN. Учесть: qBit валидирует Host-заголовок — выставить
|
||||
`WebUI\ServerDomains=*` (umbar); LLM на хосте достаётся через
|
||||
`host.docker.internal` (`extra_hosts: host-gateway`).
|
||||
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
|
||||
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
|
||||
- **mount `/srv/media`** (единая песочница) — для хардлинков и move
|
||||
(см. «Пути и контейнеры»); каталоги jellybit — отдельно.
|
||||
- **mount конфига** `/srv/applications/jellybit/config` → `/config` (ro):
|
||||
`config.toml` (0600). Восстановим при деплое (рендерит плейбук umbar) —
|
||||
бекапить не нужно.
|
||||
- **mount данных** `/srv/applications/jellybit/data` → `/data`: SQLite
|
||||
(`/data/jellybit.db`). Бекапить-и-не-терять — без него редеплой стёр бы
|
||||
всё in-flight состояние.
|
||||
- **healthcheck** на `/healthz`.
|
||||
|
||||
Разделение ответственности:
|
||||
|
||||
- **jellybit** (этот репозиторий) — статический бинарь и `Dockerfile`.
|
||||
- **umbar** — оркестрация: доставка артефактов, `docker build`, запуск
|
||||
через docker compose (`playbook-jellybit.yml`) с параметрами выше.
|
||||
|
||||
## Предполагаемая структура репозитория
|
||||
|
||||
```
|
||||
cmd/jellybit/ точка входа, сборка зависимостей
|
||||
internal/
|
||||
ingest/ qbt/ worker/ recognize/ llm/ metadata/
|
||||
layout/ store/ httpapi/ tgbot/ config/
|
||||
migrations/ миграции SQLite
|
||||
web/templates/ шаблоны веб-UI
|
||||
docs/ specs / adr / drafts
|
||||
Dockerfile .dockerignore config.example.toml
|
||||
```
|
||||
|
||||
## Решённые вопросы
|
||||
|
||||
- Пути/контейнеры — единая песочница `/srv/media:/srv/media` (подпапки
|
||||
incomplete/downloads/movies/series) монтируется идентично во все
|
||||
медиа-приложения; путь из API = хост-путь; хардлинк и move в пределах
|
||||
одного mount'а. `/srv/applications` в песочницу не попадает.
|
||||
- Сеть — общая docker-сеть, адресация по именам (`qbit:8989`); host-режим
|
||||
не используем. qBit: `WebUI\ServerDomains=*`; LLM на хосте — через
|
||||
`host.docker.internal`.
|
||||
- qBit: «incomplete» включён (`/srv/media/incomplete`), завершение
|
||||
проходит через `moving`; jellybit авторизуется логином/паролем
|
||||
(docker-подсеть не входит в LAN-whitelist qBit).
|
||||
- Внешние базы — HTTP-прокси на клиента (`proxy` в `[metadata.*]`/`[llm]`).
|
||||
- Идентификатор торрента — infohash; идемпотентность только для активных
|
||||
задач (повторная закачка спустя время → новая задача).
|
||||
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
|
||||
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
|
||||
- Пересканирование Jellyfin при изменении наших ссылок — `POST /Library/Refresh`
|
||||
(скан всех библиотек, инкрементальный), неблокирующе на входе в `done`/
|
||||
`reverted`/`deleted`; опц., включается `[jellyfin]`.
|
||||
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
|
||||
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
|
||||
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
|
||||
- Форма запуска — docker, образ собирается на сервере; контейнер под
|
||||
`1000:1000`, в общей docker-сети, mount `/srv/media` + data-том.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- (пока нет)
|
||||
@@ -1,107 +0,0 @@
|
||||
# Конвенции раскладки Jellyfin
|
||||
|
||||
> **Источник истины переехал в OpenSpec** — `openspec/specs/file-layout/` (имена,
|
||||
> хардлинки, коллизия, copy-fallback). Владение путём (`superseded`) и безопасный
|
||||
> undo (`nlink<=1`) — в `openspec/specs/state-reconciliation/`. Этот файл —
|
||||
> справочный нарратив; при расхождении верна спека OpenSpec.
|
||||
|
||||
Целевые имена и структура, в которые jellybit раскладывает файлы
|
||||
хардлинками. Источники:
|
||||
[Movies](https://jellyfin.org/docs/general/server/media/movies),
|
||||
[Shows](https://jellyfin.org/docs/general/server/media/shows).
|
||||
|
||||
## Фильмы
|
||||
|
||||
```
|
||||
movies/
|
||||
Дюна Часть вторая (2024) [tmdbid-693134]/
|
||||
Дюна Часть вторая (2024).mkv
|
||||
Дюна Часть вторая (2024).ru.srt
|
||||
```
|
||||
|
||||
- Папка и файл — `Название (Год)`.
|
||||
- provider-id в имени папки (`[tmdbid-...]`) добавляется при работе с
|
||||
базой — снимает неоднозначность для русских названий, которые Jellyfin
|
||||
иначе может опознать неверно.
|
||||
- Внешние субтитры — `Имя.<lang>[.flag].srt` (флаги `forced`/`sdh`/
|
||||
`default`/`hi`), напр. `…ru.forced.srt`; база имени совпадает с именем
|
||||
видеофайла. Пары VobSub — `.idx` + `.sub`.
|
||||
|
||||
## Сериалы
|
||||
|
||||
```
|
||||
series/
|
||||
Название (2024) [tvdbid-123456]/
|
||||
Season 01/
|
||||
Название (2024) S01E01.mkv
|
||||
Название (2024) S01E02.mkv
|
||||
```
|
||||
|
||||
- provider-id — на папке сериала.
|
||||
- Сезоны — `Season 01`, файлы — `... SxxEyy`.
|
||||
- **Сходимость папки:** при подтверждённом матче база папки (имя+год) наследуется
|
||||
от живой папки-якоря того же `(provider, provider_id)` (существующей на диске), а
|
||||
не печатается заново из выхода LLM — так второй сезон ложится в ту же папку, что
|
||||
и первый. Несколько разных живых папок одного матча → review. Источник истины —
|
||||
`openspec/specs/file-layout/` («Сходимость базы папки…»).
|
||||
|
||||
## Сопоставление источник → цель
|
||||
|
||||
Источник берём по пути из qBittorrent (`save_path` + относительное имя
|
||||
файла из `/torrents/files`, которое уже содержит корневую папку
|
||||
многофайловой раздачи; это уже хост-путь, `path_map` — фолбэк). Для каждого
|
||||
распознанного **файла** (не каталога) создаётся **хардлинк** в
|
||||
`paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
|
||||
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
|
||||
inode общий — диск не дублируется.
|
||||
|
||||
Целевое имя строится из распознанных полей и **санитизируется** (без
|
||||
разделителей пути, `..`, управляющих символов); финальный путь обязан
|
||||
быть строго под библиотекой. Существующую цель **не перезаписываем** (тот
|
||||
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
|
||||
в [architecture.md](architecture.md) → «Раскладка файлов».
|
||||
|
||||
## Владение целевым путём
|
||||
|
||||
Целевой путь принадлежит **одной** загрузке. Когда новая раскладка
|
||||
успешно ложится на путь, который раньше занимала другая загрузка (путь к
|
||||
этому моменту **свободен** — иначе была бы коллизия → review, чужой файл
|
||||
не перезаписываем), владение переходит к новой загрузке: прежние записи
|
||||
`file_link` на этот путь помечаются статусом `superseded` и перестают
|
||||
считаться целью прежней загрузки. Это нужно сверке с реальностью: иначе
|
||||
повторная закачка того же фильма (например, в другом качестве) по тому же
|
||||
пути ложно «воскрешала» бы удалённую задачу — см.
|
||||
[workflow.md](workflow.md) → «Сверка с реальностью». `superseded`-ссылки
|
||||
не считаются целью при сверке и не снимаются в `Undo`.
|
||||
|
||||
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
|
||||
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
|
||||
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
|
||||
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
|
||||
предупреждением в лог — см. architecture.md → «Раскладка файлов».
|
||||
|
||||
## Безопасный undo (не снимать последнюю копию)
|
||||
|
||||
`Undo` снимает **лишний** хардлинк, а не единственный файл. Перед удалением
|
||||
батча `layout` проверяет каждую цель: если исходный файл уже не существует
|
||||
**или** у цели не осталось других жёстких ссылок (`nlink <= 1`), это —
|
||||
последняя копия данных, и весь `Undo` отклоняется целиком (ошибка
|
||||
`ErrLastCopy`), не сняв ни одной ссылки (частичный откат тоже стёр бы часть
|
||||
данных). Так нарушенный инвариант «источник неприкосновенен» (источник
|
||||
удалён вручную) не приводит к потере данных. Отсутствующую цель `Undo`
|
||||
пропускает как уже снятую (идемпотентность). Связь с состояниями
|
||||
рассинхрона — [workflow.md](workflow.md) → «Сверка с реальностью».
|
||||
|
||||
## Крайние случаи
|
||||
|
||||
- **Многофайловый фильм** (части) — стэкинг по точному токену Jellyfin
|
||||
(`… - part1`/`cd1`); точный формат уточнить при реализации.
|
||||
- **Редакции** — `Имя (Год) [edition-Director's Cut]` либо отдельные
|
||||
версии в папке фильма.
|
||||
- **Двойная серия** в одном файле — `… SxxEyy-Eyy`.
|
||||
- **Спецвыпуски** — `Season 00`.
|
||||
- **Сезон-пак** — серии в один `Season xx`; смешанный пак — по per-file
|
||||
сезонам.
|
||||
- **Несколько аудиодорожек** — обычно внутри mkv, не наша забота.
|
||||
- **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка
|
||||
([задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md)).
|
||||
@@ -1,144 +0,0 @@
|
||||
# Распознавание контента
|
||||
|
||||
> **Источник истины переехал в OpenSpec.** Актуальные требования —
|
||||
> `openspec/specs/recognition/` (разбор сигналов LLM) и
|
||||
> `openspec/specs/metadata-match/` (сверка с внешними базами). Этот файл остаётся
|
||||
> справочным нарративом; при расхождении верна спека OpenSpec.
|
||||
|
||||
## Задача
|
||||
|
||||
По доступным сигналам определить: фильм или сериал; каноническое название
|
||||
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
|
||||
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
|
||||
и решение «авто или review» (как оно встраивается в машину состояний —
|
||||
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).
|
||||
|
||||
## Сигналы
|
||||
|
||||
- Имя торрента и структура каталогов.
|
||||
- Список файлов с размерами и расширениями. Абсолютный путь источника
|
||||
восстанавливаем как `save_path` из qBit (= хост-путь; `path_map` обычно
|
||||
тождественен) + относительное имя файла из `/torrents/files`. Имя уже
|
||||
включает корневую папку для многофайловых торрентов, поэтому префикс —
|
||||
именно `save_path`, а не `content_path` (последний удвоил бы корневую
|
||||
папку и сломал бы однофайловые раздачи).
|
||||
- Текстовый контекст человека (+ накопленные подсказки из review).
|
||||
- Распарсенное сообщение торрент-бота (если через Telegram): название с
|
||||
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).
|
||||
|
||||
**Все сигналы недоверенные** — имя торрента, сообщение бота и контекст
|
||||
управляются извне и могут содержать инъекции. Выход LLM не отвечает за
|
||||
безопасность: целевой путь всё равно санитизируется и проверяется на
|
||||
выход за пределы библиотеки (см. architecture.md → «Раскладка файлов»).
|
||||
|
||||
## Конвейер
|
||||
|
||||
1. **Пред-парс** имени релиза (`go-ptn`): черновые название/год/сезон/
|
||||
серия и качество. Грубо, но бесплатно.
|
||||
2. **LLM** (через провайдер-абстракцию, см. ниже): получает сигналы и
|
||||
пред-парс, возвращает структурированный план в нашей схеме. Хорошо
|
||||
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
|
||||
контекст модели.
|
||||
3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
|
||||
названию+году, берём официальный id и каноническое имя, собираем
|
||||
кандидатов. TVMaze — без ключа, только сериалы; внешний id
|
||||
(TVDB/IMDb) из `externals` идёт в имя папки.
|
||||
- **Поиск по нескольким названиям** в порядке убывания силы ключа:
|
||||
`original_title` → локализованное `title` → `provider_hint`. Базы
|
||||
индексированы прежде всего по оригинальным названиям, поэтому
|
||||
оригинал — первым; останавливаемся, как только очередной ключ дал
|
||||
единичный сильный матч. Пустые и нормализованно-дублирующие ключи
|
||||
пропускаем (русский фильм, где оригинал = локализованное, дёргает
|
||||
базу один раз). Кандидатов для review копим из всех заходов.
|
||||
- **Локаль TMDB:** запрос передаёт `language` (по умолчанию `ru-RU`,
|
||||
настраивается `[metadata.tmdb].language`). Влияет только на
|
||||
локализованный `Title`/`Name`; `original_title`/`original_name`
|
||||
остаётся на языке оригинала, поэтому оригинальная сторона сравнения
|
||||
не страдает, а русская — сходится.
|
||||
- **Нормализация названий** при сравнении сводит `ё`→`е` («Тёмный» и
|
||||
«Темный» — одно название).
|
||||
4. **Оценка уверенности** и решение: авто или review.
|
||||
|
||||
## Структура ответа LLM (предварительная)
|
||||
|
||||
```
|
||||
type movie | series
|
||||
title каноническое название
|
||||
original_title оригинальное (обычно англ.) название — заполняется всегда:
|
||||
нет отдельного / российский контент → дублирует title;
|
||||
при неуверенности дублируем, а не выдумываем
|
||||
year год
|
||||
provider_hint строка для поиска в базе (НЕ итоговый id)
|
||||
files[] { src, role: main|episode|subtitle|extra|sample|ignore,
|
||||
season?, episode? } # season/episode — на файл
|
||||
confidence 0..1 — самооценка модели (вспомогательный сигнал)
|
||||
notes пояснения, неоднозначности
|
||||
```
|
||||
|
||||
Сезон/серия — **на файле**: так выражаются мультисезонные паки,
|
||||
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
|
||||
`provider_hint` — только подсказка для поиска; итоговые `provider`
|
||||
(`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
|
||||
и хранятся отдельно.
|
||||
|
||||
## Провайдер LLM
|
||||
|
||||
Доступ к LLM — за интерфейсом; реализация выбирается полем `[llm].type`
|
||||
(дискриминатор). Это позволяет подключать локальные модели и сторонние
|
||||
(в т.ч. китайские) эндпоинты — ради экономии и независимости от вендора.
|
||||
|
||||
- Первый и пока единственный тип — **`openai-compat`**: OpenAI-совместимый
|
||||
Chat Completions API (`base_url` + `api_key` + `model`). Подходят
|
||||
локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
|
||||
провайдеры (DeepSeek, Qwen и др.).
|
||||
- **Структурированный вывод надёжно:** просим JSON-режим
|
||||
(`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
|
||||
локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
|
||||
```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
|
||||
при ошибке разбора ретраим, передавая модели саму ошибку и схему в
|
||||
промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
|
||||
**review** (не в `failed`) с причиной «ответ LLM не разобран».
|
||||
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая
|
||||
`recognize`.
|
||||
|
||||
## Модель уверенности
|
||||
|
||||
Почему авто только при матче в базе, а не по самооценке LLM —
|
||||
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).
|
||||
|
||||
Авто-раскладка — только если выполнено **всё**:
|
||||
|
||||
1. **Подтверждённый матч в базе** — единственный сильный результат
|
||||
TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
|
||||
база выключена) → всегда review.** Это и закрывает основной кейс
|
||||
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
|
||||
2. **Структурная валидация** без предупреждений:
|
||||
- фильм: ровно один основной видеофайл (семплы/экстра/ignore отброшены);
|
||||
- сериал: число серий бьётся с базой, нумерация S·E консистентна, без
|
||||
пропусков, дублей и неоднозначных спецвыпусков.
|
||||
3. **Согласованность сигналов** — пред-парс (`go-ptn`) и LLM не
|
||||
противоречат по типу/названию/году.
|
||||
|
||||
Самооценку LLM (`confidence`) учитываем как вспомогательный сигнал, но
|
||||
**не как единственный гейт**: она плохо откалибрована и поддаётся
|
||||
инъекции. Решают матч в базе и валидация.
|
||||
|
||||
Иначе — **review** ([review-ux.md](review-ux.md)) с явной причиной.
|
||||
|
||||
## Что делаем с краёв
|
||||
|
||||
- Семплы/«экстра»/мусор → роль `ignore` (эвристики размер/имя + LLM).
|
||||
- Внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) привязываем
|
||||
к видео и именуем по Jellyfin (`*.ru.srt`).
|
||||
- Сезон-паки разбираем по сериям; смешанные паки, спецвыпуски (`Season
|
||||
00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode;
|
||||
любая неоднозначность → review.
|
||||
- Аниме с абсолютной нумерацией — отдельный крайний случай,
|
||||
[задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md).
|
||||
|
||||
## На будущее
|
||||
|
||||
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
|
||||
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с
|
||||
бинарём). Задача [«guessit как сервис-спутник»](../backlog/guessit-sputnik.md)
|
||||
в беклоге.
|
||||
@@ -1,167 +0,0 @@
|
||||
# Ревью раскладки человеком
|
||||
|
||||
> **Источник истины переехал в OpenSpec** — `openspec/specs/review/`. Этот файл
|
||||
> остаётся справочным нарративом (UI-макеты, разбор сценариев); при расхождении
|
||||
> верна спека OpenSpec.
|
||||
|
||||
Что происходит, когда система не уверена в распознавании и не
|
||||
раскладывает файлы автоматически. Когда именно наступает ревью — см.
|
||||
[recognition.md](recognition.md); место состояния `review` в общем потоке —
|
||||
[workflow.md](workflow.md); конвенции целевых имён —
|
||||
[jellyfin-layout.md](jellyfin-layout.md).
|
||||
|
||||
Главный принцип: ревью — это **петля «догадка → подсказка человека →
|
||||
перераспознавание»**, а не статичное «ок/нет». Человек остаётся
|
||||
супервизором, а не оператором ручного ввода.
|
||||
|
||||
## Когда наступает
|
||||
|
||||
Загрузка уходит в `review`, если сработал любой триггер модели
|
||||
уверенности: низкая самооценка LLM; нет матча в базе (или несколько
|
||||
кандидатов); структурная валидация ругается (у фильма >1 основного
|
||||
файла; число серий не бьётся с базой; дыры/дубли в нумерации S·E).
|
||||
В интерфейсе всегда видна **конкретная причина**, а не просто «не уверен».
|
||||
|
||||
## Поверхность решения (едина для всех транспортов)
|
||||
|
||||
1. **Источник:** имя торрента, переданный контекст, дерево файлов с
|
||||
размерами, (если из бота) распарсенное сообщение.
|
||||
2. **Догадка системы:** тип, название, год, сезон, матч базы и
|
||||
**превью целевой раскладки** — буквальные пути, которые создадутся.
|
||||
3. **Причина сомнения.**
|
||||
|
||||
## Действия
|
||||
|
||||
- **Применить** — сделать хардлинки по плану.
|
||||
- **Уточнить и перераспознать** — добавить подсказку текстом → LLM
|
||||
перезапускается с исходными сигналами и накопленными подсказками →
|
||||
новый план. Главный путь, когда «LLM не справился».
|
||||
- **Поправить вручную** — объём зависит от версии (см. ниже).
|
||||
- **Выбрать кандидата базы** / ввести id / «без базы».
|
||||
- **Отклонить** / **Позже**.
|
||||
|
||||
**Подсказка vs override.** Подсказка мягкая — LLM её интерпретирует.
|
||||
Ручная правка поля — жёсткий **override**: система берёт значение как
|
||||
есть и «пиннит» его, перераспознавание не затирает уже поправленное.
|
||||
|
||||
## Веб-UI — точные правки
|
||||
|
||||
```
|
||||
Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
|
||||
Причины: нет в TMDB · уверенность 0.46
|
||||
|
||||
Контекст: «второй сезон, рус+англ дорожки» [+ добавить → 🔁 перераспознать]
|
||||
|
||||
Тип: ( ) фильм (•) сериал Название: Фарго Год: 2015 Сезон: 02
|
||||
|
||||
Источник совпадения (единый список — выбираем источник, а не режим):
|
||||
(•) распознано нейронкой (без базы) [активен]
|
||||
( ) tvdb Fargo · 2014 id 269613 [запись↗] [предпросмотр▸] [выбрать]
|
||||
( ) tmdb Fargo id 60622 [запись↗] [предпросмотр▸] [выбрать]
|
||||
+ добавить вручную: [tmdb▾] [id или URL записи] [Добавить]
|
||||
предпросмотр▸ раскрывает поля (тип/название/год, место под режиссёра) и
|
||||
целевые пути ЭТОГО источника — до выбора, ничего не меняя
|
||||
|
||||
Файлы → серии:
|
||||
# | файл | размер | роль | S | E
|
||||
1 | Fargo.S02E01.rus.mkv | 3.1 GB | эпизод | 02 | 01
|
||||
… [нумеровать подряд] [сброс]
|
||||
9 | sample.mkv | 40 MB | игнор | – | –
|
||||
|
||||
Превью:
|
||||
series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv ← #1
|
||||
[ Применить ] [ Отклонить ] [ Позже ]
|
||||
```
|
||||
|
||||
Ядро экрана для сериала — таблица «файл → серия» с живой валидацией
|
||||
дыр/дублей и кнопкой «нумеровать подряд» (частый случай: файлы по
|
||||
порядку, но подписаны криво). Для фильма проще: выбрать основной файл,
|
||||
остальное — extra/sample/субтитры/игнор.
|
||||
|
||||
## Telegram — быстро, где пользователь и так есть
|
||||
|
||||
```
|
||||
🟡 Нужно подтверждение
|
||||
Источник: Fargo.S02.2015.WEB-DL.1080p
|
||||
Похоже на: 📺 сериал «Фарго», сезон 2 (2015)
|
||||
База: TMDB не найдено · уверенность низкая
|
||||
План: 10 видео → series/Фарго (2015)/Season 02/…E01–E10
|
||||
|
||||
[✅ Применить] [📺↔🎬 Тип]
|
||||
[🔢 Выбрать в базе] [🔁 Уточнить]
|
||||
[🌐 Открыть в вебе] [❌ Отклонить]
|
||||
```
|
||||
|
||||
- **🔁 Уточнить** → бот просит подсказку ответом → перераспознаёт →
|
||||
редактирует то же сообщение новым планом. Петля коррекции прямо в чате.
|
||||
- Точечное переназначение файлов и выбор кандидата базы в чат не
|
||||
помещаются → **🌐 В вебе** (deep-link на ту же страницу, строится из
|
||||
`telegram.web_base_url`).
|
||||
|
||||
> Реально в боте сейчас: ✅ Применить, 📺↔🎬 Тип, 🔁 Уточнить, 🕗 Позже,
|
||||
> 🌐 В вебе, ❌ Отклонить. Кнопки «🔢 Выбрать в базе» в чате пока нет —
|
||||
> выбор кандидата и ручной ввод id делаются в вебе.
|
||||
|
||||
## Разделение труда
|
||||
|
||||
Telegram = одобрить / подсказать / выбрать кандидата / эскалировать в
|
||||
веб. Веб = точные правки. Состояние ревью одно (в SQLite); команды из
|
||||
любого транспорта сериализует `worker` под per-download блокировкой —
|
||||
гонки двух транспортов нет, применяется последняя валидная команда.
|
||||
|
||||
**Доступ.** Telegram — по `telegram.allowed_user_ids` (пусто = запрет
|
||||
всем). Веб-UI в v1 без авторизации (доверенная LAN), поэтому deep-link из
|
||||
бота ведёт на открытую страницу — приемлемо по решению; защиту навесим
|
||||
позже.
|
||||
|
||||
## Крайние сценарии
|
||||
|
||||
- **База неоднозначна** → выбор кандидата (часто чинит всё разом: пиннит
|
||||
provider-id и каноническое имя).
|
||||
- **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с
|
||||
абсолютной нумерацией → веб-хелпер «absolute → S·E»
|
||||
([задача «Аниме с абсолютной нумерацией»](../backlog/anime-absolyutnaya-numeraciya.md)).
|
||||
- **Не тот тип (movie↔series)** → «Уточнить» с явным указанием типа
|
||||
перераспознаёт план (отдельного переключателя типа нет — тип read-only).
|
||||
- **Мусор (sample/extra/дубли дорожек)** → роль «игнор».
|
||||
- **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать
|
||||
тип, ввести название/год, разложить файлы руками; в Telegram — сразу
|
||||
эскалация в веб.
|
||||
|
||||
## Вход в ревью и откат
|
||||
|
||||
- Переход в `review` **пингует** (сообщение в Telegram / бейдж в вебе) —
|
||||
пользователя зовут, а не он опрашивает. Таймера нет, источник
|
||||
продолжает сидировать.
|
||||
- После «Применить» показываем, что создано. **Undo** — убрать созданные
|
||||
хардлинки одной кнопкой (источник цел); страховка от ошибочного
|
||||
подтверждения.
|
||||
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по
|
||||
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
|
||||
после применения → `reverted` (удаляет только ссылки своего батча, под
|
||||
`media`). Полная карта состояний — в [workflow.md](workflow.md).
|
||||
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
|
||||
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
|
||||
снова приводит в review — раскладка всегда требует ручного подтверждения,
|
||||
авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
|
||||
перепривязал, поправил и применил.
|
||||
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
|
||||
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
|
||||
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
|
||||
споткнулась на разовой ошибке.
|
||||
|
||||
## Объём по версиям
|
||||
|
||||
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
|
||||
заново», **единый список источников совпадения**
|
||||
(нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу
|
||||
нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по
|
||||
URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого
|
||||
источника до применения** (место под режиссёра зарезервировано), пометка
|
||||
файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново».
|
||||
В Telegram — подтверждение с reply-подсказкой
|
||||
(«Уточнить»), «Позже»/«Отклонить» и эскалация в веб;
|
||||
пинги о входе в review и готовности.
|
||||
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
|
||||
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
|
||||
выбор кандидата базы и ввод id прямо в Telegram.
|
||||
@@ -1,237 +0,0 @@
|
||||
# Жизненный цикл загрузки и машина состояний
|
||||
|
||||
> **Источник истины переехал в OpenSpec.** Прямой путь FSM (downloading →
|
||||
> completed → stuck/failed, поллинг, усыновление) — `openspec/specs/
|
||||
> download-tracking/`; сверка с реальностью — `openspec/specs/
|
||||
> state-reconciliation/`; уведомления — `openspec/specs/notifications/`. Этот
|
||||
> файл — справочный нарратив по графу состояний; при расхождении верна спека
|
||||
> OpenSpec.
|
||||
|
||||
Как загрузка проходит путь от приёма источника до разложенных файлов:
|
||||
состояния, переходы и то, что их вызывает. Кто владеет переходами и общее
|
||||
устройство — в [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
|
||||
downloading --> failed: источник пропал из qBittorrent (source_gone, после дебаунса)
|
||||
|
||||
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 / сверка (метаданные пришли)
|
||||
failed --> completed: сверка (торрент уже готов)
|
||||
stuck --> completed: сверка (торрент уже готов)
|
||||
|
||||
done --> target_missing: сверка — цель удалена
|
||||
done --> orphaned: сверка — источник пропал
|
||||
done --> deleted: Удалить (delete)
|
||||
target_missing --> recognizing: Привязать заново
|
||||
target_missing --> orphaned: источник тоже пропал
|
||||
target_missing --> deleted: Удалить / источник тоже пропал (сверка)
|
||||
orphaned --> deleted: Удалить / цель тоже удалена (сверка)
|
||||
target_missing --> done: healing (цель вернулась)
|
||||
orphaned --> done: healing (источник вернулся)
|
||||
|
||||
done --> [*]
|
||||
cancelled --> [*]
|
||||
reverted --> [*]
|
||||
deleted --> [*]
|
||||
|
||||
note right of cancelled
|
||||
В cancelled ведут: «Отклонить»
|
||||
(из нетерминальных) и «Закрыть»
|
||||
(стоп-кран — из любого состояния,
|
||||
кроме deleted; только статус)
|
||||
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` (убрать созданные ссылки) и
|
||||
**Удалить** → `deleted` (полное удаление, см. ниже). Скан дёргается и при
|
||||
входе в `reverted`/`deleted` — наши ссылки там сняты, Jellyfin не должен
|
||||
держать битые пути.
|
||||
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
|
||||
(ретраибельна); «Отклонить».
|
||||
- **reverted / cancelled → recognizing** — «Привязать заново»: после
|
||||
отката или отклонения можно перезапустить распознавание для той же
|
||||
раздачи. Перепривязка всегда идёт через review с ручным подтверждением
|
||||
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
|
||||
qBittorrent.
|
||||
|
||||
## Сверка с реальностью (рассинхрон)
|
||||
|
||||
Состояние в БД может разойтись с диском при **ручном** удалении: раздачу
|
||||
стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые
|
||||
хардлинки). `worker` периодически сверяет уже разложенные задачи с фактом по
|
||||
двумерной матрице «источник × цель» (источник = раздача в qBittorrent,
|
||||
цель = разложенные хардлинки на ФС) и выводит состояние:
|
||||
|
||||
- **target_missing** — источник на месте, цель удалена. Доступна команда
|
||||
«Привязать заново» (`→ recognizing`); авто-действий нет.
|
||||
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
|
||||
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
|
||||
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
|
||||
больше не переоценивает (см. ниже).
|
||||
|
||||
**Undo vs Удалить (delete).** Это разные пользовательские операции. **Undo**
|
||||
(из `done`) — «перераспознать»: снимает только наши библиотечные ссылки, раздачу
|
||||
в qBittorrent бережёт, гард последней копии включён (не сотрёт единственный
|
||||
файл) → `reverted`. **Удалить** (из `done`, `orphaned`, `target_missing`) —
|
||||
«убрать окончательно, освободить место»: снимает наши ссылки **и** сносит раздачу
|
||||
с файлами из qBittorrent, гард последней копии осознанно выключен (обход
|
||||
инварианта «источник неприкосновенен» — только по подтверждению) →
|
||||
терминальный `deleted`. Идемпотентно к отсутствующей стороне, так что подчищает
|
||||
остатки из любого из трёх состояний. Инициатор в `deleted` различается по
|
||||
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
|
||||
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.
|
||||
|
||||
**Закрыть (dismiss).** Универсальный стоп-кран из любого состояния, кроме
|
||||
`deleted`: переводит запись в терминальный `cancelled` (`error_code =
|
||||
"user_dismiss"`), **только меняя статус** — ни файлы (библиотечные хардлинки
|
||||
`done`/`orphaned` остаются на месте), ни раздачу в qBittorrent не трогает, в
|
||||
отличие от «Удалить». Служит закрытием зависшей/спорной/лишней записи (напр.
|
||||
дубля-близнеца в `target_missing`); из `cancelled` дальше доступна перепривязка.
|
||||
Для нетерминальных ту же роль штатно играет «Отменить» — в UI стоп-кран
|
||||
показывается там, где иного выхода нет (терминальные, кроме `deleted`).
|
||||
|
||||
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
|
||||
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
|
||||
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
|
||||
возврате источника/цели задача переходит обратно (вплоть до `done`) — но
|
||||
**не из `deleted`**: к терминальной задаче источник не вернётся
|
||||
(идемпотентность снимается только для активных), а её бывший целевой путь, если
|
||||
его заняла другая загрузка, отбирается переходом владения (см.
|
||||
[jellyfin-layout.md](jellyfin-layout.md) → «Владение целевым путём»).
|
||||
Без этого правила переиспользование пути ложно «воскрешало» бы удалённую
|
||||
задачу в `orphaned`. Пропажа
|
||||
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
|
||||
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
|
||||
которым нужен источник (relink/распознать/применить/undo), проверяют его
|
||||
**синхронно перед действием** и не полагаются на фоновую сверку. Полные
|
||||
требования — `openspec/specs/state-reconciliation/`.
|
||||
|
||||
Все переходы и команды идут через `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`. `magnet_timeout` — **редкий страховочный
|
||||
предохранитель** (дефолт `24h`), а не рабочий механизм: долгий `metaDL`
|
||||
(медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Меры у
|
||||
двух таймаутов **разные**: `magnet_timeout` мерит **возраст** торрента от
|
||||
добавления в qBittorrent (`added_on`, фолбэк `created_at`); `stuck_after`
|
||||
мерит **длительность простоя** — от `last_activity` (последнее движение
|
||||
данных), а не возраст, иначе долго качавшийся торрент, на миг зашедший в
|
||||
`stalledDL`, ложно уходит в `stuck` со «stalled for 5h». Оба базиса
|
||||
приподнимаются до `retried_at` — ручной retry сбрасывает отсчёт, чтобы возврат
|
||||
в `downloading` не ронял задачу снова на ближайшем тике.
|
||||
- **ошибка:** `error`/`missingFiles` → `failed` (`error_code` `qbit_error`) —
|
||||
это настоящий провал, в отличие от таймаута.
|
||||
- **источник пропал:** раздача активной загрузки устойчиво (после дебаунса
|
||||
`source_missing_threshold`, тот же счётчик, что и сверка рассинхрона) исчезла
|
||||
из qBittorrent (удалил пользователь/другой клиент) → `failed` (`error_code`
|
||||
`source_gone`). Иначе `downloading` без раздачи оставался бы вечным зомби,
|
||||
которого никто не двигает (MAJOR-3). В отличие от таймаутов, сверка
|
||||
`source_gone` **не воскрешает** (удаление намеренно) — но задача штатно
|
||||
retriable: `Retry` заново отдаёт сохранённый источник.
|
||||
|
||||
### Уведомление и восстановление
|
||||
|
||||
- Любой переход в `failed`/`stuck` **уведомляет** автора загрузки
|
||||
(`notifier`), чтобы падение не оставалось незамеченным — включая приёмное
|
||||
падение `qbit_add` (не удалось добавить в qBittorrent), которое идёт мимо
|
||||
поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса
|
||||
уведомляют лишь раз — чтобы мерцающий `stalled`-торрент
|
||||
(`stuck`↔`downloading`) не спамил.
|
||||
- `failed`/`stuck` из-за нашей нетерпеливости (`error_code` `magnet_timeout`/
|
||||
`stalled`) **не тупик**: фоновая сверка возвращает задачу в поток, как
|
||||
только источник в qBittorrent ожил и продвинулся за условие падения
|
||||
(получил метаданные → `downloading`; уже готов → `completed`). Пока торрент
|
||||
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
|
||||
Настоящие провалы (`qbit_error`) и намеренная пропажа источника
|
||||
(`source_gone`) сверкой не воскрешаются — только ручной retry.
|
||||
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
|
||||
REST): возвращает в `downloading`, перецепляясь к живому **здоровому** торренту
|
||||
без повторного `Add` (к сломанному — `error`/`missingFiles` — не
|
||||
перецепляемся, повторно отдаём источник) и сбрасывая базис таймаутов
|
||||
(`retried_at`), чтобы задача не упала снова на ближайшем тике.
|
||||
|
||||
Пути файлов берём из API (`save_path` + относительные имена из
|
||||
`/torrents/files`, уже включающие корневую папку торрента), не из
|
||||
константы (обычно это уже хост-путь). «Incomplete»-каталог в
|
||||
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
|
||||
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
|
||||
`moving` — дожидаемся окончания переноса и только потом берём финальный
|
||||
путь). Подробнее о путях и песочнице — [architecture.md](architecture.md)
|
||||
→ «Пути и контейнеры».
|
||||
@@ -0,0 +1,51 @@
|
||||
# Беклог
|
||||
|
||||
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
|
||||
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут,
|
||||
план — то, подо что берут. Порядка внутри секции нет: «что делать
|
||||
дальше» отвечает набор спринта. Ведётся скиллом `tasks`.
|
||||
|
||||
Секции «блокеры» здесь нет и не заводится: блокер — это состояние
|
||||
(спринт не может продолжаться ни одной задачей), оно живёт до ответа
|
||||
человека, а его следы — вопросами в файлах задач.
|
||||
|
||||
## Ядро продукта
|
||||
- [Аниме с абсолютной нумерацией](items/anime-absolute-numbering.md) — аниме со сквозной нумерацией (#137) не раскладывается в SxxEyy, который ждёт Jellyfin — нужен пересчёт абсолютной нумерации
|
||||
- [`addReq` не пересобирается из свежего `source_type` перед `Add` (окно namer'а)](items/catched-source-type-refresh.md) — При апгрейде magnet→.torrent в окне namer'а добавится magnet из устаревшего снимка; самоисцеляется через magnet_timeout→failed→Retry _(аудит 2026-07-17)_
|
||||
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](items/disc-image-releases.md) — раздача-образ диска (VIDEO_TS/BDMV) сейчас разбирается пофайлово вместо раскладки каталога целиком — редкий, но реальный случай
|
||||
- [Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`](items/dismiss-marker-lost.md) — Функционально ок (Cancel даёт cancelled), но маркер user_dismiss в error_code теряется; расхождение с буквой спеки _(аудит 2026-07-17)_
|
||||
- [Фетч .torrent по URL — остаток «единого окна»](items/torrent-url-fetch.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
||||
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](items/auto-link-confidence-gate.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||
- [[idea] guessit как сервис-спутник](items/guessit-sidecar.md) — go-ptn слабее питоновского guessit — если точности пред-парса не хватит, завернуть guessit в сервис-спутник рядом с бинарём
|
||||
- [Согласование канона нумерации серий с провайдером тега](items/episode-numbering-canon.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
|
||||
- [Раздачи с докачиванием (merge при повторном добавлении)](items/merge-incremental-redownload.md) — повторная заливка сериала целиком должна доложить недостающие эпизоды merge-раскладкой, не трогая существующие ссылки — блокирует типовой сценарий свежих сериалов
|
||||
- [[idea] Многоступенчатая верификация привязки](items/multi-pass-verification.md) — несколько проходов распознавания с консенсусом подняли бы точность ценой стоимости/латентности — проработать, когда включать и как мерджить расхождения
|
||||
- [Обучение на правках человека (few-shot из прошлых ревью)](items/learn-from-user-corrections.md) — правки человека (матч/тип/нумерация) не переиспользуются — few-shot из прошлых ревью поднял бы точность на «своих» трекерах без смены модели
|
||||
- [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](items/infohash-identity-integrity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
|
||||
- [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](items/ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
|
||||
- [[idea] Сила совпадения кандидата и пересмотр распознавания/матчинга](items/candidate-match-strength.md) — у кандидата метабазы нет метрики силы совпадения — список кандидатов на ревью нечем отсортировать по уверенности (сперва проработать процесс матчинга)
|
||||
- [[idea] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](items/complex-series-releases.md) — сложные раздачи (все сезоны разом, паки, смешанная нумерация) целостно не проработаны — распознавание/ревью/раскладка заточены под один сезон
|
||||
- [Мгновенные обновления через SSE](items/sse-live-updates.md) — живые обновления на htmx-поллинге дают задержку и холостые запросы — SSE убрал бы то и другое (поллинг работает, поэтому улучшение, не блокер)
|
||||
- [Проверка свободного места перед copy-fallback](items/free-space-check-copy-fallback.md) — copy-fallback при невозможности хардлинка может упереться в переполненный диск посреди раскладки — нет проверки места до копирования
|
||||
- [Ревью уведомлений в Telegram (аудит текстов и формата)](items/telegram-messages-audit.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
|
||||
- [Привязка уведомлений к источнику в ботах (мульти-бот)](items/notification-source-binding.md) — пинги и ревью должен получать автор загрузки в своём транспорте — нет привязки загрузки к источнику/отправителю (нужно для мульти-бота)
|
||||
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](items/title-versions-repacks.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
|
||||
- [Внешние субтитры: пары VobSub и языковой суффикс](items/external-subtitles.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
||||
- [Современный Web-UI как PWA](items/web-ui-pwa.md) — текущий server-rendered UI функционален — PWA (устанавливаемое, удобное с телефона) это улучшение большого объёма, не блокер
|
||||
- [Полный редактор маппинга «файл → серия» и ручной режим ревью](items/review-mapping-editor.md) — правка S·E, «нумеровать подряд» и ручной режим при полном провале LLM были запланированы объёмом Ф5 и не заведены задачей — в ревью сегодня можно только подсказать текстом
|
||||
- [Крайние случаи именования: многофайловый фильм, редакции, двойная серия](items/naming-edge-cases.md) — стэкинг частей (part1/cd1), редакции [edition-…] и двойная серия SxxEyy-Eyy описаны нарративом, но в file-layout не заказаны — раскладка таких раздач не определена
|
||||
|
||||
## Инфраструктура
|
||||
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](items/quality-review-agents.md) — конвейер ревью переехал в плагин `av-dev-pipeline`; осталась калибровка проходов на этом проекте и ревьювер наименований (ждёт словарь единого языка)
|
||||
- [Авторизация веб-UI (на будущее)](items/web-ui-auth.md) — для v1 решено без авторизации (доверенная LAN, опц. allowlist подсетей) — задел на случай, если понадобится защита
|
||||
- [Бэкап SQLite](items/sqlite-backup.md) — architecture требует бекапить data-том, но стратегия не описана — сбой или редеплой стирают всё in-flight состояние (проще, пока БД маленькая)
|
||||
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](items/recognition-eval-harness.md) — смена модели или правка промпта распознавания сейчас вслепую — нет корпуса кейсов и метрики точности, регрессии не видно
|
||||
- [Глубокий healthcheck и статус зависимостей](items/deep-healthcheck-dependencies.md) — /healthz проверяет только сам сервис — недоступность qBittorrent/LLM/метабазы видна лишь по застрявшим задачам, нет readiness и бейджа в UI
|
||||
- [История переходов загрузки](items/download-transition-history.md) — хранится только текущий статус загрузки — разбор «как сюда попали» идёт по логам сервера, нет таблицы истории переходов
|
||||
- [Кэш метабаз (и опционально LLM)](items/metadata-cache.md) — повторные и ретраящиеся прогоны бьют TMDB/TVDB/TVMaze одним запросом — кэш с TTL сэкономил бы лимиты и ускорил «Распознать заново»
|
||||
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](items/scale-100-downloads.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
|
||||
- [Шум ERROR фоновых циклов при недоступной зависимости](items/background-error-noise.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
|
||||
- [Ретеншн и очистка БД](items/db-retention-cleanup.md) — терминальные задачи и сырые ответы LLM копятся вечно — без авточистки список загрузок и БД деградируют по мере эксплуатации
|
||||
- [Словарь единого языка (ubiquitous language)](items/ubiquitous-language-glossary.md) — наименования домена расходятся между спеками, UI и кодом — нет единого глоссария (на нём же стоит агент-ревьювер наименований)
|
||||
- [[idea] Завершение загрузки через webhook](items/completion-webhook.md) — завершение сейчас ловим поллингом qBittorrent — webhook реагировал бы быстрее, но связывает нас с его конфигом (решим по опыту эксплуатации)
|
||||
- [[idea] Кандидаты в конвенции кода](items/convention-candidates.md) — накоплен список кандидатов (внешние клиенты, конкурентность, тесты, CLI, время) — надо решить, что из них стало реальным трением, а что выдумано вперёд
|
||||
@@ -0,0 +1,22 @@
|
||||
# План
|
||||
|
||||
Оглавление целей. Цель — файл `[goal]` в `items/`; её задачи
|
||||
здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.
|
||||
В первой секции («порядок») очередь значима и обосновывается
|
||||
прозой; в остальных порядка нет — это тематические цели.
|
||||
|
||||
## порядок
|
||||
|
||||
Пусто. Фазы Ф0–Ф6 прежней дорожной карты (каркас, приём и трекинг,
|
||||
распознавание, раскладка и ревью, метаданные, Telegram и UX, деплой) закрыты —
|
||||
сквозной путь работает и развёрнут; закрытый шаг планом больше не является.
|
||||
Что было сделано и когда — по архиву `openspec/changes/archive/`.
|
||||
Следующая упорядоченная очередь появится, когда она понадобится.
|
||||
|
||||
## темы
|
||||
- [[goal] Точность распознавания](items/recognition-accuracy.md) — смена модели или правка промпта сегодня вслепую — нет ни метрики, ни способа переиспользовать уже сделанные человеком правки
|
||||
- [[goal] Сложные раздачи](items/complex-releases.md) — типовая раздача раскладывается, а всё, что сложнее одного сезона одного тайтла, упирается в ручной разбор
|
||||
- [[goal] Эксплуатационная прочность](items/operational-resilience.md) — сервис работает, но не переживает роста: база копится вечно, бэкапа нет, отказ зависимости виден только по застрявшим задачам
|
||||
- [[goal] Интерфейсы приёма и ревью](items/ingest-and-review-interfaces.md) — путь «принести раздачу и подтвердить догадку» упирается в незакрытые куски интерфейсов, а не в логику
|
||||
- [[goal] Целостность состояния и приёма](items/state-integrity.md) — известные окна рассинхрона и потери маркеров: каждое по отдельности самоисцеляется, вместе — источник необъяснимых состояний
|
||||
- [[goal] Процесс и качество разработки](items/dev-process-quality.md) — наименования домена расходятся между спеками, UI и кодом, а конвейер ревью не откалиброван — растёт цена каждой следующей задачи
|
||||
@@ -0,0 +1,7 @@
|
||||
# Ушедшее без реализации
|
||||
|
||||
Задачи, покинувшие беклог **без реализации**, с причиной и датой.
|
||||
Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них
|
||||
есть коммит. Это первое место, куда смотрит дедупликация при заведении.
|
||||
|
||||
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
|
||||
@@ -0,0 +1,6 @@
|
||||
# Спринт
|
||||
|
||||
Спринта нет. Цель называет человек, набор собирает агент:
|
||||
`tasks.py sprint start --goal <слаг>`.
|
||||
|
||||
## Набор
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Аниме с абсолютной нумерацией
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** аниме со сквозной нумерацией (#137) не раскладывается в SxxEyy, который ждёт Jellyfin — нужен пересчёт абсолютной нумерации
|
||||
- **Теги:** goal:complex-releases
|
||||
|
||||
Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт SxxEyy. Нужен пересчёт абсолютной нумерации в сезон/серию — надёжнее всего через TVDB (там есть absolute order). Отдельный крайний случай распознавания; на стороне ревью — веб-хелпер «absolute → S·E».
|
||||
|
||||
+5
-3
@@ -1,6 +1,8 @@
|
||||
# Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||
- **Теги:** goal:recognition-accuracy
|
||||
|
||||
Аудит спек↔код (2026-07-03) нашёл расхождение: спека recognition считает
|
||||
`confidence` вспомогательным сигналом (условия авто — только матч в базе +
|
||||
@@ -37,11 +39,11 @@
|
||||
5. **Тесты:** `decide` с `threshold=0` (гейт выключен, авто при чистых 1–3);
|
||||
confidence ниже/выше порога при выполненных 1–3.
|
||||
6. Точное число порога откалибровать позже — для этого есть задача
|
||||
[eval-харнес распознавания](eval-harness-raspoznavaniya.md) (сейчас гейтим по
|
||||
[eval-харнес распознавания](recognition-eval-harness.md) (сейчас гейтим по
|
||||
неизмеренному сигналу).
|
||||
|
||||
Оформить как OpenSpec-change (дельта `recognition` + правки
|
||||
`validate.go`/`recognize.go`/`config`).
|
||||
|
||||
Связано: openspec/specs/recognition, ADR-2026-06-13-auto-link-requires-db-match,
|
||||
[eval-харнес](eval-harness-raspoznavaniya.md), пакет recognize.
|
||||
[eval-харнес](recognition-eval-harness.md), пакет recognize.
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Шум ERROR фоновых циклов при недоступной зависимости
|
||||
|
||||
**Приоритет:** низкий · **Теги:** review-fable, logging, reliability
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
|
||||
- **Теги:** goal:operational-resilience
|
||||
|
||||
Остаток от задачи «классификация доменных ошибок + конвенции логирования»
|
||||
(основное реализовано, см. ниже). Здесь — два смежных пункта про уровень
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# [idea] Сила совпадения кандидата и пересмотр распознавания/матчинга
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** у кандидата метабазы нет метрики силы совпадения — список кандидатов на ревью нечем отсортировать по уверенности (сперва проработать процесс матчинга)
|
||||
- **Теги:** goal:recognition-accuracy
|
||||
|
||||
У кандидата метабазы нет метрики силы совпадения (metadata_candidate хранит provider/id/title/year/url), решение «авто vs review» — по правилу «единственный сильный матч + валидация», не по числу. Для ревью: список кандидатов нечем отсортировать/подсветить по уверенности. Идея — ввести на этапе матча силу совпадения кандидата (точное совпадение названия+года vs частичное) для сортировки и подсказки в UI. Шире — продумать сам процесс распознавания и матчинга: границы «разбор LLM / поиск в базе / сверка», что храним у кандидата, как считаем и показываем уверенность.
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# `addReq` не пересобирается из свежего `source_type` перед `Add` (окно namer'а)
|
||||
|
||||
**Приоритет:** средний · **Теги:** review-2026-07-17, lifecycle
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** При апгрейде magnet→.torrent в окне namer'а добавится magnet из устаревшего снимка; самоисцеляется через magnet_timeout→failed→Retry _(аудит 2026-07-17)_
|
||||
- **Теги:** goal:state-integrity
|
||||
|
||||
Найдено аудитом capability **download-tracking** (сверка код↔спека после пачки
|
||||
lifecycle-задач). Пред-существующее, вне scope задачи F3/cancel-cleanup — T4
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# [idea] Завершение загрузки через webhook
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** завершение сейчас ловим поллингом qBittorrent — webhook реагировал бы быстрее, но связывает нас с его конфигом (решим по опыту эксплуатации)
|
||||
- **Теги:** goal:ingest-and-review-interfaces
|
||||
|
||||
Сейчас завершение ловим поллингом qBittorrent раз в несколько секунд. Альтернатива: «Run external program on torrent completion» в qBittorrent дёргает эндпоинт jellybit. Реагирует быстрее, но связывает нас с конфигом qBittorrent.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# [goal] Сложные раздачи
|
||||
|
||||
- **Секция:** темы
|
||||
- **Зачем:** типовая раздача раскладывается, а всё, что сложнее одного сезона одного тайтла, упирается в ручной разбор
|
||||
- **Теги:** decomposed
|
||||
|
||||
Ради чего: сериальные паки, докачивание, аниме со сквозной нумерацией, образы дисков и внешние субтитры — это ровно тот контент, ради которого проект и заводился вместо arr-стека.
|
||||
|
||||
## Завершение
|
||||
|
||||
Достигнута, когда сериальный пак, докачивание недостающих серий, аниме со сквозной нумерацией, образ диска и внешние субтитры раскладываются без ручного вмешательства в файлы на диске — либо честно уходят в ревью с названной причиной, а не молча кладутся неверно.
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# [idea] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** сложные раздачи (все сезоны разом, паки, смешанная нумерация) целостно не проработаны — распознавание/ревью/раскладка заточены под один сезон
|
||||
- **Теги:** goal:complex-releases
|
||||
|
||||
Обычный случай — один сезон (его номер видно глазами и сверяем на ревью — под это сделана сводка сезонов). Но в редких заказах раздача сложнее: все сезоны сериала разом, пак нескольких сезонов, смешанная нумерация, вложенные папки сезонов, разнобойные имена файлов. Сейчас PlanFile.Season задаётся на каждом файле (мультисезон в принципе выразим), но целостно эти сценарии не проработаны: как надёжно распознать, как показать на ревью, как разложить и как стыкуется со сходимостью папки и merge-докачиванием. Решить, что поддерживаем явно, а что уводим в ревью как «сложную раскладку».
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# [idea] Кандидаты в конвенции кода
|
||||
|
||||
- **Секция:** Инфраструктура
|
||||
- **Зачем:** накоплен список кандидатов (внешние клиенты, конкурентность, тесты, CLI, время) — надо решить, что из них стало реальным трением, а что выдумано вперёд
|
||||
- **Теги:** goal:dev-process-quality
|
||||
|
||||
Список копился в черновике `docs/drafts/conventions-backlog.md` (удалён при
|
||||
переводе на канон, текст в истории git) под правилом «пишем по мере реального
|
||||
трения, а не вперёд». Правило соблюдено — но список с тех пор не пересматривали,
|
||||
а часть пунктов за это время либо реализовалась, либо механизировалась правилом
|
||||
и должна из кандидатов выпасть, а не переехать в прозу.
|
||||
|
||||
Разобрать по одному: стало реальным трением → в `docs/conventions/`; выражается
|
||||
правилом → в `.golangci.yml` или `internal/archrules` и в таблицу
|
||||
«Механизировано»; выдумано вперёд → выбросить.
|
||||
|
||||
**Кандидаты в отдельный документ**
|
||||
|
||||
- **Раскладка пакетов и направление зависимостей.** `cmd/<bin>` +
|
||||
`internal/<компонент>` по доменам, домен не импортирует транспорт, без свалок
|
||||
`util`/`common`/`helpers`. *Частично уже механизировано* тестами
|
||||
`internal/archrules` — проверить, что осталось прозой.
|
||||
- **`context.Context`.** Первый параметр, не хранить в структурах, в `Value`
|
||||
только request-scoped данные (не зависимости), дедлайны и отмена тянутся
|
||||
сквозь стадии. Протяжка логгера уже сделана (`internal/logctx`).
|
||||
- **Внешние клиенты.** Таймаут на **каждый** исходящий вызов, не
|
||||
`http.DefaultClient`, ретраи с backoff и потолком, HTTP-прокси из конфига.
|
||||
Кандидат на общий конструктор клиента вместо копипасты в
|
||||
`qbt`/`llm`/`jellyfin`/`metadata`. Самый живой пункт: клиентов уже четыре.
|
||||
- **Тесты.** Table-driven, фикстуры в `testdata/`, `t.Parallel()` где
|
||||
безопасно, зафиксировать stdlib `testing` против `testify`, разделение
|
||||
быстрых и интеграционных (`*_integration_test.go` + env-гейты уже есть), что
|
||||
считаем обязательным к покрытию.
|
||||
|
||||
**Кандидаты в строку-инвариант, а не в документ**
|
||||
|
||||
- **БД и миграции.** Forward-only, только параметризованные запросы, явные
|
||||
транзакции для многошаговых изменений, context-aware запросы. Сильно
|
||||
стек-специфично.
|
||||
- **Конкурентность.** Каждая горутина знает, **как** останавливается
|
||||
(ctx/закрытие канала); `errgroup` для связанных задач; фоновые процессы
|
||||
гасятся при shutdown. Актуально для воркера, не для всего проекта.
|
||||
- **CLI.** Данные в `stdout`, логи и диагностика в `stderr`, осмысленные коды
|
||||
возврата. Для диагностических команд `add`/`recognize`/`healthcheck`.
|
||||
- **Время.** Явный TZ всегда, хранение и логи в UTC. Уже частично в `CLAUDE.md`
|
||||
и `conventions/logging.md`, а `time.Now` вне `store` запрещён линтером — этот
|
||||
пункт, вероятно, закрыт и подлежит вычёркиванию.
|
||||
@@ -1,6 +1,8 @@
|
||||
# Ретеншн и очистка БД
|
||||
|
||||
**Приоритет:** высокий
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** терминальные задачи и сырые ответы LLM копятся вечно — без авточистки список загрузок и БД деградируют по мере эксплуатации
|
||||
- **Теги:** goal:operational-resilience
|
||||
|
||||
Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми ответами LLM и metadata_candidate копятся вечно — БД и список загрузок распухают и становятся нечитаемыми. Нужна авточистка старше N дней (настройка в [storage] или [worker]) и/или ручное удаление. Маленькая задача, но без неё интерфейс деградирует по мере эксплуатации.
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Глубокий healthcheck и статус зависимостей
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** /healthz проверяет только сам сервис — недоступность qBittorrent/LLM/метабазы видна лишь по застрявшим задачам, нет readiness и бейджа в UI
|
||||
- **Теги:** goal:operational-resilience
|
||||
|
||||
/healthz проверяет только сам сервис. Если qBittorrent, LLM или метабаза недоступны — узнаёшь лишь по застрявшим задачам. Нужна readiness-проверка ключевых зависимостей и отражение их состояния в UI (бейдж «qBittorrent недоступен»), чтобы причина простоя была видна сразу.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# [goal] Процесс и качество разработки
|
||||
|
||||
- **Секция:** темы
|
||||
- **Зачем:** наименования домена расходятся между спеками, UI и кодом, а конвейер ревью не откалиброван — растёт цена каждой следующей задачи
|
||||
- **Теги:** decomposed
|
||||
|
||||
Ради чего: это не поведение продукта, а то, чем он делается. Единый словарь, калибровка проходов ревью и разбор накопленных кандидатов в конвенции — вложение в скорость всех остальных целей.
|
||||
|
||||
## Завершение
|
||||
|
||||
Достигнута, когда домен называется одинаково в спеках, коде и интерфейсе, а конвейер ревью откалиброван на журнале реальных дефектов, а не на догадках о том, что он ловит.
|
||||
@@ -1,6 +1,8 @@
|
||||
# Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** раздача-образ диска (VIDEO_TS/BDMV) сейчас разбирается пофайлово вместо раскладки каталога целиком — редкий, но реальный случай
|
||||
- **Теги:** goal:complex-releases
|
||||
|
||||
Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска — структура VIDEO_TS/ (DVD) или BDMV/ (BluRay). Сейчас распознавание и раскладка заточены под пофайловый разбор, а тут «фильм» — это каталог целиком. Jellyfin такие раскладки поддерживает (папка фильма с вложенным VIDEO_TS/BDMV). Нужно: распознать, что раздача — образ диска (по наличию VIDEO_TS/BDMV), не разбирать её по отдельным VOB/m2ts как серии, разложить весь каталог хардлинками в папку фильма (Название (Год)/VIDEO_TS/…). Крайний, но реальный случай; частота низкая.
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`
|
||||
|
||||
**Приоритет:** низкий · **Теги:** review-2026-07-17, state-reconciliation
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** Функционально ок (Cancel даёт cancelled), но маркер user_dismiss в error_code теряется; расхождение с буквой спеки _(аудит 2026-07-17)_
|
||||
- **Теги:** goal:state-integrity
|
||||
|
||||
Найдено аудитом capability **state-reconciliation** (сверка код↔спека).
|
||||
Пред-существующее, вне scope пачки lifecycle-задач.
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# История переходов загрузки
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** хранится только текущий статус загрузки — разбор «как сюда попали» идёт по логам сервера, нет таблицы истории переходов
|
||||
- **Теги:** goal:state-integrity
|
||||
|
||||
Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто инициировал — воркер, человек, сверка), а не только текущее состояние. Сейчас по задаче виден лишь актуальный статус, разбор «как мы сюда попали» идёт по логам сервера. Отдельная таблица истории даёт лог переходов в карточке/расширенной информации и фундамент для метрик длительности стадий. Естественно ложится на собственный идентификатор загрузки и уже реализованный экран /download/{id}.
|
||||
|
||||
+5
-3
@@ -1,6 +1,8 @@
|
||||
# Согласование канона нумерации серий с провайдером тега
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
|
||||
- **Теги:** goal:complex-releases
|
||||
|
||||
Косметика и редкий случай: порядок просмотра не страдает (файлы уже
|
||||
пронумерованы канонически и лежат по порядку), разъезжаются только подписи серий
|
||||
@@ -60,9 +62,9 @@ LLM (`recognize.PlanFile.Episode`), проходит без изменений
|
||||
## Связи
|
||||
|
||||
- Тот же класс «у тайтла несколько легитимных порядков», что и
|
||||
[Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) (absolute
|
||||
[Аниме с абсолютной нумерацией](anime-absolute-numbering.md) (absolute
|
||||
order через TVDB) — стоит проработать совместно, возможно как одну тему.
|
||||
- [Сложные сериальные раздачи](slozhnye-serialnye-razdachi.md) — соседний пласт
|
||||
- [Сложные сериальные раздачи](complex-series-releases.md) — соседний пласт
|
||||
крайних случаев раскладки.
|
||||
- Схема «локальная сущность каноническая, provider id — опциональный внешний
|
||||
ключ» уже заложена (draft `logical-title-model.md`, сущность `title` осознанно
|
||||
@@ -1,6 +1,8 @@
|
||||
# Внешние субтитры: пары VobSub и языковой суффикс
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
||||
- **Теги:** goal:complex-releases
|
||||
|
||||
Базовая привязка субтитр→серия для сериала уже работает: `layout.PlanFile` несёт
|
||||
`Season/Episode`, а `seriesDst` именует субтитр по стему эпизода
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Проверка свободного места перед copy-fallback
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** copy-fallback при невозможности хардлинка может упереться в переполненный диск посреди раскладки — нет проверки места до копирования
|
||||
- **Теги:** goal:operational-resilience
|
||||
|
||||
Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске. На забитом диске это упрётся в полку посреди раскладки. Перед копированием проверять доступное место и при нехватке внятно уходить в failed с понятной причиной, а не падать на полпути.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# [idea] guessit как сервис-спутник
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** go-ptn слабее питоновского guessit — если точности пред-парса не хватит, завернуть guessit в сервис-спутник рядом с бинарём
|
||||
- **Теги:** goal:recognition-accuracy
|
||||
|
||||
go-ptn слабее питоновского guessit. Если точности пред-парса не хватит — завернуть guessit в крошечный HTTP-сервис (один файл, поставляется рядом с бинарём jellybit) и спрашивать его на шаге пред-парса. Сохраняет «доставку копированием»: два файла вместо одного.
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)
|
||||
|
||||
**Приоритет:** низкий · **Теги:** ingest, review-2026-07-08
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
|
||||
- **Теги:** goal:state-integrity
|
||||
|
||||
Ревью Fable 2026-07-08 (приём). Две связанные находки о доверии к парам xt в magnet (предпосылки к F1).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# [goal] Интерфейсы приёма и ревью
|
||||
|
||||
- **Секция:** темы
|
||||
- **Зачем:** путь «принести раздачу и подтвердить догадку» упирается в незакрытые куски интерфейсов, а не в логику
|
||||
- **Теги:** decomposed
|
||||
|
||||
Ради чего: приём и ревью — единственные места, где система встречается с человеком. Здесь копятся незакрытые куски: фетч по URL, редактор маппинга, привязка уведомлений к автору, латентность обновлений.
|
||||
|
||||
## Завершение
|
||||
|
||||
Достигнута, когда любой из поддержанных источников принимается одним действием из любого транспорта, а ревью позволяет довести план до применимого состояния без ухода в другой инструмент.
|
||||
@@ -1,6 +1,8 @@
|
||||
# Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)
|
||||
|
||||
**Приоритет:** низкий · **Теги:** ingest, review-2026-07-08
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
|
||||
- **Теги:** goal:state-integrity
|
||||
|
||||
Ревью Fable 2026-07-08 (приём). Косметические нити.
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Обучение на правках человека (few-shot из прошлых ревью)
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** правки человека (матч/тип/нумерация) не переиспользуются — few-shot из прошлых ревью поднял бы точность на «своих» трекерах без смены модели
|
||||
- **Теги:** goal:recognition-accuracy
|
||||
|
||||
Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать похожие в будущие промпты. Системно повышает точность на «твоих» трекерах и форматах имён без смены модели. Развитие идеи многоступенчатой верификации, но дешевле: учимся на уже собранных hint/override.
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Раздачи с докачиванием (merge при повторном добавлении)
|
||||
|
||||
**Приоритет:** высокий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** повторная заливка сериала целиком должна доложить недостающие эпизоды merge-раскладкой, не трогая существующие ссылки — блокирует типовой сценарий свежих сериалов
|
||||
- **Теги:** goal:complex-releases
|
||||
|
||||
Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают целиком, пользователь добавляет раздачу повторно. Новая загрузка приходит в ту же папку за счёт правила сходимости, а раскладка становится merge — доложить только недостающее. Существующие пути не трогаем (never-overwrite, владение у старой загрузки), новые кладём (владеет новая). Split-ownership сезона принят как норма per-path модели; обе раздачи сидируют независимо.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Кэш метабаз (и опционально LLM)
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** повторные и ретраящиеся прогоны бьют TMDB/TVDB/TVMaze одним запросом — кэш с TTL сэкономил бы лимиты и ускорил «Распознать заново»
|
||||
- **Теги:** goal:operational-resilience
|
||||
|
||||
Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и тем же запросом. Кэш ответов с TTL экономит лимиты API и ускоряет «Распознать заново»/«Уточнить». При желании — кэш ответов LLM по хешу входа (но он менее полезен, т.к. вход меняется подсказками).
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# [idea] Многоступенчатая верификация привязки
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** несколько проходов распознавания с консенсусом подняли бы точность ценой стоимости/латентности — проработать, когда включать и как мерджить расхождения
|
||||
- **Теги:** goal:recognition-accuracy
|
||||
|
||||
Несколько раз извлекать данные из раздачи и контекста разными промптами, искать в метабазах, затем сводить результаты в общий вердикт (голосование/консенсус) — выше точность ценой нескольких вызовов LLM и запросов к базам. Проработать: когда включать, как мерджить расхождения, стоимость/латентность.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Крайние случаи именования: многофайловый фильм, редакции, двойная серия
|
||||
|
||||
- **Секция:** Ядро продукта
|
||||
- **Зачем:** стэкинг частей (part1/cd1), редакции [edition-…] и двойная серия SxxEyy-Eyy описаны нарративом, но в file-layout не заказаны — раскладка таких раздач не определена
|
||||
- **Теги:** goal:complex-releases
|
||||
|
||||
Целевые имена для типового фильма и типового сезона заказаны
|
||||
[file-layout](../../../openspec/specs/file-layout/spec.md). Крайние случаи там
|
||||
не заказаны: до перевода на канон они жили разделом «Крайние случаи» нарратива
|
||||
`docs/specs/jellyfin-layout.md` (удалён, текст в истории git) как намерение, а
|
||||
не как требование. Значит, что делает код в этих случаях, без чтения кода
|
||||
неизвестно, и проверить это нечем.
|
||||
|
||||
Что надо определить и заказать спекой:
|
||||
|
||||
- **Многофайловый фильм** (фильм, разрезанный на части) — стэкинг по точному
|
||||
токену Jellyfin: `Имя (Год) - part1.mkv` либо `cd1`. Точный формат уточняется
|
||||
по документации Jellyfin: в нарративе он стоял с пометкой «уточнить при
|
||||
реализации».
|
||||
- **Редакции** — `Имя (Год) [edition-Director's Cut]` либо отдельные версии
|
||||
внутри папки фильма. Смежно с задачей про репаки и версии одного тайтла, но
|
||||
это про именование, а не про выбор версии.
|
||||
- **Двойная серия в одном файле** — `… SxxEyy-Eyy`.
|
||||
- **Спецвыпуски** — `Season 00`. Сперва проверить, не покрыты ли уже
|
||||
требованием «Роли файлов на краях раздачи» в
|
||||
[recognition](../../../openspec/specs/recognition/spec.md).
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
<!-- 2–5 проверяемых утверждений списком, у каждого назван оракул -->
|
||||
|
||||
## Рамки
|
||||
|
||||
<!-- одна строка: чего касаться нельзя, что перезапускается, что считается необратимым -->
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Привязка уведомлений к источнику в ботах (мульти-бот)
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** пинги и ревью должен получать автор загрузки в своём транспорте — нет привязки загрузки к источнику/отправителю (нужно для мульти-бота)
|
||||
- **Теги:** goal:ingest-and-review-interfaces
|
||||
|
||||
Уведомления и запросы подтверждения должен получать тот, кто прислал загрузку: автор сообщения о новой раздаче — адресат пингов и ревью по ней. Транспортов-ботов может быть несколько (Telegram, в перспективе Matrix и др.); каждый адресует «своему» отправителю. Веб-интерфейс остаётся единым для всех и точкой правды по функциональности (боты — тонкие адаптеры над тем же ядром). Нужно: хранить у загрузки источник/транспорт и идентификатор отправителя, маршрутизировать пинги по нему.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# [goal] Эксплуатационная прочность
|
||||
|
||||
- **Секция:** темы
|
||||
- **Зачем:** сервис работает, но не переживает роста: база копится вечно, бэкапа нет, отказ зависимости виден только по застрявшим задачам
|
||||
- **Теги:** decomposed
|
||||
|
||||
Ради чего: сегодня всё держится на том, что загрузок мало и всё рядом работает. Ретеншена нет, бэкапа нет, глубокого healthcheck нет, поведение под сотней загрузок не мерялось.
|
||||
|
||||
## Завершение
|
||||
|
||||
Достигнута, когда база не растёт бесконечно, состояние переживает потерю тома, отказ любой зависимости виден владельцу раньше, чем по застрявшим задачам, и поведение под сотней одновременных загрузок измерено, а не предположено.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)
|
||||
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** конвейер ревью переехал в плагин `av-dev-pipeline`; осталась калибровка проходов на этом проекте и ревьювер наименований (ждёт словарь единого языка)
|
||||
- **Теги:** goal:dev-process-quality
|
||||
|
||||
Набор проходов ревью поверх ревью-процесса из CLAUDE.md. Развивает ревью-процесс
|
||||
OpenSpec в сторону воспроизводимых автопроверок, не заменяя человеческое ревью.
|
||||
|
||||
## Сделано (2026-07-10)
|
||||
|
||||
Заведены два кастомных ревьювера: оптика спек/требований и оптика кода
|
||||
(архитектура, инварианты, конвенции, стиль, дублирование); оба подключены
|
||||
чекпоинтами в пайплайн задачи.
|
||||
|
||||
## Сделано (2026-07-23) — переработка конвейера
|
||||
|
||||
Конвейер пересобран по типу проходов, а не по ролям: гейт → сверка со спекой в
|
||||
обе стороны → generative-проходы → архитектура → враждебные постановки → триаж,
|
||||
профили `quick`/`standard`/`deep`/`design`, контракт находок, границы покрытия,
|
||||
храповик «находка → конвенция → правило → удаление», журнал проскочивших
|
||||
дефектов и процедура калибровки. Подробности — ADR
|
||||
[ADR-2026-07-23-review-pipeline-generative](../../adr/ADR-2026-07-23-review-pipeline-generative.md).
|
||||
|
||||
Открытый вопрос «дробить ли проход по конвенциям на узкие оптики» закрыт:
|
||||
**не дробим** — декорреляция внимания без декорреляции суждения почти не
|
||||
добавляет recall, но линейно удорожает триаж.
|
||||
|
||||
## Сделано (2026-08-04) — переезд в плагин
|
||||
|
||||
Проектные копии агентов (`.claude/agents/jellybit-review-*`) и скиллов
|
||||
(`review-pipeline`, `task-pipeline`, `task-batch`) удалены в пользу плагина
|
||||
`av-dev-pipeline`. Проектная специфика теперь приходит из документов канона —
|
||||
[docs/review.md](../../review.md): типовые узлы, ложноположительные, вопросы к
|
||||
проходам, триггеры профиля, недоступное проверке.
|
||||
|
||||
Два прохода плагин при этом **упразднил**, и это надо помнить:
|
||||
|
||||
- `idiom` — поимённая сверка со стайлгайдами языка не задаётся теперь ни одним
|
||||
проходом; способные части переселены в `ops` и `architecture`. Класс
|
||||
обратимый (портит форму кода, не данные) и признаётся в границах покрытия.
|
||||
- `negative` — вопрос «что опытный человек отсюда удалил бы» вошёл в
|
||||
`architecture` вторым обязательным.
|
||||
|
||||
## Осталось
|
||||
|
||||
- **Ревьювер наименований** (соответствие словарю единого языка) — отдельной
|
||||
оптикой не выделен: зависит от задачи «Словарь единого языка», без глоссария
|
||||
проверять не по чему. Завести после неё.
|
||||
- **Калибровка проходов** по процедуре `references/calibration.md` скилла
|
||||
`av-dev-pipeline:review-pipeline` — ни один проход ещё не замерен инъекцией.
|
||||
До замера ничего не удаляем и промпты не правим.
|
||||
- **Заполнить журнал дефектов** в [docs/review.md](../../review.md) случаями,
|
||||
которые уже проскочили ревью, — они станут первыми пробами калибровки.
|
||||
- **Решить судьбу упразднённых проходов:** нужен ли проекту свой `idiom` поверх
|
||||
плагина, или записи в «Недоступно проверке» достаточно.
|
||||
|
||||
Связано: CLAUDE.md (ревью-процесс, конвенции),
|
||||
[docs/conventions/](../../conventions/README.md), «Словарь единого языка».
|
||||
@@ -0,0 +1,11 @@
|
||||
# [goal] Точность распознавания
|
||||
|
||||
- **Секция:** темы
|
||||
- **Зачем:** смена модели или правка промпта сегодня вслепую — нет ни метрики, ни способа переиспользовать уже сделанные человеком правки
|
||||
- **Теги:** decomposed
|
||||
|
||||
Ради чего: распознавание — единственное место, где система может ошибиться молча и правдоподобно. Сегодня её точность не измеряется ничем, кроме впечатления, а накопленные правки человека пропадают.
|
||||
|
||||
## Завершение
|
||||
|
||||
Достигнута, когда точность распознавания меряется числом на фиксированном корпусе реальных раздач, смена модели или правка промпта прогоняются через этот корпус до выкатки, а решение auto/review опирается на измеримую силу совпадения, а не на самооценку модели.
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Eval-харнес распознавания (корпус кейсов + метрика точности)
|
||||
|
||||
**Приоритет:** высокий
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** смена модели или правка промпта распознавания сейчас вслепую — нет корпуса кейсов и метрики точности, регрессии не видно
|
||||
- **Теги:** goal:recognition-accuracy
|
||||
|
||||
Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую: регрессий не видно. Нужен корпус размеченных кейсов (русские релизы, аниме, сезон-паки, репаки, спецвыпуски) и прогон распознавания по нему с метрикой точности (тип/название/год/нумерация). Тогда можно сравнивать LLM-провайдеры и версии промпта по числам. Прогон — отдельной командой (jellybit eval или тестом), на фикстурах, без реального qBittorrent.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Полный редактор маппинга «файл → серия» и ручной режим ревью
|
||||
|
||||
- **Секция:** Ядро продукта
|
||||
- **Зачем:** правка S·E, «нумеровать подряд» и ручной режим при полном провале LLM были запланированы объёмом Ф5 и не заведены задачей — в ревью сегодня можно только подсказать текстом
|
||||
- **Теги:** goal:ingest-and-review-interfaces
|
||||
|
||||
Когда распознавание разложило файлы по сериям неверно, единственный путь —
|
||||
подсказать текстом и перераспознать. Точечно поправить номер серии у одного
|
||||
файла нельзя, а при полном провале LLM (ничего не вытащил) выхода нет вообще.
|
||||
|
||||
Материал, из которого задача выведена, — раздел «Объём по версиям» удалённого
|
||||
нарратива `docs/specs/review-ux.md` (полный текст в истории git). Заявленный там
|
||||
объём Ф5:
|
||||
|
||||
- **таблица «файл → серия»** с живой валидацией дыр и дублей нумерации и
|
||||
кнопкой «нумеровать подряд» — частый случай, когда файлы идут по порядку, но
|
||||
подписаны криво;
|
||||
- **ручной режим при полном провале LLM** — выбрать тип, ввести название и год,
|
||||
разложить файлы руками;
|
||||
- **выбор кандидата метабазы и ввод id прямо в Telegram** — сегодня это только
|
||||
в вебе, из бота идёт эскалация по deep-link.
|
||||
|
||||
Смежное: превью раскладки и единый список источников совпадения уже есть
|
||||
([review](../../../openspec/specs/review/spec.md)), так что задача про
|
||||
редактирование плана, а не про его показ.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
<!-- 2–5 проверяемых утверждений списком, у каждого назван оракул -->
|
||||
|
||||
## Рамки
|
||||
|
||||
<!-- одна строка: чего касаться нельзя, что перезапускается, что считается необратимым -->
|
||||
@@ -1,6 +1,8 @@
|
||||
# НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
|
||||
- **Теги:** goal:operational-resilience
|
||||
|
||||
Потолок по нагрузке нигде не зафиксирован: воркер, поллинг qBittorrent, пул LLM-вызовов и запись в SQLite спроектированы «на глаз». Записать в НФТ целевой ориентир — архитектура держит до 100 одновременных загрузок в работе (приём → распознавание → раскладка), план-максимум — 1000. Сама запись требования дешева и высокоценна: задаёт рамку для решений ниже. Отдельно (дороже) — аудит узких мест: одиночное соединение SQLite и сериализация записи, конкурентность воркера и лимит параллельных распознаваний, частота/стоимость поллинга и дедуп при наплыве.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Бэкап SQLite
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** architecture требует бекапить data-том, но стратегия не описана — сбой или редеплой стирают всё in-flight состояние (проще, пока БД маленькая)
|
||||
- **Теги:** goal:operational-resilience
|
||||
|
||||
architecture.md требует «бекапить data-том», но как — не описано. Без понятной стратегии сбой или редеплой стирают всё in-flight состояние. Зафиксировать решение и реализовать: периодический VACUUM INTO в /data/backups по расписанию (с ротацией) либо потоковая репликация (litestream). Лучше сделать, пока БД маленькая.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Мгновенные обновления через SSE
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** живые обновления на htmx-поллинге дают задержку и холостые запросы — SSE убрал бы то и другое (поллинг работает, поэтому улучшение, не блокер)
|
||||
- **Теги:** goal:ingest-and-review-interfaces
|
||||
|
||||
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает, но с задержкой в интервал опроса и холостыми запросами. Перевести динамический контент (прогресс загрузки, смена статуса, раздача) на Server-Sent Events, чтобы обновления приходили почти мгновенно и без лишнего поллинга. Поллинг работает, поэтому это улучшение, а не блокер; SSE — один долгоживущий ответ на соединение, ложится на server-rendered UI без тяжёлого фронтенда.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# [goal] Целостность состояния и приёма
|
||||
|
||||
- **Секция:** темы
|
||||
- **Зачем:** известные окна рассинхрона и потери маркеров: каждое по отдельности самоисцеляется, вместе — источник необъяснимых состояний
|
||||
- **Теги:** decomposed
|
||||
|
||||
Ради чего: состояние загрузки — то, по чему судят обо всём остальном. Накопились известные щели: окно namer'а, идентичность split v1/v2, потеря маркера dismiss, отсутствие истории переходов.
|
||||
|
||||
## Завершение
|
||||
|
||||
Достигнута, когда по записи загрузки можно ответить «как она сюда попала», ни один известный сегодня путь не оставляет состояние, которое не объясняется историей переходов, и идентичность раздачи не подделывается входом.
|
||||
+6
-4
@@ -1,6 +1,8 @@
|
||||
# Ревью уведомлений в Telegram (аудит текстов и формата)
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
|
||||
- **Теги:** goal:ingest-and-review-interfaces
|
||||
|
||||
Зонтичная задача: пройтись по всем исходящим уведомлениям и запросам подтверждения
|
||||
бота, выправить формулировки, состав данных и оформление. Тексты формируются в
|
||||
@@ -12,15 +14,15 @@
|
||||
- **Полнота карточек:** показываем ли нужное — название, тип (фильм/сериал), год,
|
||||
запись матча в метабазе, download id, причину `failed`.
|
||||
- **Единый язык:** в текстах бота вперемешку «задача»/«раздача»/«загрузка» —
|
||||
свести к доменному `Download` (см. [[ubiquitous-language-slovar]]).
|
||||
свести к доменному `Download` (см. [[ubiquitous-language-glossary]]).
|
||||
- **Оформление:** моноширинный download id — уже сделано (HTML parse mode +
|
||||
escape всех текстов, capability `notifications`); осталось при желании добавить
|
||||
акценты поверх включённого parse mode.
|
||||
- **Не дублировать** уже заведённое: матч метабазы в боте
|
||||
[[telegram-match-metabazy]], мульти-бот адресация уведомлений
|
||||
[[uvedomleniya-multi-bot]], выбор из нескольких находок [[telegram-vybor-nahodok]].
|
||||
[[notification-source-binding]], выбор из нескольких находок [[telegram-vybor-nahodok]].
|
||||
|
||||
Итог аудита — конкретные под-задачи (эта их порождает). Проход дешёвый, при желании
|
||||
приоритет можно поднять.
|
||||
|
||||
Связано: `internal/tgbot`, [docs/specs/review-ux.md](../specs/review-ux.md).
|
||||
Связано: `internal/tgbot`, [review](../../../openspec/specs/review/spec.md).
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** По калибровке болей (2026-07-02) — не боль, из приоритета выпало
|
||||
- **Теги:** goal:complex-releases
|
||||
|
||||
По калибровке болей (2026-07-02) — не боль, из приоритета выпало. Сосуществование версий доступно уже сейчас (Jellyfin multi-version, другой целевой путь), коллизия на тот же путь штатно уходит в review. Явный replace (undo старого хардлинка → lay нового → супересид владения путём) — отдельный change, если/когда станет болью.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Фетч .torrent по URL — остаток «единого окна»
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
||||
- **Теги:** goal:ingest-and-review-interfaces
|
||||
|
||||
Приём magnet и `.torrent`-файла уже реализован: ветка `TorrentData → torrent.Parse`
|
||||
(`internal/ingest/ingest.go`), файл-пикер в веб-форме (`web/templates/index.html`),
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Словарь единого языка (ubiquitous language)
|
||||
|
||||
**Приоритет:** средний
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** наименования домена расходятся между спеками, UI и кодом — нет единого глоссария (на нём же стоит агент-ревьювер наименований)
|
||||
- **Теги:** goal:dev-process-quality
|
||||
|
||||
Свести термины домена в один глоссарий, чтобы пользователь, документация, код и агент говорили на одном языке: загрузка, раздача, распознавание, матч, кандидат, раскладка, источник/цель, хардлинк, ревью, переход состояния и т.д. — русский термин, английский идентификатор в коде, краткое определение. Сейчас наименования расходятся между спеками, UI и кодом. Глоссарий — источник истины по именам; на нём же строится агент-ревьювер наименований.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Авторизация веб-UI (на будущее)
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** для v1 решено без авторизации (доверенная LAN, опц. allowlist подсетей) — задел на случай, если понадобится защита
|
||||
- **Теги:** goal:ingest-and-review-interfaces
|
||||
|
||||
Решено для v1: без авторизации в доверенной LAN, опц. allowlist подсетей (http.trusted_subnets) — как умеет qBittorrent. Если понадобится защита: токен/Basic в самом приложении или вынос за reverse-proxy с аутентификацией.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Современный Web-UI как PWA
|
||||
|
||||
**Приоритет:** низкий
|
||||
- **Секция:** ядро продукта
|
||||
- **Зачем:** текущий server-rendered UI функционален — PWA (устанавливаемое, удобное с телефона) это улучшение большого объёма, не блокер
|
||||
- **Теги:** goal:ingest-and-review-interfaces
|
||||
|
||||
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, отзывчивое, удобное с телефона). Текущий server-rendered UI функционален, поэтому это улучшение, а не блокер; большой объём работы.
|
||||
|
||||
Reference in New Issue
Block a user