## Context Экран ревью уже умеет много: выбор кандидата (`ChooseCandidate`), ручной ввод id (`SetProviderID`), «без базы» (`ClearProvider`), превью текущего плана (`ReviewData.Preview` через `layout.BuildLinks`). Механика выбора источника — это **overrides**: выбор кандидата пиннит `provider`, `provider_id` и (если есть) `title`/`year`, после чего эффективный план пересобирается `applyOverrides` и печатается превью путей. Проблемы текущего экрана — не в отсутствии операций, а в подаче: - нейронка и кандидаты баз показаны как разные сущности (план сверху, кандидаты снизу; «без базы» — особое состояние); - **превью есть только для уже выбранного** источника: чтобы увидеть пути для другого кандидата, его надо сначала выбрать (запись пиннится), т.е. «примерить вслепую». Ограничение capability `web-ui`: клиент не пересчитывает доменное состояние — все доменные величины (пути, поля) приходят с сервера (требование «Клиентские взаимодействия без сборки»). ## Goals / Non-Goals **Goals:** - Единый список источников: нейронка + кандидаты баз + добавленные вручную, один активный. - Предпросмотр полей (тип/название/год) и целевых путей **для любого источника до его выбора**, посчитанный на сервере. - Ручное добавление источника по id/URL как строки списка. - Переиспользовать существующую логику (`applyOverrides` + `layout.BuildLinks`), не дублируя правила именования. **Non-Goals:** - Fetch деталей записи из метабазы (режиссёр и пр.) — только резервируем место в UI. - **Сила совпадения кандидата** (per-candidate score) и сортировка/подсветка списка по ней — сегодня у кандидата такого поля нет; список берём в порядке сбора. Отдельная идея беклога (там же — пересмотр процесса распознавания/матчинга). - Изменение решения «авто vs review» и модели уверенности — не трогаем: авто по-прежнему только при единственном сильном матче + валидации, иначе review; фича работает **внутри** уже наступившего review. - Отдельный capability `review` и перекройка домена метабаз — отдельная задача беклога. - Редактор маппинга «файл → серия» и правки в Telegram. ## Decisions ### 0. Двухуровневая модель ревью сохраняется, нового подтверждения нет В ревью два независимых шага, оба остаются как есть: 1. **Выбор источника** — кнопки «выбрать» / «Задать id» / «Без базы» (POST → `ChooseCandidate`/`SetProviderID`/`ClearProvider`) **фиксируют** матч, записывая overrides. Файлы не раскладываются, лишь пересобирается план. 2. **«Применить»** (POST → `Apply`) — единственный шаг, создающий хардлинки. Фича добавляет **только предпросмотр до шага 1**: увидеть поля и целевые пути каждого источника, **не нажимая «выбрать»** (не трогая БД). Семантику «выбрать» и «Применить» не меняем, новых кнопок-подтверждений не вводим. ### 1. Предпросмотр считается на сервере эфемерно, без записи overrides Добавляем в `worker` чистый расчёт: по текущему плану и **гипотетическому** источнику `(provider, provider_id, опц. title/year)` собрать эффективные поля и `[]layout.Link`, **не записывая** overrides в БД. Технически — тот же `applyOverrides` + `layouter.BuildLinks(toLayoutPlan(...))`, что и в `ReviewData`, но поверх копии плана с временными пинами источника; в БД ничего не пишем. `ReviewData` расширяется срезом «источник → (поля, превью путей, активен ли)» для нейронки и каждого кандидата. Транспорт рендерит их строками списка. - **Почему так, а не «выбрать → посмотреть → отменить»:** выбор сейчас пиннит запись (пишет overrides) и меняет сохранённый матч; «примерка» не должна трогать состояние (инвариант «предпросмотр ничего не раскладывает и не фиксирует»). - **Альтернатива — отдельный эндпоинт `GET /review/{id}/preview?source=…`, отдающий htmx-фрагмент:** отвергнута для известных источников — превью считается быстро и без сети (`BuildLinks` — чистая работа с путями), их дешевле посчитать сразу и вложить в страницу; лишний раундтрип на каждое наведение не нужен. ### 1a. Единая деривация «источник → набор overrides» (гарантия preview == apply) **Проблема, найденная на ревью дизайна.** Пины `ovrTitle`/`ovrYear` пишутся `SetOverride` и не удаляются (в store нет `DeleteOverride`). `ChooseCandidate` пиннит title/year только при непустых полях кандидата (review.go:573-579), а `ClearProvider` их вообще не трогает (review.go:624-640). Значит после выбора кандидата «Fargo/2014» и последующего переключения на нейронку или на ручной кандидат без title запиненный `ovrTitle=Fargo` **остаётся**. Тогда `applyOverrides` (review.go:742) при `Apply` возьмёт `Fargo`, а эфемерное превью источника без собственного title покажет title из плана → **пути превью ≠ пути применения**. Это и латентный баг текущего кода (переключение кандидатов тянет чужой title). **Решение.** Ввести одну чистую функцию «источник → полный самосогласованный набор overrides» и использовать её **и в превью, и в коммите**: - источник-кандидат с title/year → пиннит их; **без** title/year → пишет **пустую строку** в `ovrTitle`/`ovrYear` (в `applyOverrides` пустая строка трактуется как «нет override», review.go:742,745 — `DeleteOverride` не нужен, берётся значение плана); - источник-нейронка (`ClearProvider`) → `provider=none` и пустые `ovrTitle`/`ovrYear` (поля из плана распознавания). Так каждый источник даёт детерминированный эффективный план; превью считается тем же набором overrides, что запишет выбор → **preview == apply по построению**. - **Затрагивает поведение `worker`** (`ChooseCandidate`/`ClearProvider` теперь очищают title/year), а не только web-ui. Это осознанно: спека остаётся в `web-ui` (наблюдаемое — «превью == применение» и «переключение не тянет чужие поля»), а правка команд ревью — реализация. Заодно чиним латентный баг. - **Альтернатива — превью «симулирует» унаследованные пины** (показывать чужой title у нейронки): отвергнута — противоречит принципу «нейронка = поля распознавания» и путает пользователя. ### 2. Клиент только показывает предпосчитанное, не считает пути Превью всех источников предпосчитываем на сервере и вкладываем в страницу (данных немного — кандидатов мало, порога/ленивой загрузки не вводим). Клиент лишь раскрывает/скрывает предпосчитанный блок строки по клику (нативный `
` или минимальный vanilla-JS) — никакого доменного пересчёта на клиенте, соблюдаем требование `web-ui`. UI-модель: секция **«Раскладка» остаётся отдельной** и показывает пути **активного** источника (как сейчас); раскрываемое инлайн-превью в строке — для сравнения **неактивных** источников до выбора. Фактическая смена активного источника (пиннинг) — по явному действию формой (раундтрип), как сейчас. ### 3. Нейронка — синтетическая строка, а не особый режим Строку «распознано нейронкой» синтезируем из сырого плана распознавания (`provider = none`): её поля — то, что дал LLM без базы, её превью — раскладка без тега провайдера. Выбор этой строки = существующий `ClearProvider`. Так «без базы» перестаёт быть отдельным состоянием UI и становится обычной строкой списка. ### 4. Ручной источник — кандидат в том же списке Ручной ввод (id или URL) на входной границе `httpapi` парсим в `(provider, provider_id)`. Допустимые провайдеры — `tmdb`, `tvdb`, `imdb` (набор согласован с `providerTag`/`providerURL`; `tvmaze` — только источник автопоиска, вручную не вводится). Добавляем как **строку списка**: сохраняем `metadata_candidate` с этим `provider`/`provider_id` и (если дан) `url`; `title`/`year` — пустые (деталей не тянем). Дедуп по `provider:id`: если такой источник уже в списке — не плодим строку, а выбираем существующую. Такой кандидат участвует в выборе и предпросмотре наравне с автонайденными; выбор — тот же `ChooseCandidate` (с очисткой title/year по решению 1a). **Парсинг URL реалистичен не для всех баз.** `providerURL` строит TVDB как `thetvdb.com/dereferrer/series/{numeric_id}`, но с сайта пользователь копирует `thetvdb.com/series/{slug}` — без числового id. Поэтому обещаем: **URL — для TMDB/IMDb**, для **TVDB — ручной ввод числового id** (slug из URL не распознаём). Список принимаемых паттернов фиксируем в реализации как обратный к `providerURL`. - Превью ручного источника корректно и без title/year из базы: имя папки берётся из title **плана**, а от источника меняется лишь тег провайдера в пути. Значит «предпросмотр путей» для ручного кандидата полноценен. - **Альтернатива — просто пиннить `SetProviderID` без строки в списке:** отвергнута — тогда ручной источник не «переключаемый» наравне с остальными, что противоречит принципу единого списка. ### 5. Режиссёр — зарезервированное место, источник позже В предпросмотре полей выводим строку «Режиссёр» пустой (прочерк). Fetch деталей из метабазы — отдельная задача; при появлении источника (детали по id или парсинг из контекста загрузки) заполняем это же место. ## Risks / Trade-offs - **[Рассинхрон превью и применения]** Превью и реальная раскладка должны идти одной логикой. → Оба используют `layout.BuildLinks`/`naming` и **одну деривацию «источник → overrides»** (решение 1a); в тестах проверяем равенство путей превью и применения (требование `web-ui` «Превью совпадает с реальной раскладкой», сценарий «Переключение источника не тянет чужие поля»). - **[Залипший override title/year]** Пины title/year не удаляются и могут утечь между источниками (латентный баг). → Решение 1a: выбор источника пишет полный самосогласованный набор (пустая строка = сброс к плану). - **[Раздувание страницы]** Предпосчёт превью для всех кандидатов кладёт N наборов путей в HTML. → Кандидатов немного (потолок сбора уже есть в `recognition`); `BuildLinks` без сети. Приемлемо; если станет тяжело — ленивый htmx-фрагмент (решение 1, альтернатива) как эволюция. - **[Ручной кандидат с пустыми title/year]** Строки списка и матч-ссылка должны переживать пустые поля. → `matchURL` уже строит URL из provider/id; заголовок берём из плана. Проверить рендер строки без title/year. - **[Дубль ручного и автокандидата]** Пользователь может ввести id, уже присутствующий в списке. → Дедуп по `provider:id` при добавлении (как в сборе кандидатов): не плодим строку, просто выбираем существующую. ## Migration Plan - Данные: миграций схемы не требуется — ручной кандидат ложится в существующую `metadata_candidate` (title/year nullable уже так). Новый источник строки — пользовательское действие, обратная совместимость полная. - Откат: изменения ограничены страницей ревью и добавочным методом предпросмотра в `worker`; откат — возврат прежнего шаблона/обработчика, данные не затрагиваются. ## Open Questions Блокирующих открытых вопросов нет. Оставшийся детерминированный на реализацию пункт — точный список принимаемых URL-паттернов ручного ввода (обратный к `providerURL`: `themoviedb.org/{movie,tv}/{id}`, `imdb.com/title/{id}`; TVDB — числовой id, не slug), см. решение 4.