recognize: название из метабазы санитизируется перед попаданием в план

- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
This commit is contained in:
av
2026-08-10 10:41:16 +03:00
parent bb278e8744
commit 9aecf757e0
26 changed files with 1653 additions and 53 deletions
@@ -0,0 +1,82 @@
# Значение метабазы чистится на каждой точке входа в план, а три санитайзера не сводятся в один
- **Дата:** 2026-08-10
- **Источник:**
[openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md](../../openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md),
разделы `Decisions` (Решения 1, 1a, 3) и `Non-Goals`
## Контекст
Название, приходящее из TMDB/TVDB/TVMaze, попадает в имя каталога библиотеки
Jellyfin. Выход LLM мы чистим и считаем недоверенным; название из метабазы того
же обращения не получало, хотя приходит так же — из-за периметра. Наблюдаемый
исход: каталог из невидимых символов выглядит пустым, кириллическая буква внутри
латинского слова даёт вторую папку, неотличимую от первой, и авто-раскладка это
пропускала.
Разбор показал, что точка входа не одна. Их четыре, и каждая ведёт в имя
каталога: сборка подтверждённого матча, копия кандидата, уходящая на экран
ревью и в хранилище, набор закреплённых значений выбранного человеком
источника и **чтение** уже закреплённого значения.
## Решение
**Чистка стоит на каждой из четырёх точек, а не в одной «правильной».**
Цитата из `design.md`, Решение 1a:
> Закрываются обе одной и той же чисткой, но в трёх местах — по одному на
> каждую точку, где значение метабазы входит в домен.
Плюс четвёртая, добавленная по находке эксплуатационного прохода: чистка **на
чтении** закреплённого значения. Гарантия чистоты не может держаться на времени записи строки —
кандидаты и закреплённые значения, сохранённые прежними версиями, обходят её,
а обычное
«Применить» ничего не перезаписывает. Санитайзинг идемпотентен, поэтому лишние
точки на уже чистом значении не делают ничего; это же свойство сделано
нормативным и покрыто тестом.
Отдельно: **гейт подтверждения матча чистка не двигает.** Сравнение кандидата с
планом идёт по значениям провайдера, чистится только копия, уходящая дальше.
Причина в том, что `normalize` и санитайзинг не эквивалентны: невидимый символ
внутри слова `normalize` превращает в пробел, а санитайзинг удаляет — чистка до
сравнения превратила бы часть нынешних «в review» в «авто».
## Рассмотренные варианты
- **Свести три санитайзера проекта в один.** Отвергнуто: у них разный предмет —
`recognize.SanitizeTitle` чистит значение, `layout.sanitizeComponent`
компонент пути под требования файловой системы, `naming.sanitize`
отображаемый ярлык. Свёртка гомоглифов — визуально неотличимых букв из разных алфавитов — внутри
`sanitizeComponent` сломала бы
правило сходимости базы папки: она гоняется и по имени, прочитанному с диска.
- **Закрыть только авто-путь, ручной отдать отдельной задаче.** Отвергнуто на
чекпоинте: спека `metadata-match` сама называет ручной выбор **основным**
путём подтверждения матча — починка коснулась бы менее употребимой половины,
а спека утверждала бы свойство, которого нет.
- **Чистить в клиентах метабаз.** Отвергнуто: пришлось бы повторять в трёх
клиентах и в каждом следующем, а проверка «в плане нет грязных полей»
перестала бы читаться в одном месте.
- **Разовая правка данных вместо чистки на чтении.** Отвергнута как более
дорогая и не закрывающая следующего читателя.
- **Полная нормализация Unicode** (NFC/NFKC плюс полная таблица визуально
совпадающих символов Unicode) — Non-Goal. Цель — предсказуемое и сверяемое значение, а не исчерпывающая защита
от визуального совпадения; курируемая кирилло-латинская таблица закрывает
реальный случай.
## Что осталось нерешённым намеренно
**Каталог с невидимым символом, уже созданный в библиотеке, кодом не лечится.**
Правило сходимости базы папки наследует имя от живой папки-якоря, и очистка
извлечённой базы напечатала бы рядом вторую, чистую папку — то есть ровно тот
исход с двумя каталогами, против которого затевалось изменение. Лечение —
переименовать папку руками, после чего сходимость подхватит новое имя.
Изменение закрывает появление новых таких каталогов, а не существующие.
## Цена
Точек чистки четыре вместо одной, и правило «значение метабазы чистится на
входе в домен» держится на ревью, а не на линтере. Взамен свойство «показанное
на экране совпадает с тем, что ляжет на диск» держится устройством кода: чистка
стоит в `sourcePins` — общем доме набора закреплённых значений, через который
идут и предпросмотр, и закрепление.
+1
View File
@@ -42,6 +42,7 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — |
| 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — |
| 2026-08-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — |
| 2026-08-04 | [Конвейер ревью и пайплайн задачи переезжают в плагины](ADR-2026-08-04-review-pipeline-to-plugin.md) | — |
+1
View File
@@ -111,6 +111,7 @@
| Время | `store.Now()` — единственный источник меток времени в данных, всегда UTC; формат хранения — RFC 3339. Вторая санкционированная точка wall-clock — timestamp-часть ULID в `ident.NewID` (исключение `^internal/(ident\|store)/` в `.golangci.yml`). Отдельно от меток в данных стоят замеры длительности: `cmd/jellybit` исключён из `forbidigo` целиком (правило `^cmd/`), плюс точечные `//nolint:forbidigo` в `internal/logging/ext.go` и `internal/httpapi/httpapi.go` |
| Идентификаторы | `internal/ident` — генерация и нормализация ULID; `ident.Parse` на каждой входной границе |
| Целевые имена и превью раскладки | `internal/naming` — одна логика для превью в UI и для реального применения |
| Чистка человекочитаемых значений | три санитайзера с разным предметом, сводить их в один нельзя: `recognize.SanitizeTitle` — значение (недоверенный вход: LLM и метабазы), `layout.sanitizeComponent` — компонент пути под требования ФС, `naming.sanitize` — отображаемый ярлык. Значение метабазы чистится **на каждой** точке входа в план: сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение — [ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md) |
| Разбор источника | `internal/magnet` и `internal/torrent`; инфохэш извлекается только здесь |
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
+42
View File
@@ -181,6 +181,10 @@ Go-сервиса и что здесь уже проскакивало. Устр
- `security`: читается ли тело ответа внешнего сервиса целиком без предела —
лимита на размер ответа LLM в проекте нет, и это единственный недоверенный
канал, где предел не стоит ([security.md](security.md) → «Что вне модели»)
- `operations`: гарантия, которую вводит изменение, поставлена на запись или на
чтение — и что будет с данными, записанными до деплоя, которые обычный путь
не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
очередь ревью её обходила)
- `operations`: не удваивает ли новая ветка расход лимита метабаз и платного
LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет,
задача `metadata-cache`)
@@ -316,6 +320,44 @@ Go-сервиса и что здесь уже проскакивало. Устр
случаи до этой даты не восстанавливались — восстановленная постфактум причина
непоймания недостоверна, а именно она и нужна.
## 2026-08-10 — чистка названия метабазы стояла только на записи, и очередь ревью её обходила [пойман]
- **Где:** `internal/worker/review.go``sourcePins`, `applyOverrides`,
`buildSources`. Норма — `openspec/specs/metadata-match/spec.md`, требование
«Санитайзинг названий кандидатов, уходящих в ревью», и
`openspec/specs/review/spec.md`, «Подтверждение матча обновляет отображаемое
имя».
- **Симптом:** найден на ревью самой задачи `metadata-title-sanitize`, до
мерджа. В эксплуатации не всплывал. Сошлись независимо четыре прохода:
`adversary` (построенный путь с падающим тестом), `ops` (постмортем),
`specs` и `code`.
- **Причина:** правка чистила значение метабазы **в момент записи** — при
копировании кандидата в список для ревью. Из этого следовали три дыры разом.
(1) Кандидаты, сохранённые прежними версиями, лежат в хранилище грязными, а
их выбор человеком закреплял название дословно. (2) `applyOverrides` читал
значение, закреплённое до деплоя, дословно, и обычное «Применить» без
повторного выбора источника создавало ровно тот каталог, ради которого
правка затевалась. (3) Предпросмотр источника на экране считался из сырого
названия, а гейт пригодности стоял только на закреплении — экран показывал
одно, раскладка делала другое, при том что «превью = применение» записано
требованием `web-ui`.
- **Чем воспроизведён:** тремя тестами, каждый падает без правки (проверено
прогоном с временно снятой правкой): `TestBuildSources_PreviewMatchesApply`,
`TestChooseCandidate_DirtyLegacyTitleSanitized`,
`TestApplyOverrides_LegacyDirtyPinSanitized`. Плюс прогон `adversary`:
превью `- (2014) [tvdbid-269613]/…` против применяемого
`Догадка (2014) [tvdbid-269613]/…` на одном экране.
- **Почему не поймали:** ловить было нечему — дефект поймали на этом же ревью,
до мерджа. Записывается ради причины его появления: **гарантия, поставленная
на запись, молчаливо не распространяется на данные, записанные раньше.**
Ревью дизайна дошло до «закрыть оба пути подтверждения матча», но точкой
закрытия выбрало запись, а не чтение; вопрос «а что с тем, что уже лежит в
хранилище» не задал никто из трёх проходов стадии дизайна. Его задал
эксплуатационный проход — на оси времени, где он и живёт.
- **Что меняем:** вопрос темы `operations` (ниже) — про гарантию, поставленную
на запись. Решение по существу — ADR-2026-08-10-sanitize-at-every-entry:
чистка стоит на каждой точке входа, включая чтение.
## 2026-08-06 — уборка своего торрента после отмены сносит чужие файлы [проскочил]
- **Где:** `internal/worker/worker.go:501-556` — гард `:501-509`, удаление
+1 -1
View File
@@ -29,7 +29,7 @@ REST API работают **без авторизации** осознанно;
| Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком |
| Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя |
| **Ответ LLM** | HTTP к эндпоинту | целиком под влиянием входа выше; названия, годы, номера сезонов и серий, из которых строится целевой путь |
| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь |
| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь; чистятся наравне с выходом LLM на каждой точке входа в план ([ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md)) |
| Ответы qBittorrent | HTTP | пути, состояния, размеры |
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
+8 -2
View File
@@ -20,6 +20,7 @@ import (
"log/slog"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/llm"
@@ -202,13 +203,18 @@ func render(f Fields) string {
return Label(f.Title, f.Director, f.Year, f.SeasonLabel())
}
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
// sanitize убирает управляющие и невидимые символы, переводы строк, схлопывает
// пробелы. Категорию Cf (zero-width, BOM, RLO) снимаем наравне с C0: значения
// приходят из метабазы и из сохранённого контекста, то есть снаружи, а ярлык
// уезжает в карточку Telegram, в шапку веб-UI и в имя раздачи qBittorrent —
// RLO там переворачивает отображение. Это же обещает требование metadata-match:
// режиссёр в плане не чистится именно потому, что его чистит рендер.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == '\r' {
return ' '
}
if r < 0x20 {
if r < 0x20 || unicode.Is(unicode.Cf, r) {
return -1
}
return r
+22
View File
@@ -158,3 +158,25 @@ func TestDeriveNameNilProviderUsesFallback(t *testing.T) {
t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got)
}
}
// Значения ярлыка приходят снаружи — из credits метабазы и из сохранённого
// контекста. Категория Cf (zero-width, BOM, RLO) обязана сниматься здесь:
// требование metadata-match не чистит режиссёра в плане именно потому, что его
// чистит рендер. RLO в карточке Telegram переворачивает отображение имени.
func TestSanitize_StripsFormatChars(t *testing.T) {
cases := []struct{ in, want string }{
{"Denis\u202eVilleneuve", "DenisVilleneuve"},
{"Denis\u200bVilleneuve", "DenisVilleneuve"},
{"Denis\ufeffVilleneuve", "DenisVilleneuve"},
{"Denis\nVilleneuve", "Denis Villeneuve"},
{"Дени Вильнёв", "Дени Вильнёв"},
}
for _, c := range cases {
if got := sanitize(c.in); got != c.want {
t.Errorf("sanitize(%q) = %q, want %q", c.in, got, c.want)
}
}
if got := Label("Dune", "Denis\u202eVilleneuve", 2021, ""); got != "Dune (DenisVilleneuve, 2021)" {
t.Errorf("Label = %q", got)
}
}
+17 -2
View File
@@ -72,13 +72,17 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
}
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
// Названия чистим на КОПИИ: выбор кандидата человеком — такой же
// путь подтверждения матча, как авто, и закреплённое название так
// же уезжает в имя каталога. Исходный cands не трогаем — по нему
// ниже ищется сильный матч, и сдвигать его гейт задача не заказывала.
for _, c := range cands {
ck := c.Provider + ":" + c.ID
if seen[ck] || len(candidates) >= maxCandidates {
continue
}
seen[ck] = true
candidates = append(candidates, c)
candidates = append(candidates, sanitizeCandidate(c))
}
// Единичный сильный матч ищем у первого подходящего провайдера.
@@ -139,10 +143,21 @@ func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c meta
}
}
prov, pid := CandidateTag(c)
// Каноническое название чистим здесь — в единственной точке сборки Match, и
// уже ПОСЛЕ гейта сильного матча (strongMatches сравнивает по значениям
// провайдера). Дальше по потоку значение считается чистым: и подстановка в
// план, и решение auto/review, и диагностика сухого прогона берут его как
// есть, а не чистят каждый заново.
title := c.Title
if clean := SanitizeTitle(title); clean != title {
logctx.FromOr(ctx, r.log).Debug("metadata match title sanitized",
"before", title, "after", clean)
title = clean
}
return &Match{
Provider: prov,
ProviderID: pid,
Title: c.Title,
Title: title,
Year: c.Year,
Director: directorOf(ctx, r.log, p, mt, c.ID),
SeasonEpisodeCounts: counts,
@@ -0,0 +1,220 @@
package recognize
import (
"context"
"testing"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// Провенанс набора входов — отчёт триажа ревью tvdb-title-locale (находка 2) и
// раздел «Воспроизведение» задачи metadata-title-sanitize. До правки каждый из
// них давал auto=true и уезжал в имя каталога библиотеки дословно.
const dunePlanResp = `{"type":"movie","title":"Dune","original_title":"Dune","year":2021,
"confidence":0.9,"files":[
{"src":"Dune.2021/movie.mkv","role":"main","season":null,"episode":null}
]}`
func duneInput() Input {
return Input{
Name: "Dune.2021.2160p.BluRay.x265",
Files: []File{{Path: "Dune.2021/movie.mkv", Size: 20 << 30}},
}
}
// recognizeWithMatchTitle прогоняет полный Recognize против базы, единственная
// запись которой названа dbTitle. Год и оригинальное название совпадают с планом,
// поэтому матч подтверждается и все прочие условия авто чисты — судим ровно
// подстановку названия.
func recognizeWithMatchTitle(t *testing.T, dbTitle string) Result {
t.Helper()
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "438631", Title: dbTitle, OriginalTitle: "Dune", Year: 2021},
}}
r := New(&fakeLLM{responses: []string{dunePlanResp}}, []metadata.Provider{p},
Config{MaxRetries: 2}, testLogger())
res, err := r.Recognize(context.Background(), duneInput())
if err != nil {
t.Fatalf("Recognize: %v", err)
}
return res
}
// Четыре входа из «Воспроизведения»: значение в плане обязано совпадать с тем,
// что дал бы санитайзер, а не с тем, что отдала база.
func TestRecognize_MatchTitleSanitized(t *testing.T) {
cases := []struct {
name string
db string
want string
auto bool
about string
}{
{
name: "zero-width внутри слова", db: "Du\u200bne", want: "Dune", auto: true,
about: "невидимка снимается, авто остаётся",
},
{
name: "RLO переворачивает отображение", db: "Dune\u202egnp.mkv",
want: "Dunegnp.mkv", auto: true,
about: "управляющий символ снимается",
},
{
name: "перевод строки", db: "Dune\nHACK", want: "Dune HACK", auto: true,
about: "перевод строки сводится к пробелу",
},
{
name: "кириллический двойник", db: "Dunа", want: "Duna", auto: true,
about: "двойник сворачивается в доминирующий скрипт",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res := recognizeWithMatchTitle(t, c.db)
if res.Match == nil {
t.Fatalf("матч должен быть подтверждён (%s)", c.about)
}
if res.Plan.Title != c.want {
t.Errorf("plan.Title = %q, want %q", res.Plan.Title, c.want)
}
if res.Plan.Title == c.db {
t.Errorf("значение базы уехало в план дословно: %q", res.Plan.Title)
}
if res.Decision.Auto != c.auto {
t.Errorf("auto = %v, want %v; reasons=%v",
res.Decision.Auto, c.auto, res.Decision.Reasons)
}
})
}
}
// Название, непригодное как имя каталога: пустое после чистки либо голая
// пунктуация. Подстановки не происходит, авто заблокирована причиной, но матч
// остаётся подтверждённым — id и год верны, распознавание не падает.
func TestRecognize_MatchTitleUnusable(t *testing.T) {
for _, db := range []string{"\u200b\u200b\u200b", ".", "...", "-", " . "} {
t.Run(db, func(t *testing.T) {
res := recognizeWithMatchTitle(t, db)
if res.Plan.Title != "Dune" {
t.Errorf("plan.Title = %q, want %q (название распознавания)",
res.Plan.Title, "Dune")
}
if res.Decision.Auto {
t.Error("авто-раскладка должна быть заблокирована")
}
if !hasReason(res.Decision.Reasons, "непригодно как имя каталога") {
t.Errorf("причина не названа: %v", res.Decision.Reasons)
}
if res.Match == nil || res.Match.ProviderID != "438631" {
t.Errorf("матч обязан остаться подтверждённым, got %+v", res.Match)
}
if res.Plan.Year != 2021 {
t.Errorf("год должен быть подставлен, got %d", res.Plan.Year)
}
})
}
}
// Нормальное название: значение базы доезжает как есть, авто разрешена. Это
// защита от регресса на TMDB — санитайзинг на чистом значении не делает ничего.
func TestRecognize_MatchTitleNormalUnchanged(t *testing.T) {
res := recognizeWithMatchTitle(t, "Dune: Part One")
if res.Plan.Title != "Dune: Part One" {
t.Errorf("plan.Title = %q, want %q", res.Plan.Title, "Dune: Part One")
}
if !res.Decision.Auto {
t.Errorf("auto = false, reasons=%v", res.Decision.Reasons)
}
}
// Кандидаты, уходящие в review, очищены: их названия закрепляет человек, и они
// становятся именем каталога так же, как каноническое.
func TestMatchMetadata_CandidatesSanitized(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Du\u200bne", OriginalTitle: "Dun\u200be", Year: 2021},
{Provider: "tmdb", ID: "2", Title: "Dunа Part Two", Year: 2024},
}}
r := recognizerWith(p)
_, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Nothing Matches Here", Year: 1900})
if len(cands) != 2 {
t.Fatalf("candidates = %d, want 2", len(cands))
}
if cands[0].Title != "Dune" || cands[0].OriginalTitle != "Dune" {
t.Errorf("кандидат не очищен: %+v", cands[0])
}
if cands[1].Title != "Duna Part Two" {
t.Errorf("двойник у кандидата не свёрнут: %q", cands[1].Title)
}
}
// Гейт матча не сдвинулся: кандидат, отличающийся от плана только невидимым
// символом внутри слова, сильным не считается. Сравнение идёт по значению
// провайдера — санитизируется только копия, уходящая в review.
func TestMatchMetadata_SanitizeDoesNotMoveGate(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Du\u200bne", Year: 2021},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
if m != nil {
t.Errorf("невидимка внутри слова не должна давать сильный матч, got %+v", m)
}
if len(cands) != 1 || cands[0].Title != "Dune" {
t.Errorf("кандидат обязан уйти в review очищенным, got %+v", cands)
}
}
// Список кандидатов не обеднел: после подтверждённого матча у первого провайдера
// остальные продолжают пополнять список для review.
func TestMatchMetadata_CandidatesFromAllProvidersKept(t *testing.T) {
a := &fakeProvider{name: "tmdb", candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Dune", Year: 2021},
}}
b := &fakeProvider{name: "tvdb", candidates: []metadata.Candidate{
{Provider: "tvdb", ID: "9", Title: "Dune Other", Year: 2021},
}}
r := New(&fakeLLM{}, []metadata.Provider{a, b}, Config{}, testLogger())
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
if m == nil {
t.Fatal("матч у первого провайдера должен подтвердиться")
}
if len(cands) != 2 {
t.Errorf("candidates = %d, want 2 (кандидаты второго провайдера не теряются)",
len(cands))
}
}
func TestUsableTitle(t *testing.T) {
usable := []string{"Dune", "2001", "Ne Zha", "Брат", "«Дюна»"}
unusable := []string{"", ".", "..", "...", "-", " . ", "—", "!?"}
for _, s := range usable {
if !UsableTitle(s) {
t.Errorf("UsableTitle(%q) = false, want true", s)
}
}
for _, s := range unusable {
if UsableTitle(s) {
t.Errorf("UsableTitle(%q) = true, want false", s)
}
}
}
// Идемпотентность несёт два утверждения сразу: что правка не двигает путей у
// раздач с нормальным названием и что «в плане нет несанитизированных полей»
// вообще проверяемо. Без теста она подразумевалась.
func TestSanitizeTitle_Idempotent(t *testing.T) {
inputs := []string{
"Du\u200bne", "Dune\u202egnp.mkv", "Dune\nHACK", "Dunа",
"Dune: Part One", " spaced out ", "\u200b\u200b\u200b", ".", "-",
"Тёмный рыцарь", "哪吒之魔童降世",
}
for _, in := range inputs {
once := SanitizeTitle(in)
if twice := SanitizeTitle(once); twice != once {
t.Errorf("sanitizeTitle не идемпотентен на %q: %q → %q", in, once, twice)
}
}
}
+7 -1
View File
@@ -260,7 +260,13 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
// review, когда единичного сильного матча нет.
match, candidates := r.matchMetadata(ctx, plan)
if match != nil {
plan.Title = match.Title
// Match.Title пришёл санитизированным (buildMatch чистит его в единственной
// точке сборки матча). Здесь остаётся только вопрос пригодности: название,
// не годное как имя каталога, не подставляем вовсе — в плане остаётся
// название распознавания, а decide уводит раздачу в review.
if UsableTitle(match.Title) {
plan.Title = match.Title
}
if match.Year != 0 {
plan.Year = match.Year
}
+37 -3
View File
@@ -4,6 +4,8 @@ import (
"log/slog"
"strings"
"unicode"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
@@ -34,17 +36,49 @@ func init() {
}
}
// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
// SanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
func sanitizeTitle(s string) string {
func SanitizeTitle(s string) string {
s = stripControl(s)
s = collapseSpaces(s)
s = foldHomoglyphs(s)
return s
}
// UsableTitle отвечает, годится ли название как компонент пути к файлу. Годится
// то, в чём после санитайзинга осталась хотя бы одна буква или цифра: layout
// снимает разделители и обрезает края от точек и пробелов, поэтому «.», «...»,
// «-» и прочая голая пунктуация схлопываются там в пустое имя каталога, а
// ведущая точка вдобавок делает каталог скрытым для Jellyfin.
//
// Проверять этим предикатом надо УЖЕ санитизированное значение: невидимые
// символы буквами не являются, но и не мешают — их снимает SanitizeTitle.
// Экспортирована вместе с SanitizeTitle, потому что тот же вопрос задаёт worker
// при закреплении выбранного человеком источника: правило одно, домов у него
// быть не должно.
func UsableTitle(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
}
return false
}
// sanitizeCandidate чистит человекочитаемые названия кандидата метабазы. Зовётся
// на КОПИИ кандидата в момент, когда он уходит из сверки дальше — в список для
// review, в хранилище, на экран и в закрепляемое человеком значение. Значения,
// по которым ищется сильный матч, при этом не меняются: сравнение обязано идти
// по тому, что отдал провайдер (см. metadata-match), иначе кандидат, отличающийся
// от плана невидимым символом, начал бы совпадать там, где прежде уходил в review.
func sanitizeCandidate(c metadata.Candidate) metadata.Candidate {
c.Title = SanitizeTitle(c.Title)
c.OriginalTitle = SanitizeTitle(c.OriginalTitle)
return c
}
// sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
@@ -57,7 +91,7 @@ func sanitizePlan(p *Plan, log *slog.Logger) {
}
func sanitizeField(v, field string, log *slog.Logger) string {
clean := sanitizeTitle(v)
clean := SanitizeTitle(v)
if clean != v && log != nil {
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
}
+2 -2
View File
@@ -49,8 +49,8 @@ func TestSanitizeTitle(t *testing.T) {
"The Matrix": "The Matrix",
}
for in, want := range cases {
if got := sanitizeTitle(in); got != want {
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want)
if got := SanitizeTitle(in); got != want {
t.Errorf("SanitizeTitle(%q) = %q, want %q", in, got, want)
}
}
}
+14 -4
View File
@@ -84,10 +84,10 @@ func validateSchema(p *Plan, in Input) error {
}
// decide считает решение модели уверенности (см. recognition.md). Авто —
// только если выполнено всё: подтверждённый единичный матч в базе; чистая
// структурная валидация (для сериала — число серий бьётся с базой);
// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая
// невыполненная — причина ухода в review.
// только если выполнено всё: подтверждённый единичный матч в базе; каноническое
// название матча пригодно как имя каталога; чистая структурная валидация (для
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
var reasons []string
@@ -98,6 +98,16 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
}
// Каноническое название базы, непригодное как имя каталога (пустое или без
// единой буквы и цифры), в план не подставлено — там осталось название
// распознавания. Матч при этом верен: id, год и режиссёр на месте, поэтому
// отклоняем не матч, а авто-раскладку. Match.Title уже санитизирован
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
// условия разъедутся на первой же правке одного из них.
if match != nil && !UsableTitle(match.Title) {
reasons = append(reasons, "название из базы непригодно как имя каталога")
}
reasons = append(reasons, structuralWarnings(p)...)
if match != nil && p.Type == MediaSeries {
+28 -3
View File
@@ -812,7 +812,8 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
if w.recognizer != nil {
director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID)
}
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year, director) {
pins := sourcePins(cand.Provider, cand.ProviderID, title, year, director)
for field, value := range pins {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
@@ -820,8 +821,13 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
// title_pinned=false означает, что название кандидата не годится как имя
// каталога и в плане осталось название распознавания. Без этого атрибута
// вопрос «почему папка названа догадкой, а не как в базе» по логам не
// разбирается: на авто-пути такой отказ несёт причина решения, здесь её нет.
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
"provider", cand.Provider, "provider_id", cand.ProviderID)
"provider", cand.Provider, "provider_id", cand.ProviderID,
"title_pinned", pins[ovrTitle] != "")
// Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
// выбор кандидата: логируем и продолжаем.
@@ -911,6 +917,18 @@ func sourcePins(provider, providerID, title string, year int, director string) m
if year > 0 {
yr = strconv.Itoa(year)
}
// Название источника — недоверенное значение метабазы, и чистится оно здесь,
// на единственном общем доме набора пинов: через sourcePins идут и превью
// источника, и его закрепление, поэтому «превью = применение» держится
// конструкцией, а не памятью. Чистка идемпотентна — на кандидате, записанном
// уже с чисткой, она ничего не меняет, а строку из БД, сохранённую прежней
// версией, приводит в порядок. Непригодное как имя каталога название пином не
// становится: пустое значение очищает пин, и в плане остаётся название
// распознавания.
title = recognize.SanitizeTitle(title)
if !recognize.UsableTitle(title) {
title = ""
}
return map[string]string{
ovrProvider: provider,
ovrProviderID: providerID,
@@ -1297,8 +1315,15 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
// раскладка пропустит).
//
// Название чистится здесь, НА ЧТЕНИИ, и это не дубль чистки в sourcePins: та
// держит хранилище чистым, а эта защищает от значений, записанных прежними
// версиями. Пин, закреплённый до появления санитайзинга, иначе доезжает до имени
// каталога дословно при обычном «Применить» — без повторного выбора источника
// запись в хранилище никто не перепишет. Чистка идемпотентна, так что на пинах,
// записанных уже с ней, обе не делают ничего.
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
if t := overrides[ovrTitle]; t != "" {
if t := recognize.SanitizeTitle(overrides[ovrTitle]); recognize.UsableTitle(t) {
plan.Title = t
}
if y := overrides[ovrYear]; y != "" {
+121
View File
@@ -2285,3 +2285,124 @@ func TestSweepLinking_LeavesOtherStates(t *testing.T) {
t.Errorf("review task moved to %q", st.downloads["2"].State)
}
}
// Кандидат с непригодным названием (голая пунктуация — TVDB правится
// сообществом, мусорные записи там штатны): пин названия не ставится, и в
// эффективном плане остаётся название распознавания. Иначе в библиотеке
// появился бы каталог «- (2000)», а на «.» раскладка упала бы ошибкой слоя
// layout — тот же исход, что и у канонического названия на авто-пути.
func TestChooseCandidate_UnusableTitleNotPinned(t *testing.T) {
for _, title := range []string{"-", ".", "..."} {
t.Run(title, func(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString(title),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if ov := st.overrides["1"]; ov[ovrTitle] != "" {
t.Errorf("непригодное название закреплено: %q", ov[ovrTitle])
}
// Провайдер, id и год закрепляются как обычно — матч верен.
if ov := st.overrides["1"]; ov[ovrProviderID] != "269613" || ov[ovrYear] != "2014" {
t.Errorf("overrides = %v", st.overrides["1"])
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" {
t.Errorf("plan.Title = %q, want название распознавания", plan.Title)
}
})
}
}
// Кандидат, сохранённый ПРЕЖНЕЙ версией (до чистки на входе в список), лежит в
// БД грязным. Гарантия чистоты не может держаться на времени записи: чистка
// стоит на закреплении, в sourcePins, и идемпотентна — на новых строках это
// no-op. Без неё невидимка доезжает до имени каталога, потому что
// layout.sanitizeComponent категорию Cf не трогает.
func TestChooseCandidate_DirtyLegacyTitleSanitized(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString("Far\u200bgo"),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if got := st.overrides["1"][ovrTitle]; got != "Fargo" {
t.Errorf("закреплено %q, want %q — грязная строка из БД не очищена", got, "Fargo")
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Fargo" {
t.Errorf("plan.Title = %q", plan.Title)
}
}
// Превью = применение: строка источника показывает ровно то название, которое
// закрепится по клику. Прежде превью считалось из сырого названия кандидата, а
// гейт пригодности стоял только на закреплении — экран обещал одно, раскладка
// делала другое.
func TestBuildSources_PreviewMatchesApply(t *testing.T) {
for _, c := range []struct{ name, dbTitle, want string }{
{"непригодное название", "-", "Догадка"},
{"грязное название", "Far\u200bgo", "Fargo"},
} {
t.Run(c.name, func(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString(c.dbTitle),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
rd, err := w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
var src *SourceOption
for i := range rd.Sources {
if rd.Sources[i].Kind == SourceCandidate {
src = &rd.Sources[i]
}
}
if src == nil {
t.Fatal("кандидат не попал в источники")
}
if src.Title != c.want {
t.Errorf("превью показывает %q, want %q", src.Title, c.want)
}
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != src.Title {
t.Errorf("превью %q, применилось %q — расхождение", src.Title, plan.Title)
}
})
}
}
// Пин, закреплённый ПРЕЖНЕЙ версией, лежит в overrides грязным, и обычное
// «Применить» его не переписывает — источник заново не выбирают. Поэтому чистка
// стоит и на чтении: иначе раздача, стоящая в ревью на момент выката, создаёт
// ровно тот каталог, ради которого затевалась правка.
func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000}
got := applyOverrides(plan, map[string]string{ovrTitle: "Fa\u200brgo"})
if got.Title != "Fargo" {
t.Errorf("plan.Title = %q, want %q — грязный пин доехал до раскладки", got.Title, "Fargo")
}
// Непригодный пин названием не становится: остаётся название распознавания.
if got := applyOverrides(plan, map[string]string{ovrTitle: "-"}); got.Title != "Догадка" {
t.Errorf("plan.Title = %q, want название распознавания", got.Title)
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-10
@@ -0,0 +1,215 @@
## Context
Распознавание строит план в два приёма. Сперва разбирается ответ LLM:
`parsePlan` вызывает `sanitizePlan`, и три человекочитаемых поля — `title`,
`original_title`, `provider_hint` — чистятся как недоверенный вход. Потом идёт
сверка с метабазой, и при подтверждённом матче `recognize.go` подменяет поля
плана каноническими значениями:
```go
match, candidates := r.matchMetadata(ctx, plan)
if match != nil {
plan.Title = match.Title // ← значение внешнего сервиса, мимо чистки
...
}
dec := decide(plan, pre, match, ...)
```
Подстановка стоит **после** чистки, поэтому очищено ровно то, что чисткой потом
и перезаписывается. Дальше `plan.Title` уходит в `layout.titleYear` и становится
именем каталога библиотеки.
Ниже по потоку `layout.sanitizeComponent` снимает разделители пути, символы,
недопустимые в SMB/NTFS, и байты `< 0x20`. Категорию Cf (zero-width, BOM,
мягкий перенос) он не трогает и гомоглифы не сворачивает — они не мешают
файловой системе и потому там не при чём.
Гейт авто-раскладки от этого не спасает: `metadata.go` сверяет
`normalize(c.Title) || normalize(c.OriginalTitle)`, то есть кандидат проходит по
**любому** из двух полей, а в план и в путь уезжает только первое.
Инвариант «целевой путь строго под библиотекой» при этом держится — ревью
проверило `Dune/../../etc`, `" .. "`, `"..."` и 400 символов, выхода из
песочницы нет. Речь о предсказуемости имени, а не о песочнице.
## Goals / Non-Goals
**Goals:**
- Название из метабазы получает ту же чистку, что и название от LLM, до того как
попадёт в план.
- Название, схлопнувшееся чисткой в пустое, не порождает каталог с пустым именем
и не роняет раскладку.
- У чистки названий остаётся один дом: второй реализации не заводится.
- Поведение TMDB на нормальных названиях не меняется — регресс на работающем
провайдере дороже самого дефекта.
**Non-Goals:**
- Полная нормализация Unicode (NFC/NFKC, конфузаблы по таблице Unicode). Рамка
задачи это прямо исключает: цель — предсказуемое и сверяемое значение, а не
исчерпывающая защита от визуального совпадения.
- Расширение состава `layout.sanitizeComponent`. Он чистит компонент пути под
требования файловой системы, и категория Cf ему не мешает.
- Чистка `director` в плане. Действующее требование `metadata-match` относит её к
рендеру отображаемого имени, и трогать это изменение не будет.
- Пересмотр гейта матча (`normalize(c.Title) || normalize(c.OriginalTitle)`).
Расхождение «сверяем по двум полям, подставляем одно» после этой правки
перестаёт быть опасным: подставляется очищенное значение.
## Decisions
### Решение 1. Чистить на подстановке, в `recognize`
Санитайзинг применяется к `match.Title` в месте подстановки — там же, где
сегодня стоит `plan.Title = match.Title`. Используется существующая
`sanitizeTitle`, без новых функций.
*Почему здесь.* Место подстановки — единственная точка, где значение метабазы
входит в план; всё, что ниже, работает уже с планом. Чистка здесь означает
инвариант, который можно сформулировать одной фразой и проверить: **план после
матча не содержит несанитизированных человекочитаемых полей**.
*Рассмотрено и отвергнуто:*
- **Закрыть категорию Cf и гомоглифы в `layout.sanitizeComponent`.** Дало бы
второй дом чистке названий и разошлось бы с первым: `sanitizeTitle` сворачивает
гомоглифы потокенно по доминирующему скрипту — правило нетривиальное, и две его
реализации разъедутся молча. Плюс `sanitizeComponent` работает и на
`FolderBase`, прочитанной с диска, — там свёртка гомоглифов сломала бы
сходимость к существующей папке.
- **Чистить в клиентах метабаз (`internal/metadata/*.go`).** Пришлось бы
повторить в трёх клиентах и повторять в каждом следующем; проверка «в плане нет
грязных полей» перестала бы читаться в одном месте.
- **Прогнать `sanitizePlan` ещё раз после матча.** Внешне дешевле всего, но
вторым проходом чистит и то, что уже чисто, а главное — молчит о случае, когда
название базы схлопнулось в пустое: `p.Title` стал бы пустым, и раскладка
упала бы на `layout: empty title after sanitization`.
### Решение 1a. Точек входа две, и закрываются обе
Ревью дизайна показало, что подстановка при авто-матче — не единственный вход
значения метабазы в план. Второй: человек выбирает кандидата на ревью, его
название закрепляется как override и попадает в план мимо распознавания. Спека
`metadata-match` при этом сама называет ручной выбор **основным** путём
подтверждения матча — закрыть только авто-путь значило бы починить менее
употребимую половину и записать в спеку свойство, которого нет.
Закрываются обе одной и той же чисткой, но в трёх местах — по одному на каждую
точку, где значение метабазы входит в домен:
1. **каноническое название матча** — в `buildMatch`, единственной точке сборки
`Match`. Дальше по потоку значение считается чистым: и подстановка в план, и
решение auto/review, и диагностика сухого прогона берут его как есть. Ревью
кода показало, чем плоха чистка на подстановке: условие пригодности
пересчитывалось независимо в двух файлах, и первая же правка одного из них
дала бы молчаливую авто-раскладку;
2. **названия кандидатов** — в момент, когда кандидат копируется в список для
ревью (`matchMetadata`, накопление `candidates`). Оттуда чистое значение
уезжает разом в хранилище, на экран ревью и в карточку Telegram;
3. **набор пинов источника**`sourcePins` в `worker`. Это не дубль пункта 2, а
ответ на два разных вопроса. Во-первых, через `sourcePins` идут **и** превью
источника, **и** его закрепление, поэтому свойство «превью = применение»
держится конструкцией: без чистки здесь экран показывал бы одно название, а
раскладка делала другое. Во-вторых, пункт 2 чистит **на записи**, то есть
гарантия держалась бы на времени записи строки — все кандидаты, сохранённые
до этой правки и стоящие в очереди ревью, обошли бы её. Чистка идемпотентна,
поэтому на новых строках пункт 3 не делает ничего.
*Почему это не двигает гейт матча.* Сильный матч ищется по `cands` — срезу, как
его отдал провайдер, — а в ревью уходит **копия** в `candidates`. Чистка на
копировании до сравнения не доходит. Проверять это пришлось отдельно: сравнение
идёт через `normalize`, и она **не** эквивалентна санитайзингу — zero-width
внутри слова `normalize` превращает в пробел (`Du␀ne``du ne`), а санитайзинг
удаляет (`dune`). То есть чистка всего списка `cands` до сравнения превратила бы
часть нынешних «в review» в «авто», и это был бы сдвиг гейта, которого задача не
заказывала.
*Рассмотрено и отвергнуто:* звать санитайзер прямо в `chooseCandidateLocked`
это закрыло бы закрепление и оставило превью считаться по сырому значению, то
есть развело бы показанное и применённое. Правило живёт в `sourcePins`, потому
что это единственный общий дом набора пинов; запланированный
`review-mapping-editor` придёт туда же, а не заведёт четвёртый вызов.
*Что при этом экспортируется:* `recognize.SanitizeTitle` и `recognize.UsableTitle`
— пара «почисти и проверь пригодность». Экспортировать пришлось обе: предикат без
санитайзера обязывал бы вызывающего помнить порядок, а порядок прозой не
проверяется.
### Решение 2. Пустой результат чистки — прежнее название и уход в review
Если `sanitizeTitle(match.Title)` даёт пустую строку, подстановка не выполняется:
в плане остаётся название от LLM (оно уже прошло чистку и непустое — иначе
`validateSchema` отклонил бы план), а в причины решения добавляется строка, из-за
которой раздача уходит в ревью.
*Почему так.* Из трёх исходов — упасть, подставить пустое, оставить прежнее —
только третий сохраняет работоспособность и при этом не скрывает происшествие.
Название из одних невидимых символов означает, что с записью базы что-то не так,
и это ровно тот случай, ради которого ревью и существует: система не уверена —
зовёт человека, а не заминает.
*Рассмотрено и отвергнуто:* отклонять матч целиком (терялись бы `provider_id`,
год и режиссёр — а они верны); подставлять пустое и ловить это раскладкой (отказ
приходит поздно, терминальным состоянием и текстом чужого слоя — ровно та боль,
которую чинит соседняя задача `long-title-to-review`).
### Решение 3. Год, режиссёр и провайдер не трогаются
`match.Year` — число, чистить нечего. `match.Director` в путь на диске не
попадает, и его очистка по действующему требованию делается при рендере имени
(`naming.sanitize`). `provider`/`provider_id` — идентификаторы, у них своя
валидация.
`original_title` в план из матча **не подставляется вовсе** — сегодня
`recognize.go` берёт из `Match` только название, год и режиссёра. Чистить там
нечего, и требование про «поля плана после матча» это учитывает: значение
`original_title` осталось тем, что пришло от LLM, то есть уже санитизированным.
### Решение 4. Решение auto/review остаётся с одним производителем
`Decision{Auto, Reasons}` для разобранного плана рождается ровно в одном месте —
`decide` в `validate.go`, и её доккомментарий утверждает исчерпывающий перечень
условий авто. Новая причина появляется **внутри** `decide`, а не дописывается к
готовому решению со сбросом `Auto`: иначе у того же решения появляется второй
производитель, и следующая правка гейта (соседняя задача `long-title-to-review`
заводит причину того же класса) будет выбирать между двумя местами.
Дополнительного входа у `decide` при этом не появилось, хотя сперва
предполагался: раз `Match.Title` санитизируется в `buildMatch` (Решение 1a),
`decide` отвечает на вопрос пригодности по уже чистому значению — `match` у неё
и так на руках. Условие считается один раз и читается из одного поля.
### Решение 5. Общая форма «название непригодно как компонент пути» отложена
Пустое-или-вырожденное название и название длиннее лимита файловой системы — один
класс: значение не годится как компонент пути, исход один — review с названной
причиной вместо отказа из слоя раскладки. Общее место для этого класса здесь
**не** заводится: второй его случай — предмет соседней задачи
`long-title-to-review`, и собирать общую форму из одного случая рано. Это
записано, чтобы второй автор не изобретал её параллельно, а достроил.
## Risks / Trade-offs
- **Правка меняет работающий путь TMDB.** → Значение проходит через
идемпотентную чистку, которая на нормальном названии не меняет ничего; критерий
приёмки требует зелёных существующих тестов `internal/recognize` и
`internal/metadata` **без правки ожиданий** — расхождение сразу видно.
- **Свёртка гомоглифов может тронуть честное название.** → Правило потокенное:
одно-скриптовый токен не трогается, и кириллические названия проходят как есть.
Правило уже работает на выводе LLM и покрыто сценариями спеки; изменение лишь
распространяет его на второй источник.
- **Гомоглифы сворачиваются, а конфузаблы шире таблицы — остаются.**
Осознанный trade-off, названный в рамках задачи. Курируемая кирилло-латинская
таблица закрывает реальный случай (русскоклавиатурные двойники); полная
нормализация Unicode — отдельный разговор.
- **Название базы схлопнулось в пустое — пользователь видит название от LLM.**
Раздача при этом в ревью, где название правится подсказкой, а причина названа
словами.
- **Уже созданные «грязные» каталоги правкой не чинятся.** → Правило сходимости
базы папки (`file-layout`, «Сходимость базы папки при подтверждённом матче»)
наследует имя от живой папки-якоря и не печатает его заново из распознавания.
Каталог с невидимыми символами, созданный до этой правки, останется якорем, и
следующий сезон ляжет в него. Лечение — переименовать папку руками, после чего
сходимость подхватит новое имя. Изменение закрывает появление новых таких
каталогов, а не существующие.
@@ -0,0 +1,61 @@
## Why
Название, которое система берёт из метабазы (TMDB, TVDB, TVMaze) при
подтверждённом матче, попадает в имя каталога библиотеки Jellyfin дословно —
таким, каким его отдал внешний сервис. Вывод LLM мы чистим и считаем
недоверенным; название из метабазы того же обращения не получает, хотя приходит
ровно так же — из-за периметра.
Итог наблюдаем: название из трёх невидимых символов (zero-width) даёт каталог,
имя которого выглядит пустым; название с кириллической `а` внутри латинского
слова даёт второй каталог, визуально неотличимый от первого; перевод строки
внутри названия доезжает до плана. Ни один из этих случаев авто-раскладку не
останавливает — она проходит, и разбирается это потом руками в библиотеке.
Класс не новый: тем же путём ходит TMDB с самого начала. Разговор поднят
ревью изменения `tvdb-title-locale`, где тот же путь распространили на второго
провайдера.
## What Changes
- Название, взятое из метабазы при подтверждённом матче, проходит ту же чистку,
что и название от LLM, — **до** того, как попасть в план и оттуда в путь на
диске. Дом чистки остаётся один, второго способа чистить названия не заводится.
- Ту же чистку проходят названия кандидатов, уходящих на ревью: выбор кандидата
человеком — полноправный путь подтверждения матча, и закреплённое им название
становится именем каталога так же, как название авто-матча. Условия
подтверждения сильного матча при этом не меняются: сравнение с планом идёт по
значению, как его отдал провайдер.
- Название, от которого после чистки ничего не остаётся, план не перезаписывает:
раздача уходит в ревью с названной причиной, а не раскладывается автоматически
и не падает на пустом имени каталога.
- Режиссёр остаётся как есть — он не участвует в пути на диске, и его чистка по
действующему требованию делается при выводе отображаемого имени.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `metadata-match`: требование «Подтверждение матча и каноническое имя»
дополняется — каноническое название перед подстановкой в план санитизируется,
а название, схлопнувшееся в пустое, подстановку не выполняет и блокирует
авто-раскладку.
- `recognition`: требование «Санитайзинг человекочитаемых полей плана»
перестаёт быть требованием только о выводе LLM — оно называет чистку общей для
обоих источников названия и фиксирует, что после сверки с базой в плане не
остаётся несанитизированных человекочитаемых полей.
## Impact
- `internal/recognize` — порядок подстановки канонического названия
относительно санитайзинга (`recognize.go`), причина ухода в review
(`validate.go`).
- Поведение **обоих** провайдеров: правка меняет уже работающий путь TMDB, и это
главный риск изменения.
- Пути на диске не меняются ни для одной раздачи с нормальным названием:
санитайзинг идемпотентен и на чистом значении не меняет ничего.
- Внешних зависимостей, схемы БД и конфигурации изменение не касается.
@@ -0,0 +1,162 @@
# Отчёт ревью — `metadata-title-sanitize`
Стадия: ревью кода после apply, до archive. Отчёт триажа записан оркестратором
(проход `review-triage` пишет только во временный каталог).
## Сводка
| | |
|---|---|
| Размер / сложность / метка | среднее / незнакомое / `large` (максимум по осям) |
| Обоснование метки | триггер «заводится или меняется правило идентичности, слияния или разбора — построение целевых путей и санитизация имён» (`docs/review.md` → «Триггеры метки») |
| Режим | по графу |
| База диффа | `HEAD~1`, рабочее дерево |
| Гейт | зелёный, прогнан триажом самостоятельно: 14 шагов `OK`, ни одного `SKIP`, `-race` реально выполнился, diff-coverage 46/46 строк (100%) |
| Находок на входе | 16 (+1 замер): specs 4, code 3, architecture 3, adversary 3, ops 3, autotests 0 |
| После дедупликации по причине | 11 причин + 1 найденная самим триажом |
| Итог | 0 блокирующих, 1 «стоит исправить сейчас», 3 гипотезы, 2 promote |
Сигнал о заниженной метке: `review-code` возражений не подал. Второго,
независимого от него корректора на этом прогоне не было — `basics` не
запускался, своих тем проекта не размечено.
## План разметки и исход по каждой теме
| тема | дом | глубина | закрывает | исход |
|---|---|---|---|---|
| requirements | дельты change + `openspec/specs/{metadata-match,recognition}` | разбор | specs | закрыта, 4 находки (minor) |
| autotests | `CLAUDE.md` → «Гейт» | — | autotests | закрыта, 0 находок |
| conventions | `docs/conventions/*` | разбор | code | закрыта, 3 находки (minor), нарушений конвенций 0 |
| architecture | `docs/architecture.md` + источник `docs/passport.md` | доказательство | architecture | закрыта, 3 находки (minor) |
| security | `docs/security.md` | доказательство | adversary | закрыта, 2 построенных пути (major) + 1 свойство |
| operations | `docs/architecture.md` «Эксплуатация» + источник `docs/database.md` | доказательство | ops | закрыта, 3 постмортема (2 major) + 1 замер |
**Тем без отчёта нет.** Тем без дома план не содержал.
## Что стало с каждой причиной
| # | причина | кто нашёл | статус |
|---|---|---|---|
| 1 | превью источника расходится с применением | architecture + adversary | закрыта: общий дом пинов `sourcePins`; `TestBuildSources_PreviewMatchesApply` |
| 2 | кандидат, лежащий в БД грязным, пиннит несанитизированное название | specs + code + adversary | закрыта: чистка в `sourcePins`; `TestChooseCandidate_DirtyLegacyTitleSanitized` |
| 3 | пин, закреплённый до деплоя, читается дословно | ops | закрыта: чистка на чтении в `applyOverrides`; `TestApplyOverrides_LegacyDirtyPinSanitized` |
| 4 | предикат пригодности считается дважды из сырого значения | specs + code | закрыта: `decide` читает уже чистое `Match.Title` |
| 5 | отказ закрепить название молчит | specs + code | закрыта: атрибут `title_pinned` |
| 6 | `Match.Title` остаётся сырым, чистка в двух местах | architecture | закрыта: единственная точка сборки `buildMatch` |
| 7 | `specs/review` обещает закрепление безусловно | specs | закрыта: заведена дельта `review` |
| 8 | грязный каталог-якорь наследуется дальше | ops | не чинится сознательно, см. H1 |
| 9 | название от LLM гейта пригодности не получает | adversary | вне объёма, см. P1 |
| 10 | набор пинов пишется пятью транзакциями | ops | пре-существующее, см. H2 |
| 11 | `resolveFolderBase` даёт `SCAN` по `recognition` | ops (замер) | пре-существующее, см. H3 |
| 12 | режиссёр и название доезжают до карточки с RLO и zero-width | **триаж** | закрыта: `naming.sanitize` снимает Cf |
## Блокирует мердж
Пусто. Обе `major`-находки враждебного прохода и обе `major` эксплуатационного
отработаны, каждая с падающим-без-правки тестом в дереве. Потолок не срабатывал.
## Стоит исправить сейчас — 1 находка (отработана)
### Название и режиссёр из метабазы доезжают до карточки Telegram, шапки веб-UI и имени раздачи с RLO и zero-width
- Файл: `internal/naming/naming.go`, `sanitize`
- Severity: `minor` · Confidence: `high`
- Оракул (прогон триажа): `director = "DenisVilleneuve"`
`label = "Dune (DenisVilleneuve, 2021)"`; `naming.sanitize` снимал только
C0 (`r < 0x20`) и не трогал категорию Cf.
- Последствие: дельта `recognition` этого изменения обосновывает отказ чистить
`director` в плане тем, что «его очистка применяется при рендере отображаемого
имени». Рендер этой гарантии для Cf не давал — то есть изменение записывало в
спеку свойство, которого в коде нет. RLO из credits разворачивает отображение
имени в карточке Telegram, которая для единственного пользователя и есть
интерфейс.
- Найдено проходом: триаж, при добыче оракула на утверждение дельты. Ни один
проход этого не заявлял: тема `requirements` сверяла дельту с кодом изменения,
а обоснование дельты уводило в пакет, которого дифф не касался.
- **Отработано инлайн:** `naming.sanitize` снимает `unicode.Cf`, тест
`TestSanitize_StripsFormatChars`.
## Гипотезы без доказательства
**H1. Грязный каталог-якорь наследует имя всем последующим раздачам того же
матча.** Оракула нет (постмортем-рассуждение), severity снижена до `minor`.
Предложение прохода — чистить базу, извлечённую из якоря, — дало бы вторую папку
рядом с существующей, то есть исход, против которого затевалась задача.
`planBase` и так гоняет `sanitizeComponent` по `FolderBase`, на уровне ФС база
безопасна; речь только о невидимке внутри имени существующей папки. Задача не
заводится: лечение — переименовать папку руками, записано риском в `design.md`.
**H2. Набор пинов пишется пятью отдельными транзакциями.** Пре-существующее,
диффом не заведено (дифф добавил только присваивание переменной ради лог-атрибута).
Окно — миллисекунды между пятью `UPSERT` в локальный SQLite, пути отказа никто не
строил. Дом — цель `state-integrity`.
**H3. `resolveFolderBase` даёт `SCAN` по `recognition` без индекса на
`(provider, provider_id)`.** Замер прохода `ops`: 12.9 мс при 100 загрузках и
20000 `file_link`. Команды замера в выводе прохода нет, триаж его не
воспроизводил — число несётся как заявленное, не как проверенное.
Пре-существующее; дом есть — `tasks/items/scale-100-downloads.md`.
## Promote candidates
**P1. Гейт пригодности названия стоит у источников, а не на границе `layout`.**
Путь построен триажом: `title="-"` → каталог `- (2021)`; `title="…"` → каталог
`… (2021)`; `title="..."``layout: empty title after sanitization`;
`title="etcpasswd"` → каталог `etcpasswd (2021)`. Инвариант «целевой путь
строго под библиотекой» **не нарушен** (`underRoot` держит, полноширинный `` не
разделитель), «существующее не перезаписываем» тоже (коллизия → review). Отсюда
`minor`. Претензия к правилу: `UsableTitle` зовут три вызывающих со стороны
метабазы, а настоящая граница — вход в `layout`. Класс тот же, что у задачи
`long-title-to-review`; расширить её, а не заводить новую.
**P2. `ident.Parse` на входных границах остаётся правилом без механизации.**
Стоячий разрыв из `CLAUDE.md`, кандидат в `internal/archrules`.
## Отсев вкусовщины
По проектному списку «Типовые ложноположительные» не выброшено ничего — ни одна
входящая находка под его пункты не подошла. По общим критериям выброшены две:
«правило разложено по трём пакетам» (после переноса чистки последствия за
пределами чтения не осталось, а отдельный дом правила у `layout` дельта
`recognition` легитимизирует дословно) и «атрибут `title_pinned` конфлатит два
случая» (исход в обоих один, последствия нет).
## Границы покрытия
**Запускалось:** `autotests`, `specs`, `code`, `architecture`, `adversary`, `ops`,
`triage` — метка `large`, режим по графу. Стадия ревью дизайна прогонялась
отдельно составом `specs`, `rubric`, `architecture`; её находки отработаны до
реализации.
**Не запускалось:** `basics` — своих тем проекта не размечено, принимать нечего;
следствие — второго, независимого от `code`, сигнала о заниженной метке не было.
Проход независимой реализации в конвейере отсутствует (снят по стоимости),
`idiom` упразднён 2026-08-04.
**Ни один из шести проходов не сообщил свой потолок и величину среза.** По
молчанию «показал всё» неотличимо от «показал первые N» — это находка о прогоне.
**Правки оркестратора не видел ни один проход.** Все шесть отчётов сняты с
состояния кода до исправлений; семь закрытых причин — правки, по которым ревью не
проводилось. Их проверка сводится к зелёному гейту со 100% diff-coverage, трём
новым падающим-без-правки тестам и чтению кода в точках единственности
(`buildMatch`, `sourcePins`, `applyOverrides`). Находка №12 — прямое следствие:
она живёт в пакете, которого дифф не касался.
**Не проверит ни один проход** (`docs/review.md` → «Недоступно проверке»):
история инцидентов на umbar; поведение SQLite под реальным объёмом и профилем;
завязка внешних потребителей (Jellyfin, закладки) на текущее поведение; суждение
«этой функциональности не должно существовать»; качество распознавания как
такового.
**Перестали проверять сознательно:** идиоматичность Go — с 2026-08-04, различение
«идиоматично против распространено» не спрашивает никто; класс обратимый,
пересмотр — задача `quality-review-agents`.
**Решения и наблюдения проекта прогон не читал:** `docs/adr/` и `docs/research/`
процессные документы. Расхождение изменения с записанным решением ловит сверка
документации (`av-dev-docs:healthcheck`), а не ревью.
**Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.**
На этом изменении — правило разбора и санитизации — именно такой проход на прошлом
прогоне приносил независимый оракул.
@@ -0,0 +1,157 @@
## ADDED Requirements
### Requirement: Санитайзинг названий кандидатов, уходящих в ревью
Система SHALL санитизировать названия кандидата (`Title`, `OriginalTitle`) тем же
санитайзингом человекочитаемых полей плана (см. `recognition`) перед тем, как
унести их из сверки дальше: в список кандидатов для ревью, в хранилище, на экран
и в закрепляемое человеком значение. Причина та же, что у
канонического названия: выбор кандидата человеком — полноправный путь
подтверждения матча, и закреплённое им название становится именем каталога
библиотеки в тех же условиях, что и название авто-матча.
Условия подтверждения сильного матча система SHALL проверять на значениях, как их
отдал провайдер: санитайзинг кандидатов SHALL NOT влиять на эти значения. Порядок
операций требование не нормирует — нормирует исход: гейт матча этой правкой не
двигается, иначе кандидат, чьё название отличается от плана невидимым символом,
начал бы совпадать там, где прежде уходил в review. Полнота списка кандидатов
тоже SHALL остаться прежней.
Если название кандидата после санитайзинга непригодно как компонент пути (пусто
либо без единой буквы и цифры), система SHALL сохранить кандидата в списке — он
остаётся выбором человека и несёт `provider_id` и URL для внешней проверки, — но
закрепление такого названия SHALL приводить к тому же исходу, что и у
канонического: подстановки не происходит, в плане остаётся название
распознавания.
`OriginalTitle` кандидата чистится наравне с `Title`, хотя в хранилище и на экран
сегодня доходит только второе: первое уезжает в результат распознавания и в
диагностику сухого прогона, и держать в одной структуре одно чистое поле и одно
грязное — источник будущей ошибки.
#### Scenario: Название кандидата чистится перед показом и закреплением
- **GIVEN** провайдер вернул кандидата, название которого содержит zero-width
символ и кириллический двойник внутри латинского слова
- **WHEN** кандидат попадает в список для ревью
- **THEN** его название очищено тем же санитайзингом, что и поля плана
- **AND** человек, выбравший этого кандидата, закрепляет очищенное название
- **AND** в имя каталога библиотеки оно уходит в тех же условиях, что и название
авто-матча (живой папки-якоря того же тайтла нет — см. `file-layout`,
«Сходимость базы папки при подтверждённом матче»)
#### Scenario: Сравнение с планом идёт по значению провайдера
- **GIVEN** кандидат, название которого отличается от названия плана только
невидимым символом внутри слова
- **WHEN** проверяются условия подтверждения сильного матча
- **THEN** сравнение идёт по значению, как его отдал провайдер
- **AND** исход подтверждения матча тот же, что был до этого изменения
## MODIFIED Requirements
### Requirement: Подтверждение матча и каноническое имя
При единичном сильном матче система SHALL брать из записи базы официальный
`provider` (`tmdb`|`tvdb`|`tvmaze`) и `provider_id`, а также каноническое название
и год, и подменять ими соответствующие поля плана (для сериала — с учётом внешнего
тега TVDB/IMDb из `externals`, идущего в имя папки). Матч SHALL считаться
подтверждённым только при ровно одном сильном кандидате; при нуле или нескольких
кандидатах подтверждённого матча быть SHALL NOT (авто-раскладка не разрешается,
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
сверка не выполняется и подтверждённого матча нет.
Каноническое название — **недоверенное значение внешнего сервиса**, и перед
подстановкой в план система SHALL применять к нему тот же санитайзинг
человекочитаемых полей, что и к выводу LLM (см. `recognition`, требование
«Санитайзинг человекочитаемых полей плана»). Подстановка несанитизированного
значения SHALL NOT выполняться: план — источник имени каталога библиотеки, и
значение, не прошедшее чистку, уходит в путь на диске.
Если после санитайзинга каноническое название оказывается **непригодным как
компонент пути** — пустым либо вырожденным, то есть не содержащим ни одной буквы
и ни одной цифры (`.`, `..`, `-`, только пунктуация), — система SHALL сохранить в
плане прежнее название и SHALL NOT разрешать авто-раскладку: причина уходит в
перечень причин решения, раздача попадает в review. Проверка пригодности SHALL
стоять **после** санитайзинга, а не до него. Ронять распознавание или раскладку
такой матч SHALL NOT — год, провайдер и `provider_id` при этом подставляются как
обычно.
Санитайзинг, **изменивший** каноническое название, но оставивший его пригодным,
авто-раскладку SHALL NOT блокировать: в план идёт очищенное значение, и оно
предсказуемо — ради этого чистка и стоит.
При подтверждённом матче система 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** подтверждённый единичный матч, каноническое название которого содержит
zero-width символ, перевод строки или кириллический двойник внутри латинского слова
- **WHEN** каноническое название подставляется в план
- **THEN** в плане оказывается санитизированное значение
- **AND** авто-раскладка остаётся разрешённой, если прочие условия выполнены
- **AND** когда база имени папки печатается из распознавания (живой папки-якоря
того же тайтла нет — см. `file-layout`, «Сходимость базы папки при подтверждённом
матче»), в имя каталога уходит очищенное значение
#### Scenario: Название базы непригодно как компонент пути — раздача уходит в review
- **GIVEN** подтверждённый единичный матч, каноническое название которого после
санитайзинга пусто (целиком состояло из zero-width символов) либо не содержит ни
одной буквы и ни одной цифры (`.`, `...`, `-`)
- **WHEN** каноническое название подставляется в план
- **THEN** название плана остаётся прежним (подстановки не происходит)
- **AND** решение auto/review содержит причину «название из базы непригодно как имя
каталога», авто-раскладка не разрешается
- **AND** распознавание не проваливается, год и провайдер подставлены
#### Scenario: Нормальное название матча не меняется
- **GIVEN** подтверждённый единичный матч с обычным каноническим названием
- **WHEN** каноническое название подставляется в план
- **THEN** значение в плане совпадает с тем, что отдала база (санитайзинг на чистом
значении ничего не меняет)
- **AND** авто-раскладка остаётся разрешённой, если прочие условия выполнены
#### Scenario: Матч подтягивает режиссёра
- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits
указан режиссёр
- **WHEN** матч подтверждается
- **THEN** в план вкладывается `director` из credits
- **AND** отображаемое имя может использовать этого режиссёра
#### Scenario: Режиссёр недоступен — матч не ломается
- **GIVEN** подтверждённый матч, для которого выборка режиссёра недоступна или
провайдер режиссёра не отдаёт
- **WHEN** матч подтверждается
- **THEN** `director` остаётся пустым
- **AND** матч подтверждён, распознавание не проваливается
#### Scenario: Несколько кандидатов — матч не подтверждён
- **GIVEN** поиск вернул более одного подходящего кандидата
- **WHEN** оценивается матч
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
@@ -0,0 +1,111 @@
## MODIFIED Requirements
### Requirement: Санитайзинг человекочитаемых полей плана
Перед структурной валидацией плана и сверкой с базами система SHALL санитизировать
человекочитаемые поля плана — `title`, `original_title`, `provider_hint` — как
недоверенный вывод LLM. Санитайзинг SHALL: (1) удалять управляющие и zero-width
символы (C0/C1, `U+200B` и родственные, BOM `U+FEFF`); (2) сводить внутренние
последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать
кирилло-латинские homoglyph-двойники (см. ниже).
Санитайзинг SHALL быть **общим для обоих источников названия** — вывода LLM и
записи метабазы. Значение, пришедшее из метабазы и заменяющее поле плана при
подтверждённом матче, SHALL проходить тот же санитайзинг перед подстановкой (см.
`metadata-match`, требование «Подтверждение матча и каноническое имя»). Второго
способа чистить **названия плана** система SHALL NOT заводить: одна реализация
обслуживает оба источника. Речь только о полях плана — очистка компонента пути
под требования файловой системы (`layout`) и очистка отображаемого ярлыка
(`naming`) остаются своими, отдельными и законными.
Санитайзинг SHALL быть **идемпотентным**: повторное применение к уже
санитизированному значению SHALL NOT изменять его. На этом свойстве стоит
утверждение, что правка не меняет путей на диске для раздач с нормальным
названием, и оно проверяется тестом, а не подразумевается.
Следствие, на которое опирается раскладка: к моменту, когда план уходит в решение
auto/review, каждое из полей `title`, `original_title`, `provider_hint` совпадает
с тем, что дал бы санитайзинг этого значения — независимо от того, пришло оно от
LLM или из метабазы.
Свёртка двойников SHALL работать потокенно (по словам, разделённым не-буквенными
символами): токен, все буквы которого принадлежат одному скрипту, система SHALL
оставлять без изменений (билингвальность реальна — кириллические названия
неприкосновенны); в токене смешанного скрипта система SHALL определять доминирующий
скрипт по числу буквенных рун и заменять буквы-меньшинство их визуальными
двойниками из доминирующего скрипта по курируемой таблице. При отсутствии
доминирующего скрипта (равенство) токен SHALL оставаться без изменений.
Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям
**плана**. Применение того же санитайзинга к значениям, пришедшим из метабазы —
каноническому названию и названиям кандидатов, — заказано отдельно, см.
`metadata-match`.
`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с
реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться
основанием отклонить план, а не поводом «чинить» путь. `director` система SHALL
NOT санитизировать в плане — он не участвует в пути на диске, и его очистка
применяется при рендере отображаемого имени (см. `metadata-match`).
Когда санитайзинг реально изменил значение поля, система SHALL логировать это на
уровне `Debug` (названия не относятся к секретам).
#### Scenario: Кириллический двойник в англоязычном названии сворачивается
- **GIVEN** план, где `title` = `Hаrold and the Purple Crayon` (буква `а` в первом
слове — кириллическая `U+0430`)
- **WHEN** план санитизируется
- **THEN** первое слово становится `Harold` (все буквы латинские)
- **AND** в запрос к базе и в сравнение уходит латинское название
#### Scenario: Честное кириллическое название не трогается
- **GIVEN** план российского фильма с `title` = `Тёмный рыцарь`, где все буквы
каждого слова кириллические
- **WHEN** план санитизируется
- **THEN** название остаётся кириллическим без замены букв
#### Scenario: Токен без доминирующего скрипта не трогается
- **GIVEN** план, где короткий токен содержит поровну латинских и кириллических
букв (доминирующего скрипта нет)
- **WHEN** план санитизируется
- **THEN** этот токен остаётся без замены букв (осознанный trade-off: двухбуквенный
homoglyph-typo не сворачивается)
#### Scenario: Zero-width и лишние пробелы вычищаются
- **GIVEN** план, где `title` содержит zero-width символ и сдвоенные пробелы
- **WHEN** план санитизируется
- **THEN** zero-width удалён, внутренние пробелы сведены к одиночным, края обрезаны
#### Scenario: files[].src не санитизируется
- **GIVEN** ответ LLM, где `files[].src` содержит символ-двойник и не совпадает ни
с одним реальным файлом торрента
- **WHEN** план обрабатывается
- **THEN** `files[].src` НЕ изменяется санитайзингом
- **AND** несовпадение src приводит к отклонению плана (эскалация в review), а не к
«починке» пути
#### Scenario: Название из метабазы чистится тем же санитайзингом
- **GIVEN** подтверждённый единичный матч, каноническое название которого содержит
zero-width символ и кириллический двойник внутри латинского слова
- **WHEN** каноническое название подставляется в план
- **THEN** в плане оказывается значение, прошедшее тот же санитайзинг, что и вывод
LLM
#### Scenario: После сверки с базой несанитизированных полей в плане не остаётся
- **GIVEN** план после подтверждённого матча с базой
- **WHEN** оценивается решение auto/review
- **THEN** каждое из полей `title`, `original_title`, `provider_hint` совпадает с
тем, что дал бы санитайзинг этого значения
- **AND** `director` под это требование не подпадает: он чистится при рендере
отображаемого имени
#### Scenario: Повторный санитайзинг ничего не меняет
- **GIVEN** значение, уже прошедшее санитайзинг
- **WHEN** санитайзинг применяется к нему второй раз
- **THEN** значение не изменяется
@@ -0,0 +1,69 @@
## MODIFIED Requirements
### Requirement: Подтверждение матча обновляет отображаемое имя
Система SHALL при подтверждении матча в ревью запускать обновление отображаемого
имени загрузки по подтверждённому распознаванию (см. capability `ingest`):
переливать **полный ярлык** имени — «Название (режиссёр, год)», для сериала со
сводкой сезонов — в `download.display_name` и в имя раздачи qBittorrent, без
нового вызова LLM. Имя строится из эффективных полей (override → распознавание с
вложенным матчем → сохранённый контекст). Подтверждением матча SHALL
считаться как выбор кандидата из списка совпадений, так и ручное добавление
источника по id/URL (оба закрепляют провайдера и каноническое название).
Закрепляемое название источника SHALL проходить санитайзинг человекочитаемых
полей (см. `recognition`) и проверку пригодности как компонента пути (см.
`metadata-match`) — на **общей** точке сборки набора пинов источника, той же,
через которую строится предпросмотр. Отсюда следует свойство, на которое
опирается экран ревью: показанное для источника название и путь совпадают с тем,
что закрепится и разложится по выбору этого источника. Название, непригодное как
имя каталога, пином SHALL NOT становиться — в плане остаётся название
распознавания, а факт отказа SHALL быть наблюдаем в журнале.
Санитайзинг на закреплении SHALL применяться независимо от того, было ли значение
очищено при сохранении кандидата: гарантия чистоты не может держаться на времени
записи строки, иначе кандидаты, сохранённые прежними версиями, обходят её. Тот же
санитайзинг идемпотентен, поэтому на уже очищенном значении он ничего не меняет.
При закреплении выбранного/добавленного источника система SHALL best-effort
получить режиссёра этого источника из метабазы (credits по `provider:id`, см.
`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля
и в ярлык. Недоступность credits или отсутствие режиссёра SHALL NOT проваливать
выбор источника: режиссёр остаётся из более низкого слоя (сохранённый контекст)
или пустым. Так режиссёр из метабазы появляется и на **основном** пути
подтверждения — ручном выборе кандидата, а не только при авто-матче.
Обновление SHALL выполняться после успешного закрепления выбора кандидата и
SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
только при подтверждённом матче».
#### Scenario: Выбор кандидата переливает каноническое имя
- **GIVEN** загрузка в ревью с кандидатами метабазы
- **WHEN** человек выбирает кандидата
- **THEN** провайдер, id и каноническое название закрепляются как override
- **AND** отображаемое имя загрузки обновляется полным ярлыком
#### Scenario: Название источника показано ровно таким, каким закрепится
- **GIVEN** кандидат, название которого содержит zero-width символ или
кириллический двойник внутри латинского слова
- **WHEN** строится список источников для экрана ревью
- **THEN** в строке источника и в его предпросмотре стоит очищенное название
- **AND** выбор этого источника закрепляет то же самое значение
#### Scenario: Кандидат, сохранённый прежней версией, чистится на закреплении
- **GIVEN** кандидат, чьё название записано в хранилище без санитайзинга
- **WHEN** человек выбирает этого кандидата
- **THEN** закрепляется санитизированное значение, а не то, что лежит в хранилище
#### Scenario: Непригодное название источника пином не становится
- **GIVEN** кандидат, название которого не содержит ни одной буквы и ни одной
цифры
- **WHEN** человек выбирает этого кандидата
- **THEN** название пином не становится, в плане остаётся название распознавания
- **AND** провайдер, id и год закрепляются как обычно
- **AND** отказ закрепить название виден в журнале
@@ -0,0 +1,90 @@
## 1. Код
- [x] 1.1 В `internal/recognize/recognize.go` прогнать `match.Title` через
`sanitizeTitle` перед подстановкой в `plan.Title`; год, режиссёр и провайдер
оставить как есть
- [x] 1.2 Непригодный результат чистки (пусто либо ни одной буквы и цифры):
подстановку не выполнять, прежнее название сохранить, причину передать **в**
`decide`, а не дописывать к готовому решению (см. design, Решение 4)
- [x] 1.3 Логировать на `Debug`, когда чистка реально изменила каноническое
название — тем же способом, что `sanitizeField` (названия не секреты)
- [x] 1.4 Чистить `Title`/`OriginalTitle` кандидата на **копии**, в момент
`append` в `candidates` (`internal/recognize/metadata.go`). Место накопления не
двигать — оно стоит до `if match != nil { continue }`, и перенос обеднил бы
список кандидатов при подтверждённом матче с заблокированным авто. Срез `cands`
не мутировать: `for _, c := range cands` даёт копию структуры (все поля
`metadata.Candidate` скалярные), поэтому сравнение остаётся на значениях
провайдера и гейт матча не сдвигается
- [x] 1.5 Закрепление кандидата с непригодным названием (`chooseCandidateLocked`,
`internal/worker/review.go`): пин названия не ставится, в плане остаётся
название распознавания — тот же исход, что у канонического названия
## 2. Тесты
- [x] 2.1 Табличный тест на четыре входа из «Воспроизведения» задачи: три ZWSP
(`U+200B`), `Dune<RLO>gnp.mkv` (`U+202E`), `Dune\nHACK`, `Dunа` с кириллической
`а` — проверяет `plan.Title` и `Decision.Auto`
- [x] 2.2 Граничные случаи непригодного названия: три ZWSP (пусто после чистки) и
вырожденные `"."`, `"..."`, `"-"`, `" . "` — план сохраняет прежнее название,
`Auto=false`, причина названа, год и провайдер подставлены
- [x] 2.5 Идемпотентность: `sanitizeTitle(sanitizeTitle(x)) == sanitizeTitle(x)`
по тому же набору входов, что и 2.1
- [x] 2.6 Кандидаты, уходящие в ревью, очищены: те же четыре входа в названии
кандидата — в `Result.Candidates` значения санитизированы
- [x] 2.7 Гейт матча не сдвинулся: кандидат, отличающийся от плана только
zero-width внутри слова, по-прежнему **не** даёт подтверждённого матча
(сравнение идёт по значению провайдера)
- [x] 2.8 Список кандидатов не обеднел: при подтверждённом матче от первого
провайдера кандидаты остальных провайдеров того же ключа по-прежнему в списке
- [x] 2.9 Закрепление кандидата с названием `-`: пин названия не поставлен, в
плане остаётся название распознавания, каталог с мусорным именем не строится
- [x] 2.3 Нормальное название матча: значение в плане совпадает с ответом базы,
`Auto=true` при прочих чистых условиях
- [x] 2.4 Прогнать существующие тесты `internal/recognize` и `internal/metadata`
**без правки ожиданий** — регресс на TMDB виден сразу
## 3. Гейт и спеки
- [x] 3.1 `task gate` зелёный
- [x] 3.2 `openspec validate --strict metadata-title-sanitize`
## Критерии приёмки задачи
Приходят из постановки `tasks/items/metadata-title-sanitize.md`, здесь — дословно.
- [x] A1 Все четыре входа из «Воспроизведения» дают либо санитизированный
`plan.Title`, либо уход в review — но не авто-раскладку с исходным значением.
**Оракул:** тот самый падающий тест из отчёта триажа, перенесённый в дерево
- [x] A2 Название, схлопывающееся санитизацией в пустую строку, не порождает
каталог с пустым именем и не роняет раскладку. **Оракул:** табличный тест на
границе, случай «три ZWSP»
- [x] A3 Поведение TMDB на нормальных названиях не изменилось. **Оракул:**
существующие тесты `internal/recognize` и `internal/metadata` зелёные без правок
ожиданий
- [x] A4 Место санитизации названо требованием спеки, а не только кодом.
**Оракул:** `openspec validate --strict` на дельте
## 4. Правки по ревью кода
- [x] 4.1 Санитайзинг канонического названия перенесён в `buildMatch`
единственную точку сборки `Match`; `recognize.go` и `decide` читают уже чистое
значение и не пересчитывают условие каждый по-своему
- [x] 4.2 Чистка и проверка пригодности названия источника перенесены в
`sourcePins` — общий дом набора пинов, через который идут и превью, и
закрепление; «превью = применение» держится конструкцией
- [x] 4.3 Кандидаты, сохранённые прежней версией, чистятся на закреплении:
гарантия не держится на времени записи строки
- [x] 4.4 Отказ закрепить непригодное название виден в журнале — атрибут
`title_pinned` у существующей записи `review candidate chosen`
- [x] 4.5 Дельта `review` — требование «Подтверждение матча обновляет
отображаемое имя» дополнено санитайзингом на закреплении и свойством
«превью = применение»
- [x] 4.6 Тесты: `TestChooseCandidate_DirtyLegacyTitleSanitized`,
`TestBuildSources_PreviewMatchesApply` (оба падают без правки)
- [x] 4.7 Чистка названия и на **чтении** пина (`applyOverrides`): пин,
закреплённый прежней версией, обычным «Применить» не переписывается, и без
этого доезжал до имени каталога дословно (находка эксплуатационного прохода)
- [x] 4.8 `naming.sanitize` снимает категорию Cf: дельта `recognition`
обосновывает отказ чистить `director` в плане тем, что его чистит рендер, — а
рендер снимал только C0, и RLO из credits разворачивал карточку Telegram
(находка триажа)
+98
View File
@@ -54,6 +54,26 @@
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
сверка не выполняется и подтверждённого матча нет.
Каноническое название — **недоверенное значение внешнего сервиса**, и перед
подстановкой в план система SHALL применять к нему тот же санитайзинг
человекочитаемых полей, что и к выводу LLM (см. `recognition`, требование
«Санитайзинг человекочитаемых полей плана»). Подстановка несанитизированного
значения SHALL NOT выполняться: план — источник имени каталога библиотеки, и
значение, не прошедшее чистку, уходит в путь на диске.
Если после санитайзинга каноническое название оказывается **непригодным как
компонент пути** — пустым либо вырожденным, то есть не содержащим ни одной буквы
и ни одной цифры (`.`, `..`, `-`, только пунктуация), — система SHALL сохранить в
плане прежнее название и SHALL NOT разрешать авто-раскладку: причина уходит в
перечень причин решения, раздача попадает в review. Проверка пригодности SHALL
стоять **после** санитайзинга, а не до него. Ронять распознавание или раскладку
такой матч SHALL NOT — год, провайдер и `provider_id` при этом подставляются как
обычно.
Санитайзинг, **изменивший** каноническое название, но оставивший его пригодным,
авто-раскладку SHALL NOT блокировать: в план идёт очищенное значение, и оно
предсказуемо — ради этого чистка и стоит.
При подтверждённом матче система SHALL дополнительно попытаться получить из базы
**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как
недоверенное косметическое значение для вывода отображаемого имени. Тот же способ
@@ -77,6 +97,36 @@ plan-санитайзинге.
- **WHEN** матч подтверждается
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
#### Scenario: Каноническое название чистится перед подстановкой
- **GIVEN** подтверждённый единичный матч, каноническое название которого содержит
zero-width символ, перевод строки или кириллический двойник внутри латинского слова
- **WHEN** каноническое название подставляется в план
- **THEN** в плане оказывается санитизированное значение
- **AND** авто-раскладка остаётся разрешённой, если прочие условия выполнены
- **AND** когда база имени папки печатается из распознавания (живой папки-якоря
того же тайтла нет — см. `file-layout`, «Сходимость базы папки при подтверждённом
матче»), в имя каталога уходит очищенное значение
#### Scenario: Название базы непригодно как компонент пути — раздача уходит в review
- **GIVEN** подтверждённый единичный матч, каноническое название которого после
санитайзинга пусто (целиком состояло из zero-width символов) либо не содержит ни
одной буквы и ни одной цифры (`.`, `...`, `-`)
- **WHEN** каноническое название подставляется в план
- **THEN** название плана остаётся прежним (подстановки не происходит)
- **AND** решение auto/review содержит причину «название из базы непригодно как имя
каталога», авто-раскладка не разрешается
- **AND** распознавание не проваливается, год и провайдер подставлены
#### Scenario: Нормальное название матча не меняется
- **GIVEN** подтверждённый единичный матч с обычным каноническим названием
- **WHEN** каноническое название подставляется в план
- **THEN** значение в плане совпадает с тем, что отдала база (санитайзинг на чистом
значении ничего не меняет)
- **AND** авто-раскладка остаётся разрешённой, если прочие условия выполнены
#### Scenario: Матч подтягивает режиссёра
- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits
@@ -386,3 +436,51 @@ fallback: если первый проход подтвердил матч ил
- **THEN** гейт даёт двух сильных кандидатов вместо одного
- **AND** подтверждённого матча нет, обе записи уходят кандидатами в review
### Requirement: Санитайзинг названий кандидатов, уходящих в ревью
Система SHALL санитизировать названия кандидата (`Title`, `OriginalTitle`) тем же
санитайзингом человекочитаемых полей плана (см. `recognition`) перед тем, как
унести их из сверки дальше: в список кандидатов для ревью, в хранилище, на экран
и в закрепляемое человеком значение. Причина та же, что у
канонического названия: выбор кандидата человеком — полноправный путь
подтверждения матча, и закреплённое им название становится именем каталога
библиотеки в тех же условиях, что и название авто-матча.
Условия подтверждения сильного матча система SHALL проверять на значениях, как их
отдал провайдер: санитайзинг кандидатов SHALL NOT влиять на эти значения. Порядок
операций требование не нормирует — нормирует исход: гейт матча этой правкой не
двигается, иначе кандидат, чьё название отличается от плана невидимым символом,
начал бы совпадать там, где прежде уходил в review. Полнота списка кандидатов
тоже SHALL остаться прежней.
Если название кандидата после санитайзинга непригодно как компонент пути (пусто
либо без единой буквы и цифры), система SHALL сохранить кандидата в списке — он
остаётся выбором человека и несёт `provider_id` и URL для внешней проверки, — но
закрепление такого названия SHALL приводить к тому же исходу, что и у
канонического: подстановки не происходит, в плане остаётся название
распознавания.
`OriginalTitle` кандидата чистится наравне с `Title`, хотя в хранилище и на экран
сегодня доходит только второе: первое уезжает в результат распознавания и в
диагностику сухого прогона, и держать в одной структуре одно чистое поле и одно
грязное — источник будущей ошибки.
#### Scenario: Название кандидата чистится перед показом и закреплением
- **GIVEN** провайдер вернул кандидата, название которого содержит zero-width
символ и кириллический двойник внутри латинского слова
- **WHEN** кандидат попадает в список для ревью
- **THEN** его название очищено тем же санитайзингом, что и поля плана
- **AND** человек, выбравший этого кандидата, закрепляет очищенное название
- **AND** в имя каталога библиотеки оно уходит в тех же условиях, что и название
авто-матча (живой папки-якоря того же тайтла нет — см. `file-layout`,
«Сходимость базы папки при подтверждённом матче»)
#### Scenario: Сравнение с планом идёт по значению провайдера
- **GIVEN** кандидат, название которого отличается от названия плана только
невидимым символом внутри слова
- **WHEN** проверяются условия подтверждения сильного матча
- **THEN** сравнение идёт по значению, как его отдал провайдер
- **AND** исход подтверждения матча тот же, что был до этого изменения
+49 -2
View File
@@ -179,6 +179,25 @@ per-file `season`/`episode` (отдельного скалярного `season`
последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать
кирилло-латинские homoglyph-двойники (см. ниже).
Санитайзинг SHALL быть **общим для обоих источников названия** — вывода LLM и
записи метабазы. Значение, пришедшее из метабазы и заменяющее поле плана при
подтверждённом матче, SHALL проходить тот же санитайзинг перед подстановкой (см.
`metadata-match`, требование «Подтверждение матча и каноническое имя»). Второго
способа чистить **названия плана** система SHALL NOT заводить: одна реализация
обслуживает оба источника. Речь только о полях плана — очистка компонента пути
под требования файловой системы (`layout`) и очистка отображаемого ярлыка
(`naming`) остаются своими, отдельными и законными.
Санитайзинг SHALL быть **идемпотентным**: повторное применение к уже
санитизированному значению SHALL NOT изменять его. На этом свойстве стоит
утверждение, что правка не меняет путей на диске для раздач с нормальным
названием, и оно проверяется тестом, а не подразумевается.
Следствие, на которое опирается раскладка: к моменту, когда план уходит в решение
auto/review, каждое из полей `title`, `original_title`, `provider_hint` совпадает
с тем, что дал бы санитайзинг этого значения — независимо от того, пришло оно от
LLM или из метабазы.
Свёртка двойников SHALL работать потокенно (по словам, разделённым не-буквенными
символами): токен, все буквы которого принадлежат одному скрипту, система SHALL
оставлять без изменений (билингвальность реальна — кириллические названия
@@ -187,10 +206,15 @@ per-file `season`/`episode` (отдельного скалярного `season`
двойниками из доминирующего скрипта по курируемой таблице. При отсутствии
доминирующего скрипта (равенство) токен SHALL оставаться без изменений.
Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям.
Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям
**плана**. Применение того же санитайзинга к значениям, пришедшим из метабазы —
каноническому названию и названиям кандидатов, — заказано отдельно, см.
`metadata-match`.
`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с
реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться
основанием отклонить план, а не поводом «чинить» путь.
основанием отклонить план, а не поводом «чинить» путь. `director` система SHALL
NOT санитизировать в плане — он не участвует в пути на диске, и его очистка
применяется при рендере отображаемого имени (см. `metadata-match`).
Когда санитайзинг реально изменил значение поля, система SHALL логировать это на
уровне `Debug` (названия не относятся к секретам).
@@ -233,3 +257,26 @@ per-file `season`/`episode` (отдельного скалярного `season`
- **AND** несовпадение src приводит к отклонению плана (эскалация в review), а не к
«починке» пути
#### Scenario: Название из метабазы чистится тем же санитайзингом
- **GIVEN** подтверждённый единичный матч, каноническое название которого содержит
zero-width символ и кириллический двойник внутри латинского слова
- **WHEN** каноническое название подставляется в план
- **THEN** в плане оказывается значение, прошедшее тот же санитайзинг, что и вывод
LLM
#### Scenario: После сверки с базой несанитизированных полей в плане не остаётся
- **GIVEN** план после подтверждённого матча с базой
- **WHEN** оценивается решение auto/review
- **THEN** каждое из полей `title`, `original_title`, `provider_hint` совпадает с
тем, что дал бы санитайзинг этого значения
- **AND** `director` под это требование не подпадает: он чистится при рендере
отображаемого имени
#### Scenario: Повторный санитайзинг ничего не меняет
- **GIVEN** значение, уже прошедшее санитайзинг
- **WHEN** санитайзинг применяется к нему второй раз
- **THEN** значение не изменяется
+36 -33
View File
@@ -231,6 +231,20 @@ LLM; нет матча в базе или несколько кандидато
считаться как выбор кандидата из списка совпадений, так и ручное добавление
источника по id/URL (оба закрепляют провайдера и каноническое название).
Закрепляемое название источника SHALL проходить санитайзинг человекочитаемых
полей (см. `recognition`) и проверку пригодности как компонента пути (см.
`metadata-match`) — на **общей** точке сборки набора пинов источника, той же,
через которую строится предпросмотр. Отсюда следует свойство, на которое
опирается экран ревью: показанное для источника название и путь совпадают с тем,
что закрепится и разложится по выбору этого источника. Название, непригодное как
имя каталога, пином SHALL NOT становиться — в плане остаётся название
распознавания, а факт отказа SHALL быть наблюдаем в журнале.
Санитайзинг на закреплении SHALL применяться независимо от того, было ли значение
очищено при сохранении кандидата: гарантия чистоты не может держаться на времени
записи строки, иначе кандидаты, сохранённые прежними версиями, обходят её. Тот же
санитайзинг идемпотентен, поэтому на уже очищенном значении он ничего не меняет.
При закреплении выбранного/добавленного источника система SHALL best-effort
получить режиссёра этого источника из метабазы (credits по `provider:id`, см.
`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля
@@ -244,46 +258,35 @@ SHALL быть best-effort по отношению к qBittorrent: недост
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
только при подтверждённом матче».
#### Scenario: Выбор кандидата обновляет имя
#### Scenario: Выбор кандидата переливает каноническое имя
- **GIVEN** загрузка в ревью с пустым или неинформативным `display_name`
(например, «Unknown») и списком кандидатов
- **WHEN** пользователь выбирает кандидата, подтверждая матч
- **THEN** выбор кандидата закрепляется как и прежде
- **AND** `download.display_name` обновляется полным ярлыком
«Название (режиссёр, год)» (для сериала — со сводкой сезонов)
- **AND** раздача в qBittorrent переименовывается в то же имя
- **GIVEN** загрузка в ревью с кандидатами метабазы
- **WHEN** человек выбирает кандидата
- **THEN** провайдер, id и каноническое название закрепляются как override
- **AND** отображаемое имя загрузки обновляется полным ярлыком
#### Scenario: Ручное добавление источника обновляет имя
#### Scenario: Название источника показано ровно таким, каким закрепится
- **GIVEN** загрузка в ревью без совпадений в списке
- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч
- **THEN** источник закрепляется как и прежде
- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются
полным ярлыком подтверждённого источника
- **GIVEN** кандидат, название которого содержит zero-width символ или
кириллический двойник внутри латинского слова
- **WHEN** строится список источников для экрана ревью
- **THEN** в строке источника и в его предпросмотре стоит очищенное название
- **AND** выбор этого источника закрепляет то же самое значение
#### Scenario: Выбор кандидата подтягивает режиссёра в ярлык
#### Scenario: Кандидат, сохранённый прежней версией, чистится на закреплении
- **GIVEN** загрузка в ревью, у выбранного кандидата в credits метабазы указан
режиссёр
- **WHEN** пользователь выбирает кандидата, подтверждая матч
- **THEN** режиссёр best-effort извлекается из метабазы и закрепляется override
- **AND** `download.display_name` получает полный ярлык с этим режиссёром
- **GIVEN** кандидат, чьё название записано в хранилище без санитайзинга
- **WHEN** человек выбирает этого кандидата
- **THEN** закрепляется санитизированное значение, а не то, что лежит в хранилище
#### Scenario: Режиссёр кандидата недоступен — выбор не ломается
#### Scenario: Непригодное название источника пином не становится
- **GIVEN** выбор кандидата, для которого credits недоступны или режиссёра нет
- **WHEN** пользователь подтверждает матч
- **THEN** выбор источника выполнен, режиссёр берётся из сохранённого контекста
или остаётся пустым
- **AND** команда ревью не возвращает ошибку
#### Scenario: Недоступность qBittorrent не ломает выбор кандидата
- **GIVEN** выбор кандидата в ревью
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой
- **THEN** выбор кандидата и обновление `download.display_name` выполнены
- **AND** команда ревью не возвращает ошибку
- **GIVEN** кандидат, название которого не содержит ни одной буквы и ни одной
цифры
- **WHEN** человек выбирает этого кандидата
- **THEN** название пином не становится, в плане остаётся название распознавания
- **AND** провайдер, id и год закрепляются как обычно
- **AND** отказ закрепить название виден в журнале
### Requirement: Инфо и предпросмотр выбранного источника