- Раскладка 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.
12 KiB
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
- Миграция goose:
ALTER TABLE download ADD COLUMN parsed_context TEXT NOT NULL DEFAULT ''; обновить ER-схемуdocs/specs/database.md. - Существующие строки — с пустым
parsed_context; поведение имени для них не меняется (нижний слой пуст). Откат — колонка неиспользуемая, безопасно игнорируется; down-миграция дропает колонку. - Раскатка обычная (копия бинаря на umbar), без ручных шагов данных.
Open Questions
- Нет (развилки хранения/источника/сезона согласованы с пользователем до proposal).