display_name: слоистое разрешение полей + сохранение режиссёра из контекста

Единый источник полей отображаемого имени и один рендер полного ярлыка на
всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный
«Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по
распознаванию — усечённый «Title (Year)».

- Слоистое разрешение скаляров имени: override → recognition(+match) →
  новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields).
- naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён
  FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary.
- Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный
  metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор
  кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст.
- refreshDisplayNameLocked строит полный ярлык из эффективных полей;
  инфо-панель ревью показывает загруженного режиссёра.
- Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку
  не влияет, приём/вывод имени не валятся (best-effort).

Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка».
OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest,
recognition, metadata-match, review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-11 11:50:08 +03:00
co-authored by Claude Opus 4.8
parent 9472bfdd83
commit 02d4ecc2aa
40 changed files with 1536 additions and 311 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-11
@@ -0,0 +1,153 @@
## Context
`download.display_name` — косметический ярлык (список qBittorrent + заголовок в
веб-UI), не влияющий на пути/раскладку. Сейчас его выводят два расходящихся пути:
- **Старт** (`worker.go:455`, `naming.DeriveName`): LLM извлекает `extracted`
(type/title/original_title/year/**director**/season), приватная `render`
собирает полный ярлык «Название (режиссёр, год). Сезон N». Структура после
рендера выбрасывается.
- **Обновление по распознаванию** (`review.go:1089`, `refreshDisplayNameLocked`):
ручная кнопка «Обновить имя» и авто-перелив при матче зовут
`naming.FormatTitleYear(plan.Title, plan.Year)` → усечённый `Title (Year)`.
В системе уже есть слоистое разрешение полей плана: `effectivePlan` читает
`recognition.Plan` (в него `Recognize` вкладывает каноничные title/year матча) и
накладывает `override` (ручные пины) через `applyOverrides`. Не хватает **нижнего
слоя «контекст»** и **поля режиссёра**.
Constraints (инварианты): вывод имени НИКОГДА не валит приём/добавление
(деградация к пустому); выход LLM и метабаз недоверенный; санитайзинг + лимит
`maxNameLen`; секреты не в логах; время UTC; ULID-идентификаторы; при изменении
схемы — миграция goose + ER-схема `docs/specs/database.md`.
## Goals / Non-Goals
**Goals:**
- Единый слоистый источник скалярных полей имени и **одна** функция рендера
полного ярлыка, используемая и на старте, и при обновлении.
- Режиссёр из контекста сохраняется (`parsed_context`) и не теряется; режиссёр из
метабазы (TMDB/TVDB credits) его перекрывает.
- Кнопка «Обновить имя»/авто-перелив дают полный формат (закрытие беклог-задачи
`knopka-obnovit-imya-polnyj-format`).
**Non-Goals:**
- Не вводим EAV-таблицу «поле+источник» и не переносим title/year из
Plan/override в новое хранилище (Plan структурен — `files[]`; дубль исказит
«где правда»).
- Не храним провенанс поля (источник выводится при разрешении, если понадобится
в UI).
- Не добавляем режиссёра в промпт распознавания (его уже извлекает контекстный
`naming`; в план он приходит из матча).
- Не трогаем логику раскладки/путей/безопасности.
## Decisions
### 1. Хранение контекста — JSON-колонка `download.parsed_context`
Извлечённую на старте структуру (`naming.extracted`) сериализуем JSON-ом в новую
колонку `download.parsed_context TEXT NOT NULL DEFAULT ''`. Это единственный
недостающий источник; он 1:1 с загрузкой, ставится один раз, читается точечно.
*Почему не таблица-спутник:* join ради 1:1 без выгоды. *Почему не колонки-на-поле:*
миграция на каждое под-поле; JSON эволюционирует свободно, как уже хранится Plan.
*Почему вообще persist, а не пере-извлечение из `context` при обновлении:* лишний
вызов LLM; пользователь явно просил «сохраняем».
### 2. Слоистое разрешение — хелпер в коде, не хранимый провенанс
Вводим структуру эффективных полей имени и хелпер, собирающий её из слоёв
override → recognition(+match) → parsed_context (первый непустой на поле).
Источник каждого поля выводится позицией слоя; хранить его не нужно. Хелпер живёт
рядом с `effectivePlan`/`refreshDisplayNameLocked` (worker), т.к. только он имеет
доступ ко всем трём слоям под `w.mu`.
### 3. Режиссёр: два входа из метабазы + слой override (решение A2)
`recognize.Plan` получает опциональное `Director string \`json:"director,omitempty"\``.
LLM его не заполняет и не валидирует. Режиссёр из метабазы приходит **двумя**
путями, оба best-effort (ошибка/пусто/провайдер-без-режиссёра — напр. TVMaze — не
валят матч):
- **Авто-матч** (`Recognize`/`matchMetadata`): при подтверждённом единичном матче
вкладываем режиссёра в `plan.Director` — ровно как уже вкладываются title/year.
- **Ручной выбор кандидата в ревью** (основной путь): `chooseCandidateLocked`/
`AddManualSource` при закреплении кандидата тянут режиссёра выбранного `provider:id`
из credits и пишут его как **director-override** (новое поле `ovr` в наборе пинов
источника рядом с provider/id/title/year). `applyOverrides` кладёт значение в
`plan.Director`. Так режиссёр выбранного кандидата переживает перезагрузку
страницы (override персистентен) без колонки на `metadata_candidate`.
Выборку credits по `provider:id` даёт новый метод интерфейса метабазы, проброшенный
в worker через интерфейс `Recognizer` (worker уже зависит от него; прямой зависимости
worker→metadata не заводим). Credits тянем **только** для подтверждённого/выбранного
источника, а не для каждого кандидата поиска — экономим внешние вызовы.
*Альтернатива A1 (отклонена пользователем):* режиссёр только из авто-матча —
на основном (ручном) пути подтверждения матча не проявлялся бы. *Альтернатива
(колонка `metadata_candidate.director` + фетч на поиске):* вторая миграция и фетч
для всех кандидатов — дороже, отклонена.
### 3a. Режиссёр — недоверенное косметическое поле
`director` (из контекста, из авто-матча или из override) — недоверенный вход. Он
НЕ входит в санитайзинг плана (`recognition` «Санитайзинг человекочитаемых полей»
чистит `title`/`original_title`/`provider_hint`) и НЕ участвует в структурной
валидации/гейте. Очистка (управляющие символы, пробелы, лимит) применяется к нему
на **рендере ярлыка** (`render`/`sanitize` уже это делают). На пути/раскладку
режиссёр не влияет.
### 4. Единый рендер полного ярлыка
Экспортируем из `internal/naming` функцию, строящую ярлык из эффективных полей
(та же логика, что приватная `render`): «Название (режиссёр, год)» + для сериала
хвост сезона. `FormatTitleYear` удаляем (или переводим на новый рендер).
`refreshDisplayNameLocked` вместо `FormatTitleYear(plan.Title, plan.Year)` зовёт
новый рендер по эффективным полям. Старт (`DeriveName`) использует тот же рендер.
### 5. Сводка сезонов — общая с UI, отдельная форма слоя
Сезон не разрешается как плоский скаляр: у слоя `recognition` он выражен
**per-file** (`plan.Files[].Season`) и сворачивается в строку через `seasonSummary`
(`httpapi/files.go:47`: один → «Сезон N», диапазон → «Сезоны 1–3», спецвыпуски), а
у слоя `parsed_context` это **скаляр** `Season *int` (даёт лишь «Сезон N»). Поэтому:
- Публичный рендер ярлыка принимает **готовую строку сводки сезонов**, а не сырое
число; хвост ярлыка — «. <сводка>» (пусто → хвоста нет).
- `effectiveNameFields` вычисляет эту строку по источнику: если есть план
распознавания с episode-ролями — `seasonSummary(plan)`; иначе (плана нет —
например ярлык на старте — или у сериала нет episode-ролей) fallback на
контекстный скаляр `parsed_context.season` → «Сезон N». Для фильма сезона нет.
- Логику `seasonSummary` выносим из `httpapi` в переиспользуемое место (`recognize`
или `naming`); `httpapi` и рендер зовут один хелпер — карточка страницы и ярлык
дают одинаковую сводку. Тесты `seasonSummary` переезжают вместе с кодом.
## Risks / Trade-offs
- **Доп. вызов credits к TMDB/TVDB при каждом подтверждённом матче** → best-effort
с таймаутом клиента; провал не валит матч; кэш метабаз — отдельная задача
беклога (`kesh-metabaz`).
- **Рассинхрон формата ярлыка и сводки сезонов между стартом, обновлением и
карточкой** → устраняется единой функцией рендера и общим `seasonSummary`
(ревью проверит, что все три пути зовут одно).
- **Миграция добавляет колонку существующим строкам** → `DEFAULT ''`, старые
загрузки просто без `parsed_context` (нижний слой пуст) — деградация штатная,
имя выводится из распознавания как и раньше.
- **`parsed_context` — недоверенный вход** (LLM/фолбек) → к его полям применяется
тот же санитайзинг/лимит на рендере; на пути/раскладку не влияет.
## Migration Plan
1. Миграция goose: `ALTER TABLE download ADD COLUMN parsed_context TEXT NOT NULL
DEFAULT ''`; обновить ER-схему `docs/specs/database.md`.
2. Существующие строки — с пустым `parsed_context`; поведение имени для них не
меняется (нижний слой пуст). Откат — колонка неиспользуемая, безопасно
игнорируется; down-миграция дропает колонку.
3. Раскатка обычная (копия бинаря на umbar), без ручных шагов данных.
## Open Questions
- Нет (развилки хранения/источника/сезона согласованы с пользователем до
proposal).
@@ -0,0 +1,74 @@
## Why
Отображаемое имя (`download.display_name`) сейчас выводится по двум расходящимся
правилам. На шаге добавления `naming` извлекает из контекста структуру
(тип/название/год/**режиссёр**/сезон) и рендерит **полный** ярлык
«Название (режиссёр, год). Сезон N», но структуру после рендера **выбрасывает**.
А обновление имени по распознаванию (ручная кнопка «Обновить имя» и авто-перелив
при матче) даёт **усечённый** `Title (Year)` — без режиссёра и сезона. В итоге
режиссёр, добытый из контекста, теряется, а перелив ухудшает уже показанное имя.
Причина расхождения — у распознавания нет поля режиссёра, а извлечение из
контекста нигде не сохраняется. Решаем в общем виде: поля имени приходят из
разных источников в разное время (контекст — на старте; метабаза — при
распознавании; правки — в ревью), поэтому вводим **единый слоистый источник** и
**одну** функцию рендера ярлыка.
## What Changes
- **Слоистое разрешение скалярных полей имени** (тип, название, ориг. название,
год, режиссёр, сезон-для-ярлыка): эффективное значение поля — первый непустой
слой сверху вниз `override` (человек) → `recognition` (LLM + матч метабазы) →
**новый базовый слой «контекст»** (извлечение `naming` на старте). Слои
`override``recognition` уже существуют (`effectivePlan`/`applyOverrides`);
добавляем нижний слой и единый хелпер разрешения.
- **Извлечение из контекста становится persistent.** Структуру, которую `naming`
извлекает на шаге добавления, сохраняем у загрузки в новой JSON-колонке
(`download.parsed_context`), чтобы переиспользовать без повторного вызова LLM.
- **Режиссёр из метабазы.** Подтверждённый матч TMDB/TVDB несёт режиссёра
(credits), он вкладывается в план так же, как уже вкладываются каноничные
название/год, и в разрешении бьёт контекстного (более проверенный источник).
- **Единый рендер ярлыка display_name.** Одна функция строит полный ярлык
«Название (режиссёр, год)» (+ для сериала сводка сезонов «. Сезон N» /
«. Сезоны 1–3» / «спецвыпуски») из эффективных полей и зовётся и на старте, и
при обновлении по распознаванию. Усечённый формат `Title (Year)` при обновлении
убирается. Это закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат».
- **Инфо-часть ревью** показывает загруженного режиссёра (ранее — пустое
зарезервированное место).
Всё перечисленное — косметика отображения: не влияет на пути файлов,
распознавание или раскладку. Приём и вывод имени по-прежнему не валят загрузку.
## Capabilities
### New Capabilities
(нет — вводится слой внутри существующих capability, новых доменов нет)
### Modified Capabilities
- `ingest`: извлечение из контекста сохраняется у загрузки (`parsed_context`);
обновление отображаемого имени переходит с `Title (Year)` на полный ярлык из
эффективных полей (слоистое разрешение + сводка сезонов).
- `recognition`: план несёт опциональное скалярное поле `director` (источник —
подтверждённый матч, не LLM); режиссёр — недоверенное косметическое поле.
- `metadata-match`: подтверждённый матч и кандидат несут режиссёра (TMDB/TVDB
credits), когда он доступен, для вывода имени.
- `review`: подтверждение матча обновляет имя полным ярлыком; инфо-часть
выбранного источника показывает загруженного режиссёра.
## Impact
- Код: `internal/naming` (публичный рендер из эффективных полей, экспорт
извлечённой структуры), `internal/recognize` (`Plan.Director`),
`internal/metadata` (`Candidate.Director` + выборка credits в клиентах
TMDB/TVDB), `internal/worker` (сохранение `parsed_context` на старте; хелпер
`effectiveFields`; полный формат в `refreshDisplayNameLocked`),
`internal/store` (миграция goose: колонка `download.parsed_context`; чтение/
запись), `internal/httpapi` (инфо-часть режиссёра; переиспользование
`seasonSummary`).
- БД: новая колонка `download.parsed_context` (TEXT, JSON) — обновить ER-схему
`docs/specs/database.md`.
- Внешние вызовы: дополнительный запрос credits к TMDB/TVDB при подтверждённом
матче (best-effort, недоступность не валит распознавание).
- Беклог: закрывается `docs/backlog/knopka-obnovit-imya-polnyj-format.md`.
@@ -0,0 +1,121 @@
# ingest Specification
## ADDED Requirements
### Requirement: Сохранение извлечённой из контекста структуры имени
Система SHALL сохранять структуру имени, извлечённую LLM со структурированным
выводом на шаге добавления (тип, название, оригинальное название, год, режиссёр,
сезон), у загрузки (`download.parsed_context`, JSON), чтобы её поля могли
переиспользоваться при последующем выводе имени без повторного вызова LLM.
Алгоритмический фолбек структуры не даёт (его выход — только строка имени), тогда
`parsed_context` остаётся пустым — это штатно (нижний слой отсутствует). Сохранённая структура SHALL
быть **базовым (наименее доверенным) слоем** источника полей имени: её значения
берутся, только если более доверенный слой (распознавание/матч, ручные правки)
соответствующего поля не дал.
Сохранение SHALL быть best-effort и косметическим: неудача записи `parsed_context`
SHALL NOT проваливать добавление загрузки, а сама структура SHALL влиять только на
отображаемое имя и SHALL NOT влиять на пути файлов, распознавание или раскладку.
Пустая/невыведенная структура (нет контекста и подсказки) SHALL приводить к
пустому `parsed_context` (нечего сохранять).
#### Scenario: Извлечённый режиссёр сохраняется у загрузки
- **GIVEN** контекст загрузки, из которого LLM извлёк режиссёра и год
- **WHEN** система выводит имя на шаге добавления
- **THEN** извлечённая структура (в т.ч. режиссёр) сохраняется в
`download.parsed_context`
- **AND** отображаемое имя формируется как и прежде (полный ярлык)
#### Scenario: Сбой сохранения структуры не валит добавление
- **GIVEN** запись `parsed_context` завершается ошибкой
- **WHEN** идёт шаг добавления загрузки
- **THEN** загрузка всё равно добавляется в qBittorrent (с `rename`, если имя
выведено)
- **AND** ошибка логируется, приём/добавление не проваливается
#### Scenario: Пустой вход — пустая структура
- **GIVEN** пойманная загрузка без контекста и без подсказки из полей источника
- **WHEN** выполняется шаг добавления
- **THEN** структура не выводится, `download.parsed_context` пуст
## MODIFIED Requirements
### Requirement: Обновление отображаемого имени по распознаванию
Система SHALL уметь обновлять отображаемое имя загрузки после того, как
распознавание дало каноническое название, — переливая уже вычисленное имя (без
нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent.
Источником имени SHALL быть **эффективные поля имени**, разрешённые по слоям
сверху вниз (берётся первый непустой слой): (1) ручные правки `override`;
(2) распознавание с вложенным подтверждённым матчем (`recognition`, куда матч
метабазы уже вложил каноничные название/год/режиссёра); (3) сохранённая
структура из контекста (`download.parsed_context`). Так каждое поле берётся из
самого доверенного доступного источника, а данные из контекста (например
режиссёр) не теряются, если распознавание/матч их не дали. Название из слоя
распознавания SHALL совпадать с тем, что использует раскладка (эффективный
`title`), чтобы отображаемое имя не расходилось с целевыми путями.
Формат SHALL быть тем же полным детерминированным ярлыком, что и на шаге
добавления: «Название (режиссёр, год)», где режиссёр и год опциональны, а для
сериала добавляется сводка сезонов («. Сезон N» для одного сезона; «. Сезоны …»
для многосезонного пака; отметка спецвыпусков) — согласованная со сводкой сезонов
на экране просмотра. Применяются та же очистка от управляющих символов и обрезка
по ограничению длины. Пустой источник (нет ни распознавания, ни сохранённой
структуры, дающих непустое название) SHALL приводить к отсутствию изменений
(no-op).
Переименование раздачи в qBittorrent SHALL адресоваться по infohash своей
раздачи и SHALL быть best-effort: сбой (раздача удалена, qBittorrent недоступен)
SHALL NOT проваливать обновление — `download.display_name` обновляется в любом
случае, ошибка внешнего вызова логируется. Как и на шаге добавления,
отображаемое имя SHALL влиять только на отображение и SHALL NOT влиять на пути
файлов, распознавание или раскладку.
Обновление имени SHALL иметь две точки входа: **авто** — при подтверждённом
матче (см. capability `review`); **ручную** — по явному действию пользователя.
Ручное действие SHALL перезаписывать текущее имя всегда; авто SHALL перезаписывать,
когда выведенное имя непусто.
#### Scenario: Перелив имени в загрузку и раздачу
- **GIVEN** загрузка с распознанным непустым каноническим названием и известным
режиссёром (из матча или из сохранённого контекста)
- **WHEN** запускается обновление отображаемого имени
- **THEN** `download.display_name` устанавливается в полный ярлык
«Название (режиссёр, год)» (для сериала — со сводкой сезонов)
- **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей
раздачи)
#### Scenario: Режиссёр из контекста переживает распознавание без матча
- **GIVEN** загрузка, где режиссёр был извлечён из контекста, а распознавание
прошло без подтверждённого матча (режиссёр из метабазы недоступен)
- **WHEN** запускается обновление отображаемого имени
- **THEN** в ярлыке используется режиссёр из сохранённого контекста
- **AND** название/год берутся из распознавания
#### Scenario: Режиссёр из матча бьёт контекстного
- **GIVEN** загрузка, где режиссёр есть и в контексте, и в подтверждённом матче
- **WHEN** формируется ярлык
- **THEN** используется режиссёр из матча (более доверенный слой)
#### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено
- **GIVEN** обновление отображаемого имени с выведенным непустым именем
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой (недоступен
или раздача удалена)
- **THEN** `download.display_name` всё равно обновлён
- **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается
#### Scenario: Нет источника имени — обновление ничего не делает
- **GIVEN** загрузка без распознанного названия и без сохранённой структуры
(пустой источник имени)
- **WHEN** запускается обновление отображаемого имени
- **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op)
@@ -0,0 +1,59 @@
# metadata-match Specification
## MODIFIED Requirements
### Requirement: Подтверждение матча и каноническое имя
При единичном сильном матче система SHALL брать из записи базы официальный
`provider` (`tmdb`|`tvdb`|`tvmaze`) и `provider_id`, а также каноническое название
и год, и подменять ими соответствующие поля плана (для сериала — с учётом внешнего
тега TVDB/IMDb из `externals`, идущего в имя папки). Матч SHALL считаться
подтверждённым только при ровно одном сильном кандидате; при нуле или нескольких
кандидатах подтверждённого матча быть SHALL NOT (авто-раскладка не разрешается,
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
сверка не выполняется и подтверждённого матча нет.
При подтверждённом матче система SHALL дополнительно попытаться получить из базы
**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как
недоверенное косметическое значение для вывода отображаемого имени. Тот же способ
выборки режиссёра по `provider:id` SHALL быть доступен при закреплении вручную
выбранного в ревью кандидата (см. `review`), т.к. основной путь подтверждения
матча — ручной выбор, а не авто. Выборка режиссёра SHALL быть best-effort: её
недоступность, отсутствие в базе или провайдер без режиссёра (напр. TVMaze) SHALL
NOT проваливать распознавание/матч/выбор — `director` остаётся пустым, а имя
выводится без режиссёра или из более низкого слоя (сохранённый контекст). Режиссёр
из метабазы SHALL иметь приоритет над режиссёром из контекста (более проверенный
источник).
Режиссёр — недоверенное человекочитаемое поле: он SHALL NOT участвовать в
структурной валидации/гейте авто-раскладки, а его очистка (управляющие символы,
пробелы, лимит длины) применяется при рендере отображаемого имени, а не в
plan-санитайзинге.
#### Scenario: Единичный матч даёт id и каноническое имя
- **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма
- **WHEN** матч подтверждается
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
#### Scenario: Матч подтягивает режиссёра
- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits
указан режиссёр
- **WHEN** матч подтверждается
- **THEN** в план вкладывается `director` из credits
- **AND** отображаемое имя может использовать этого режиссёра
#### Scenario: Режиссёр недоступен — матч не ломается
- **GIVEN** подтверждённый матч, для которого выборка режиссёра недоступна или
провайдер режиссёра не отдаёт
- **WHEN** матч подтверждается
- **THEN** `director` остаётся пустым
- **AND** матч подтверждён, распознавание не проваливается
#### Scenario: Несколько кандидатов — матч не подтверждён
- **GIVEN** поиск вернул более одного подходящего кандидата
- **WHEN** оценивается матч
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
@@ -0,0 +1,44 @@
# recognition Specification
## MODIFIED Requirements
### Requirement: Разбор сигналов LLM в структурированный план
Система SHALL передавать LLM недоверенные сигналы (имя торрента, дерево файлов с
размерами, текстовый контекст и накопленные подсказки, пред-парс) и получать
структурированный план в схеме: `type` (`movie`|`series`), `title`,
`original_title`, `year`, `provider_hint`, `files[]` и `confidence`. Каждый
элемент `files[]` SHALL нести `src`, `role`
(`main`|`episode`|`subtitle`|`extra`|`sample`|`ignore`) и, для сериала,
per-file `season`/`episode` (отдельного скалярного `season` быть SHALL NOT — так
выражаются мультисезонные паки и спецвыпуски). План SHALL приниматься только
если каждый `files[].src` совпадает с реальным файлом торрента.
План MAY дополнительно нести опциональное скалярное поле `director` (режиссёр).
Это поле НЕ требуется от LLM и НЕ участвует в структурной валидации или гейте
авто-раскладки; его заполняют подтверждённый матч метабазы (авто) или закреплённый
в ревью выбранный источник (через override, см. `metadata-match`/`review`) как
недоверенное косметическое значение для вывода отображаемого имени. Как недоверенное
человекочитаемое поле, `director` SHALL NOT входить в plan-санитайзинг (он чистит
`title`/`original_title`/`provider_hint`); очистка режиссёра применяется при рендере
имени. Пустой `director` SHALL быть штатным (режиссёр неизвестен).
#### Scenario: План сериала с per-file нумерацией
- **GIVEN** сезон-пак из 10 видеофайлов
- **WHEN** LLM возвращает план
- **THEN** `type` = `series`, а каждый видеофайл несёт свои `season`/`episode`
#### Scenario: Несуществующий src отклоняется
- **GIVEN** ответ LLM, где `files[].src` не совпадает ни с одним файлом торрента
- **WHEN** план разбирается
- **THEN** такой план не принимается как валидный
#### Scenario: Режиссёр не требуется от LLM и не влияет на гейт
- **GIVEN** ответ LLM без поля `director`
- **WHEN** план разбирается и оценивается
- **THEN** разбор успешен, `director` пуст
- **AND** отсутствие режиссёра не влияет на структурную валидацию и решение
auto/review
@@ -0,0 +1,105 @@
# review Specification
## MODIFIED Requirements
### Requirement: Подтверждение матча обновляет отображаемое имя
Система SHALL при подтверждении матча в ревью запускать обновление отображаемого
имени загрузки по подтверждённому распознаванию (см. capability `ingest`):
переливать **полный ярлык** имени — «Название (режиссёр, год)», для сериала со
сводкой сезонов — в `download.display_name` и в имя раздачи qBittorrent, без
нового вызова LLM. Имя строится из эффективных полей (override → распознавание с
вложенным матчем → сохранённый контекст). Подтверждением матча SHALL
считаться как выбор кандидата из списка совпадений, так и ручное добавление
источника по id/URL (оба закрепляют провайдера и каноническое название).
При закреплении выбранного/добавленного источника система SHALL best-effort
получить режиссёра этого источника из метабазы (credits по `provider:id`, см.
`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля
и в ярлык. Недоступность credits или отсутствие режиссёра SHALL NOT проваливать
выбор источника: режиссёр остаётся из более низкого слоя (сохранённый контекст)
или пустым. Так режиссёр из метабазы появляется и на **основном** пути
подтверждения — ручном выборе кандидата, а не только при авто-матче.
Обновление SHALL выполняться после успешного закрепления выбора кандидата и
SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
только при подтверждённом матче».
#### Scenario: Выбор кандидата обновляет имя
- **GIVEN** загрузка в ревью с пустым или неинформативным `display_name`
(например, «Unknown») и списком кандидатов
- **WHEN** пользователь выбирает кандидата, подтверждая матч
- **THEN** выбор кандидата закрепляется как и прежде
- **AND** `download.display_name` обновляется полным ярлыком
«Название (режиссёр, год)» (для сериала — со сводкой сезонов)
- **AND** раздача в qBittorrent переименовывается в то же имя
#### Scenario: Ручное добавление источника обновляет имя
- **GIVEN** загрузка в ревью без совпадений в списке
- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч
- **THEN** источник закрепляется как и прежде
- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются
полным ярлыком подтверждённого источника
#### Scenario: Выбор кандидата подтягивает режиссёра в ярлык
- **GIVEN** загрузка в ревью, у выбранного кандидата в credits метабазы указан
режиссёр
- **WHEN** пользователь выбирает кандидата, подтверждая матч
- **THEN** режиссёр best-effort извлекается из метабазы и закрепляется override
- **AND** `download.display_name` получает полный ярлык с этим режиссёром
#### Scenario: Режиссёр кандидата недоступен — выбор не ломается
- **GIVEN** выбор кандидата, для которого credits недоступны или режиссёра нет
- **WHEN** пользователь подтверждает матч
- **THEN** выбор источника выполнен, режиссёр берётся из сохранённого контекста
или остаётся пустым
- **AND** команда ревью не возвращает ошибку
#### Scenario: Недоступность qBittorrent не ломает выбор кандидата
- **GIVEN** выбор кандидата в ревью
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой
- **THEN** выбор кандидата и обновление `download.display_name` выполнены
- **AND** команда ревью не возвращает ошибку
### Requirement: Инфо и предпросмотр выбранного источника
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
название, оригинальное название, год, режиссёра (из подтверждённого матча/
кандидата, когда доступен; иначе пусто/прочерк, не ломая вёрстку), для сериала —
сводку сезонов (один сезон, диапазон/список для многосезонного пака или
«Спецвыпуски»); и **предпросмотр раскладки** — целевые пути хардлинков этого
источника. Обе части SHALL относиться именно к активному источнику и SHALL
обновляться при смене выбора. Отрисовка блока (показ инфо и предпросмотра) MUST
NOT создавать хардлинки: раскладка создаётся только явным действием «Применить».
Совпадение целевых путей предпросмотра с результатом применения регулируется
требованием «Превью раскладки через единую логику именования» (`web-ui`).
#### Scenario: Инфо и предпросмотр относятся к активному источнику
- **GIVEN** в списке активен кандидат метабазы
- **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки
- **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для
сериала) именно этого источника и предпросмотр его целевых путей
#### Scenario: Просмотр блока не создаёт раскладку
- **GIVEN** экран ревью с показанным блоком выбора источника
- **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая
«Применить»
- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не
меняются
#### Scenario: Режиссёр показан, когда доступен
- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней показан режиссёр этого источника
- **AND** при отсутствии режиссёра место остаётся пустым (или прочерком), не
ломая вёрстку
@@ -0,0 +1,71 @@
## 1. Хранение контекста (миграция + store)
- [x] 1.1 Миграция goose (`internal/store/migrations`): `ALTER TABLE download ADD
COLUMN parsed_context TEXT NOT NULL DEFAULT ''` (down — дроп колонки)
- [x] 1.2 `store.Download`: поле `ParsedContext string \`db:"parsed_context"\``;
включить колонку в SELECT/INSERT (`internal/store/download.go`, `list.go`)
- [x] 1.3 Метод `SetParsedContext(ctx, id, json string) error` (best-effort
апдейт) в store; добавить в интерфейс worker
- [x] 1.4 Обновить ER-схему `docs/specs/database.md` (новая колонка)
## 2. Извлечение из контекста → persistent
- [x] 2.1 `internal/naming`: экспортировать извлечённую структуру и её JSON
(публичный тип полей + метод/функция, возвращающая структуру вместе с именем),
не ломая текущий `DeriveName`
- [x] 2.2 `worker.go` шаг добавления: сохранять извлечённую структуру в
`download.parsed_context` (best-effort; сбой логируется, добавление не валит);
пустая структура → пустой `parsed_context`
## 3. Режиссёр из метабазы (решение A2)
- [x] 3.1 `metadata.Provider`: метод выборки режиссёра по id (credits) —
реализовать в клиентах TMDB и TVDB (best-effort); TVMaze → пусто/не поддержан
- [x] 3.2 `recognize.Plan`: поле `Director \`json:"director,omitempty"\``; LLM его
не заполняет, в валидации/гейте/plan-санитайзинге не участвует
- [x] 3.3 `Recognize`/`matchMetadata`: при подтверждённом авто-матче вкладывать
режиссёра в `plan.Director` (как уже вкладываются title/year), best-effort
- [x] 3.4 Интерфейс `Recognizer` (worker): метод выборки режиссёра по
`(provider, id)` — проброс к metadata-провайдерам (без прямой зависимости
worker→metadata)
- [x] 3.5 Ручной путь ревью: новое override-поле `ovrDirector`; добавить его в
`sourcePins`; `chooseCandidateLocked`/`AddManualSource` best-effort тянут
режиссёра выбранного `provider:id` и пишут в `ovrDirector` (пусто → очищает пин)
- [x] 3.6 `applyOverrides`: класть `ovrDirector` в `plan.Director`
## 4. Единый рендер ярлыка + слоистое разрешение
- [x] 4.1 Вынести логику `seasonSummary` (`httpapi/files.go`) в переиспользуемое
место (`recognize` или `naming`); `httpapi` зовёт общий хелпер (поведение
карточки не меняется)
- [x] 4.2 `internal/naming`: публичная функция рендера полного ярлыка из
эффективных полей (title/director/year + **готовая строка сводки сезонов**);
удалить/перевести `FormatTitleYear`
- [x] 4.3 `worker`: хелпер `effectiveNameFields(download, plan, overrides)` —
первый непустой слой override → recognition(+match) → parsed_context на поле;
сводка сезонов: `seasonSummary(plan)` при наличии episode-ролей, иначе fallback
на контекстный скаляр `Season` → «Сезон N»; director: override → plan → context
- [x] 4.4 `refreshDisplayNameLocked`: заменить `FormatTitleYear(...)` на рендер по
эффективным полям; сохранить best-effort qBit rename, no-op при пустом источнике
- [x] 4.5 Сверить, что старт (`DeriveName`) использует тот же рендер (единый
формат ярлыка на всех путях)
## 5. UI ревью: режиссёр в инфо-части
- [x] 5.1 `httpapi` инфо-часть выбранного источника: показывать режиссёра из
эффективного плана/кандидата, пустой → прочерк (без слома вёрстки)
## 6. Тесты и проверки
- [x] 6.1 Юнит-тесты рендера ярлыка: полный формат, опциональность режиссёра/года,
сводка сезонов (один/несколько/спецвыпуски), санитайзинг+лимит
- [x] 6.2 Тест слоистого разрешения: контекст-режиссёр переживает распознавание без
матча; матч-режиссёр бьёт контекстного; override бьёт оба
- [x] 6.3 Тест persist `parsed_context` на старте + best-effort (сбой не валит
добавление)
- [x] 6.4 Тест метабазного режиссёра: авто-матч TMDB/TVDB → `plan.Director`;
ручной выбор кандидата тянет режиссёра в `ovrDirector`; недоступность credits →
пусто, матч/выбор не падают
- [x] 6.5 `task lint` и `task test` зелёные; `openspec validate --strict`
- [x] 6.6 Удалить закрытую беклог-задачу
`docs/backlog/knopka-obnovit-imya-polnyj-format.md` и строку в индексе беклога
+79 -11
View File
@@ -132,13 +132,24 @@ JSON-вывод), извлекая из контекста тип (movie/series)
распознавание дало каноническое название, — переливая уже вычисленное имя (без
нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent.
Источником имени SHALL быть **эффективное** распознанное название: пины
`title`/`year` (если матч подтверждён), иначе `recognition.title`/`year` — то
же название, что использует раскладка. Формат SHALL быть коротким детерминированным
ярлыком `Title (Year)` (год опционален), с той же очисткой от управляющих
символов и обрезкой по длине, что и вывод имени на шаге добавления. Пустой
источник (нет распознавания или пустое название) SHALL приводить к отсутствию
изменений (no-op).
Источником имени SHALL быть **эффективные поля имени**, разрешённые по слоям
сверху вниз (берётся первый непустой слой): (1) ручные правки `override`;
(2) распознавание с вложенным подтверждённым матчем (`recognition`, куда матч
метабазы уже вложил каноничные название/год/режиссёра); (3) сохранённая
структура из контекста (`download.parsed_context`). Так каждое поле берётся из
самого доверенного доступного источника, а данные из контекста (например
режиссёр) не теряются, если распознавание/матч их не дали. Название из слоя
распознавания SHALL совпадать с тем, что использует раскладка (эффективный
`title`), чтобы отображаемое имя не расходилось с целевыми путями.
Формат SHALL быть тем же полным детерминированным ярлыком, что и на шаге
добавления: «Название (режиссёр, год)», где режиссёр и год опциональны, а для
сериала добавляется сводка сезонов («. Сезон N» для одного сезона; «. Сезоны …»
для многосезонного пака; отметка спецвыпусков) — согласованная со сводкой сезонов
на экране просмотра. Применяются та же очистка от управляющих символов и обрезка
по ограничению длины. Пустой источник (нет ни распознавания, ни сохранённой
структуры, дающих непустое название) SHALL приводить к отсутствию изменений
(no-op).
Переименование раздачи в qBittorrent SHALL адресоваться по infohash своей
раздачи и SHALL быть best-effort: сбой (раздача удалена, qBittorrent недоступен)
@@ -154,12 +165,28 @@ SHALL NOT проваливать обновление — `download.display_name
#### Scenario: Перелив имени в загрузку и раздачу
- **GIVEN** загрузка с распознанным непустым каноническим названием
- **GIVEN** загрузка с распознанным непустым каноническим названием и известным
режиссёром (из матча или из сохранённого контекста)
- **WHEN** запускается обновление отображаемого имени
- **THEN** `download.display_name` устанавливается в `Title (Year)`
- **THEN** `download.display_name` устанавливается в полный ярлык
«Название (режиссёр, год)» (для сериала — со сводкой сезонов)
- **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей
раздачи)
#### Scenario: Режиссёр из контекста переживает распознавание без матча
- **GIVEN** загрузка, где режиссёр был извлечён из контекста, а распознавание
прошло без подтверждённого матча (режиссёр из метабазы недоступен)
- **WHEN** запускается обновление отображаемого имени
- **THEN** в ярлыке используется режиссёр из сохранённого контекста
- **AND** название/год берутся из распознавания
#### Scenario: Режиссёр из матча бьёт контекстного
- **GIVEN** загрузка, где режиссёр есть и в контексте, и в подтверждённом матче
- **WHEN** формируется ярлык
- **THEN** используется режиссёр из матча (более доверенный слой)
#### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено
- **GIVEN** обновление отображаемого имени с выведенным непустым именем
@@ -168,9 +195,10 @@ SHALL NOT проваливать обновление — `download.display_name
- **THEN** `download.display_name` всё равно обновлён
- **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается
#### Scenario: Нет распознавания — обновление ничего не делает
#### Scenario: Нет источника имени — обновление ничего не делает
- **GIVEN** загрузка без распознанного названия (пустой источник имени)
- **GIVEN** загрузка без распознанного названия и без сохранённой структуры
(пустой источник имени)
- **WHEN** запускается обновление отображаемого имени
- **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op)
@@ -561,3 +589,43 @@ NOT.
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
### Requirement: Сохранение извлечённой из контекста структуры имени
Система SHALL сохранять структуру имени, извлечённую LLM со структурированным
выводом на шаге добавления (тип, название, оригинальное название, год, режиссёр,
сезон), у загрузки (`download.parsed_context`, JSON), чтобы её поля могли
переиспользоваться при последующем выводе имени без повторного вызова LLM.
Алгоритмический фолбек структуры не даёт (его выход — только строка имени), тогда
`parsed_context` остаётся пустым — это штатно (нижний слой отсутствует). Сохранённая структура SHALL
быть **базовым (наименее доверенным) слоем** источника полей имени: её значения
берутся, только если более доверенный слой (распознавание/матч, ручные правки)
соответствующего поля не дал.
Сохранение SHALL быть best-effort и косметическим: неудача записи `parsed_context`
SHALL NOT проваливать добавление загрузки, а сама структура SHALL влиять только на
отображаемое имя и SHALL NOT влиять на пути файлов, распознавание или раскладку.
Пустая/невыведенная структура (нет контекста и подсказки) SHALL приводить к
пустому `parsed_context` (нечего сохранять).
#### Scenario: Извлечённый режиссёр сохраняется у загрузки
- **GIVEN** контекст загрузки, из которого LLM извлёк режиссёра и год
- **WHEN** система выводит имя на шаге добавления
- **THEN** извлечённая структура (в т.ч. режиссёр) сохраняется в
`download.parsed_context`
- **AND** отображаемое имя формируется как и прежде (полный ярлык)
#### Scenario: Сбой сохранения структуры не валит добавление
- **GIVEN** запись `parsed_context` завершается ошибкой
- **WHEN** идёт шаг добавления загрузки
- **THEN** загрузка всё равно добавляется в qBittorrent (с `rename`, если имя
выведено)
- **AND** ошибка логируется, приём/добавление не проваливается
#### Scenario: Пустой вход — пустая структура
- **GIVEN** пойманная загрузка без контекста и без подсказки из полей источника
- **WHEN** выполняется шаг добавления
- **THEN** структура не выводится, `download.parsed_context` пуст
+33
View File
@@ -54,12 +54,45 @@
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
сверка не выполняется и подтверждённого матча нет.
При подтверждённом матче система SHALL дополнительно попытаться получить из базы
**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как
недоверенное косметическое значение для вывода отображаемого имени. Тот же способ
выборки режиссёра по `provider:id` SHALL быть доступен при закреплении вручную
выбранного в ревью кандидата (см. `review`), т.к. основной путь подтверждения
матча — ручной выбор, а не авто. Выборка режиссёра SHALL быть best-effort: её
недоступность, отсутствие в базе или провайдер без режиссёра (напр. TVMaze) SHALL
NOT проваливать распознавание/матч/выбор — `director` остаётся пустым, а имя
выводится без режиссёра или из более низкого слоя (сохранённый контекст). Режиссёр
из метабазы SHALL иметь приоритет над режиссёром из контекста (более проверенный
источник).
Режиссёр — недоверенное человекочитаемое поле: он SHALL NOT участвовать в
структурной валидации/гейте авто-раскладки, а его очистка (управляющие символы,
пробелы, лимит длины) применяется при рендере отображаемого имени, а не в
plan-санитайзинге.
#### Scenario: Единичный матч даёт id и каноническое имя
- **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма
- **WHEN** матч подтверждается
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
#### Scenario: Матч подтягивает режиссёра
- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits
указан режиссёр
- **WHEN** матч подтверждается
- **THEN** в план вкладывается `director` из credits
- **AND** отображаемое имя может использовать этого режиссёра
#### Scenario: Режиссёр недоступен — матч не ломается
- **GIVEN** подтверждённый матч, для которого выборка режиссёра недоступна или
провайдер режиссёра не отдаёт
- **WHEN** матч подтверждается
- **THEN** `director` остаётся пустым
- **AND** матч подтверждён, распознавание не проваливается
#### Scenario: Несколько кандидатов — матч не подтверждён
- **GIVEN** поиск вернул более одного подходящего кандидата
+17
View File
@@ -59,6 +59,15 @@ per-file `season`/`episode` (отдельного скалярного `season`
выражаются мультисезонные паки и спецвыпуски). План SHALL приниматься только
если каждый `files[].src` совпадает с реальным файлом торрента.
План MAY дополнительно нести опциональное скалярное поле `director` (режиссёр).
Это поле НЕ требуется от LLM и НЕ участвует в структурной валидации или гейте
авто-раскладки; его заполняют подтверждённый матч метабазы (авто) или закреплённый
в ревью выбранный источник (через override, см. `metadata-match`/`review`) как
недоверенное косметическое значение для вывода отображаемого имени. Как недоверенное
человекочитаемое поле, `director` SHALL NOT входить в plan-санитайзинг (он чистит
`title`/`original_title`/`provider_hint`); очистка режиссёра применяется при рендере
имени. Пустой `director` SHALL быть штатным (режиссёр неизвестен).
#### Scenario: План сериала с per-file нумерацией
- **GIVEN** сезон-пак из 10 видеофайлов
@@ -71,6 +80,14 @@ per-file `season`/`episode` (отдельного скалярного `season`
- **WHEN** план разбирается
- **THEN** такой план не принимается как валидный
#### Scenario: Режиссёр не требуется от LLM и не влияет на гейт
- **GIVEN** ответ LLM без поля `director`
- **WHEN** план разбирается и оценивается
- **THEN** разбор успешен, `director` пуст
- **AND** отсутствие режиссёра не влияет на структурную валидацию и решение
auto/review
### Requirement: Провайдер LLM за абстракцией со структурированным выводом
Доступ к LLM SHALL быть за интерфейсом с выбором реализации по полю `[llm].type`
+45 -18
View File
@@ -191,11 +191,21 @@ qBittorrent, но и **готов к раскладке** — раздача в
Система SHALL при подтверждении матча в ревью запускать обновление отображаемого
имени загрузки по подтверждённому распознаванию (см. capability `ingest`):
переливать каноническое название (`Title (Year)`) в `download.display_name` и в
имя раздачи qBittorrent, без нового вызова LLM. Подтверждением матча SHALL
переливать **полный ярлык** имени — «Название (режиссёр, год)», для сериала со
сводкой сезонов — в `download.display_name` и в имя раздачи qBittorrent, без
нового вызова LLM. Имя строится из эффективных полей (override → распознавание с
вложенным матчем → сохранённый контекст). Подтверждением матча SHALL
считаться как выбор кандидата из списка совпадений, так и ручное добавление
источника по id/URL (оба закрепляют провайдера и каноническое название).
При закреплении выбранного/добавленного источника система SHALL best-effort
получить режиссёра этого источника из метабазы (credits по `provider:id`, см.
`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля
и в ярлык. Недоступность credits или отсутствие режиссёра SHALL NOT проваливать
выбор источника: режиссёр остаётся из более низкого слоя (сохранённый контекст)
или пустым. Так режиссёр из метабазы появляется и на **основном** пути
подтверждения — ручном выборе кандидата, а не только при авто-матче.
Обновление SHALL выполняться после успешного закрепления выбора кандидата и
SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
@@ -207,8 +217,8 @@ NOT проваливать команду ревью. Это согласует
(например, «Unknown») и списком кандидатов
- **WHEN** пользователь выбирает кандидата, подтверждая матч
- **THEN** выбор кандидата закрепляется как и прежде
- **AND** `download.display_name` обновляется каноническим именем кандидата
`Title (Year)`
- **AND** `download.display_name` обновляется полным ярлыком
«Название (режиссёр, год)» (для сериала — со сводкой сезонов)
- **AND** раздача в qBittorrent переименовывается в то же имя
#### Scenario: Ручное добавление источника обновляет имя
@@ -217,7 +227,23 @@ NOT проваливать команду ревью. Это согласует
- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч
- **THEN** источник закрепляется как и прежде
- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются
каноническим названием подтверждённого источника
полным ярлыком подтверждённого источника
#### Scenario: Выбор кандидата подтягивает режиссёра в ярлык
- **GIVEN** загрузка в ревью, у выбранного кандидата в credits метабазы указан
режиссёр
- **WHEN** пользователь выбирает кандидата, подтверждая матч
- **THEN** режиссёр best-effort извлекается из метабазы и закрепляется override
- **AND** `download.display_name` получает полный ярлык с этим режиссёром
#### Scenario: Режиссёр кандидата недоступен — выбор не ломается
- **GIVEN** выбор кандидата, для которого credits недоступны или режиссёра нет
- **WHEN** пользователь подтверждает матч
- **THEN** выбор источника выполнен, режиссёр берётся из сохранённого контекста
или остаётся пустым
- **AND** команда ревью не возвращает ошибку
#### Scenario: Недоступность qBittorrent не ломает выбор кандидата
@@ -230,15 +256,15 @@ NOT проваливать команду ревью. Это согласует
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
название, оригинальное название, год, для сериала — сводку сезонов (один сезон,
диапазон/список для многосезонного пака или «Спецвыпуски»), с
зарезервированным местом под режиссёра; и **предпросмотр раскладки** — целевые
пути хардлинков этого источника. Обе части SHALL относиться именно к активному
источнику и SHALL обновляться при смене выбора. Отрисовка блока (показ инфо и
предпросмотра) MUST NOT создавать хардлинки: раскладка создаётся только явным
действием «Применить». Совпадение целевых путей предпросмотра с результатом
применения регулируется требованием «Превью раскладки через единую логику
именования» (`web-ui`).
название, оригинальное название, год, режиссёра (из подтверждённого матча/
кандидата, когда доступен; иначе пусто/прочерк, не ломая вёрстку), для сериала —
сводку сезонов (один сезон, диапазон/список для многосезонного пака или
«Спецвыпуски»); и **предпросмотр раскладки** — целевые пути хардлинков этого
источника. Обе части SHALL относиться именно к активному источнику и SHALL
обновляться при смене выбора. Отрисовка блока (показ инфо и предпросмотра) MUST
NOT создавать хардлинки: раскладка создаётся только явным действием «Применить».
Совпадение целевых путей предпросмотра с результатом применения регулируется
требованием «Превью раскладки через единую логику именования» (`web-ui`).
#### Scenario: Инфо и предпросмотр относятся к активному источнику
@@ -255,12 +281,13 @@ NOT проваливать команду ревью. Это согласует
- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не
меняются
#### Scenario: Зарезервированное место под режиссёра
#### Scenario: Режиссёр показан, когда доступен
- **GIVEN** режиссёр из метабазы пока не загружается
- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней присутствует место под режиссёра, показанное пустым (или
прочерком), не ломая вёрстку
- **THEN** в ней показан режиссёр этого источника
- **AND** при отсутствии режиссёра место остаётся пустым (или прочерком), не
ломая вёрстку
### Requirement: Разделение труда транспортов в ревью