- Раскладка 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.
154 lines
12 KiB
Markdown
154 lines
12 KiB
Markdown
## 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
|
||
с таймаутом клиента; провал не валит матч; кэш метабаз — отдельная задача
|
||
беклога (`metadata-cache`).
|
||
- **Рассинхрон формата ярлыка и сводки сезонов между стартом, обновлением и
|
||
карточкой** → устраняется единой функцией рендера и общим `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).
|