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:
@@ -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`.
|
||||
+121
@@ -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)
|
||||
+59
@@ -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
|
||||
+44
@@ -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
|
||||
+105
@@ -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` и строку в индексе беклога
|
||||
Reference in New Issue
Block a user