recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение: гарантия, поставленная только на запись, обходится данными, сохранёнными прежними версиями - название, непригодное как имя каталога (пустое или без единой буквы и цифры), не подставляется — раздача уходит в review с названной причиной - гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям провайдера, чистится только копия, уходящая дальше
This commit is contained in:
@@ -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` — общем доме набора закреплённых значений, через который
|
||||||
|
идут и предпросмотр, и закрепление.
|
||||||
@@ -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-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-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — |
|
||||||
| 2026-08-04 | [Конвейер ревью и пайплайн задачи переезжают в плагины](ADR-2026-08-04-review-pipeline-to-plugin.md) | — |
|
| 2026-08-04 | [Конвейер ревью и пайплайн задачи переезжают в плагины](ADR-2026-08-04-review-pipeline-to-plugin.md) | — |
|
||||||
|
|||||||
@@ -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` |
|
| Время | `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/ident` — генерация и нормализация ULID; `ident.Parse` на каждой входной границе |
|
||||||
| Целевые имена и превью раскладки | `internal/naming` — одна логика для превью в UI и для реального применения |
|
| Целевые имена и превью раскладки | `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`; инфохэш извлекается только здесь |
|
| Разбор источника | `internal/magnet` и `internal/torrent`; инфохэш извлекается только здесь |
|
||||||
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
|
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
|
||||||
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
|
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ Go-сервиса и что здесь уже проскакивало. Устр
|
|||||||
- `security`: читается ли тело ответа внешнего сервиса целиком без предела —
|
- `security`: читается ли тело ответа внешнего сервиса целиком без предела —
|
||||||
лимита на размер ответа LLM в проекте нет, и это единственный недоверенный
|
лимита на размер ответа LLM в проекте нет, и это единственный недоверенный
|
||||||
канал, где предел не стоит ([security.md](security.md) → «Что вне модели»)
|
канал, где предел не стоит ([security.md](security.md) → «Что вне модели»)
|
||||||
|
- `operations`: гарантия, которую вводит изменение, поставлена на запись или на
|
||||||
|
чтение — и что будет с данными, записанными до деплоя, которые обычный путь
|
||||||
|
не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
|
||||||
|
очередь ревью её обходила)
|
||||||
- `operations`: не удваивает ли новая ветка расход лимита метабаз и платного
|
- `operations`: не удваивает ли новая ветка расход лимита метабаз и платного
|
||||||
LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет,
|
LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет,
|
||||||
задача `metadata-cache`)
|
задача `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 — уборка своего торрента после отмены сносит чужие файлы [проскочил]
|
## 2026-08-06 — уборка своего торрента после отмены сносит чужие файлы [проскочил]
|
||||||
|
|
||||||
- **Где:** `internal/worker/worker.go:501-556` — гард `:501-509`, удаление
|
- **Где:** `internal/worker/worker.go:501-556` — гард `:501-509`, удаление
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ REST API работают **без авторизации** осознанно;
|
|||||||
| Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком |
|
| Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком |
|
||||||
| Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя |
|
| Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя |
|
||||||
| **Ответ LLM** | HTTP к эндпоинту | целиком под влиянием входа выше; названия, годы, номера сезонов и серий, из которых строится целевой путь |
|
| **Ответ 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 | пути, состояния, размеры |
|
| Ответы qBittorrent | HTTP | пути, состояния, размеры |
|
||||||
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
|
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
"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())
|
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 {
|
func sanitize(s string) string {
|
||||||
s = strings.Map(func(r rune) rune {
|
s = strings.Map(func(r rune) rune {
|
||||||
if r == '\n' || r == '\t' || r == '\r' {
|
if r == '\n' || r == '\t' || r == '\r' {
|
||||||
return ' '
|
return ' '
|
||||||
}
|
}
|
||||||
if r < 0x20 {
|
if r < 0x20 || unicode.Is(unicode.Cf, r) {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -158,3 +158,25 @@ func TestDeriveNameNilProviderUsesFallback(t *testing.T) {
|
|||||||
t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,13 +72,17 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||||
|
// Названия чистим на КОПИИ: выбор кандидата человеком — такой же
|
||||||
|
// путь подтверждения матча, как авто, и закреплённое название так
|
||||||
|
// же уезжает в имя каталога. Исходный cands не трогаем — по нему
|
||||||
|
// ниже ищется сильный матч, и сдвигать его гейт задача не заказывала.
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
ck := c.Provider + ":" + c.ID
|
ck := c.Provider + ":" + c.ID
|
||||||
if seen[ck] || len(candidates) >= maxCandidates {
|
if seen[ck] || len(candidates) >= maxCandidates {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[ck] = true
|
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)
|
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{
|
return &Match{
|
||||||
Provider: prov,
|
Provider: prov,
|
||||||
ProviderID: pid,
|
ProviderID: pid,
|
||||||
Title: c.Title,
|
Title: title,
|
||||||
Year: c.Year,
|
Year: c.Year,
|
||||||
Director: directorOf(ctx, r.log, p, mt, c.ID),
|
Director: directorOf(ctx, r.log, p, mt, c.ID),
|
||||||
SeasonEpisodeCounts: counts,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,7 +260,13 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
// review, когда единичного сильного матча нет.
|
// review, когда единичного сильного матча нет.
|
||||||
match, candidates := r.matchMetadata(ctx, plan)
|
match, candidates := r.matchMetadata(ctx, plan)
|
||||||
if match != nil {
|
if match != nil {
|
||||||
|
// Match.Title пришёл санитизированным (buildMatch чистит его в единственной
|
||||||
|
// точке сборки матча). Здесь остаётся только вопрос пригодности: название,
|
||||||
|
// не годное как имя каталога, не подставляем вовсе — в плане остаётся
|
||||||
|
// название распознавания, а decide уводит раздачу в review.
|
||||||
|
if UsableTitle(match.Title) {
|
||||||
plan.Title = match.Title
|
plan.Title = match.Title
|
||||||
|
}
|
||||||
if match.Year != 0 {
|
if match.Year != 0 {
|
||||||
plan.Year = match.Year
|
plan.Year = match.Year
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
|
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
|
||||||
@@ -34,17 +36,49 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
|
// SanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
|
||||||
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
|
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
|
||||||
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
|
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
|
||||||
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
|
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
|
||||||
func sanitizeTitle(s string) string {
|
func SanitizeTitle(s string) string {
|
||||||
s = stripControl(s)
|
s = stripControl(s)
|
||||||
s = collapseSpaces(s)
|
s = collapseSpaces(s)
|
||||||
s = foldHomoglyphs(s)
|
s = foldHomoglyphs(s)
|
||||||
return 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
|
// sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src
|
||||||
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
|
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
|
||||||
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
|
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
|
||||||
@@ -57,7 +91,7 @@ func sanitizePlan(p *Plan, log *slog.Logger) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeField(v, field string, log *slog.Logger) string {
|
func sanitizeField(v, field string, log *slog.Logger) string {
|
||||||
clean := sanitizeTitle(v)
|
clean := SanitizeTitle(v)
|
||||||
if clean != v && log != nil {
|
if clean != v && log != nil {
|
||||||
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
|
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ func TestSanitizeTitle(t *testing.T) {
|
|||||||
"The Matrix": "The Matrix",
|
"The Matrix": "The Matrix",
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := sanitizeTitle(in); got != want {
|
if got := SanitizeTitle(in); got != want {
|
||||||
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want)
|
t.Errorf("SanitizeTitle(%q) = %q, want %q", in, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ func validateSchema(p *Plan, in Input) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decide считает решение модели уверенности (см. recognition.md). Авто —
|
// decide считает решение модели уверенности (см. recognition.md). Авто —
|
||||||
// только если выполнено всё: подтверждённый единичный матч в базе; чистая
|
// только если выполнено всё: подтверждённый единичный матч в базе; каноническое
|
||||||
// структурная валидация (для сериала — число серий бьётся с базой);
|
// название матча пригодно как имя каталога; чистая структурная валидация (для
|
||||||
// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая
|
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
|
||||||
// невыполненная — причина ухода в review.
|
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
|
||||||
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
|
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
|
||||||
var reasons []string
|
var reasons []string
|
||||||
|
|
||||||
@@ -98,6 +98,16 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold
|
|||||||
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
|
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Каноническое название базы, непригодное как имя каталога (пустое или без
|
||||||
|
// единой буквы и цифры), в план не подставлено — там осталось название
|
||||||
|
// распознавания. Матч при этом верен: id, год и режиссёр на месте, поэтому
|
||||||
|
// отклоняем не матч, а авто-раскладку. Match.Title уже санитизирован
|
||||||
|
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
|
||||||
|
// условия разъедутся на первой же правке одного из них.
|
||||||
|
if match != nil && !UsableTitle(match.Title) {
|
||||||
|
reasons = append(reasons, "название из базы непригодно как имя каталога")
|
||||||
|
}
|
||||||
|
|
||||||
reasons = append(reasons, structuralWarnings(p)...)
|
reasons = append(reasons, structuralWarnings(p)...)
|
||||||
|
|
||||||
if match != nil && p.Type == MediaSeries {
|
if match != nil && p.Type == MediaSeries {
|
||||||
|
|||||||
@@ -812,7 +812,8 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
|
|||||||
if w.recognizer != nil {
|
if w.recognizer != nil {
|
||||||
director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID)
|
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 {
|
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
|
||||||
return fmt.Errorf("choose candidate: %w", err)
|
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 {
|
if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil {
|
||||||
return fmt.Errorf("choose candidate: %w", err)
|
return fmt.Errorf("choose candidate: %w", err)
|
||||||
}
|
}
|
||||||
|
// title_pinned=false означает, что название кандидата не годится как имя
|
||||||
|
// каталога и в плане осталось название распознавания. Без этого атрибута
|
||||||
|
// вопрос «почему папка названа догадкой, а не как в базе» по логам не
|
||||||
|
// разбирается: на авто-пути такой отказ несёт причина решения, здесь её нет.
|
||||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
|
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 и в ярлык
|
// Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык
|
||||||
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
|
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
|
||||||
// выбор кандидата: логируем и продолжаем.
|
// выбор кандидата: логируем и продолжаем.
|
||||||
@@ -911,6 +917,18 @@ func sourcePins(provider, providerID, title string, year int, director string) m
|
|||||||
if year > 0 {
|
if year > 0 {
|
||||||
yr = strconv.Itoa(year)
|
yr = strconv.Itoa(year)
|
||||||
}
|
}
|
||||||
|
// Название источника — недоверенное значение метабазы, и чистится оно здесь,
|
||||||
|
// на единственном общем доме набора пинов: через sourcePins идут и превью
|
||||||
|
// источника, и его закрепление, поэтому «превью = применение» держится
|
||||||
|
// конструкцией, а не памятью. Чистка идемпотентна — на кандидате, записанном
|
||||||
|
// уже с чисткой, она ничего не меняет, а строку из БД, сохранённую прежней
|
||||||
|
// версией, приводит в порядок. Непригодное как имя каталога название пином не
|
||||||
|
// становится: пустое значение очищает пин, и в плане остаётся название
|
||||||
|
// распознавания.
|
||||||
|
title = recognize.SanitizeTitle(title)
|
||||||
|
if !recognize.UsableTitle(title) {
|
||||||
|
title = ""
|
||||||
|
}
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
ovrProvider: provider,
|
ovrProvider: provider,
|
||||||
ovrProviderID: providerID,
|
ovrProviderID: providerID,
|
||||||
@@ -1297,8 +1315,15 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr
|
|||||||
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из
|
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из
|
||||||
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
|
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
|
||||||
// раскладка пропустит).
|
// раскладка пропустит).
|
||||||
|
//
|
||||||
|
// Название чистится здесь, НА ЧТЕНИИ, и это не дубль чистки в sourcePins: та
|
||||||
|
// держит хранилище чистым, а эта защищает от значений, записанных прежними
|
||||||
|
// версиями. Пин, закреплённый до появления санитайзинга, иначе доезжает до имени
|
||||||
|
// каталога дословно при обычном «Применить» — без повторного выбора источника
|
||||||
|
// запись в хранилище никто не перепишет. Чистка идемпотентна, так что на пинах,
|
||||||
|
// записанных уже с ней, обе не делают ничего.
|
||||||
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
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
|
plan.Title = t
|
||||||
}
|
}
|
||||||
if y := overrides[ovrYear]; y != "" {
|
if y := overrides[ovrYear]; y != "" {
|
||||||
|
|||||||
@@ -2285,3 +2285,124 @@ func TestSweepLinking_LeavesOtherStates(t *testing.T) {
|
|||||||
t.Errorf("review task moved to %q", st.downloads["2"].State)
|
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="/etc/passwd"` → каталог `/etc/passwd (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`), а не ревью.
|
||||||
|
|
||||||
|
**Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.**
|
||||||
|
На этом изменении — правило разбора и санитизации — именно такой проход на прошлом
|
||||||
|
прогоне приносил независимый оракул.
|
||||||
+157
@@ -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
|
||||||
+111
@@ -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
|
||||||
|
(находка триажа)
|
||||||
@@ -54,6 +54,26 @@
|
|||||||
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
|
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
|
||||||
сверка не выполняется и подтверждённого матча нет.
|
сверка не выполняется и подтверждённого матча нет.
|
||||||
|
|
||||||
|
Каноническое название — **недоверенное значение внешнего сервиса**, и перед
|
||||||
|
подстановкой в план система SHALL применять к нему тот же санитайзинг
|
||||||
|
человекочитаемых полей, что и к выводу LLM (см. `recognition`, требование
|
||||||
|
«Санитайзинг человекочитаемых полей плана»). Подстановка несанитизированного
|
||||||
|
значения SHALL NOT выполняться: план — источник имени каталога библиотеки, и
|
||||||
|
значение, не прошедшее чистку, уходит в путь на диске.
|
||||||
|
|
||||||
|
Если после санитайзинга каноническое название оказывается **непригодным как
|
||||||
|
компонент пути** — пустым либо вырожденным, то есть не содержащим ни одной буквы
|
||||||
|
и ни одной цифры (`.`, `..`, `-`, только пунктуация), — система SHALL сохранить в
|
||||||
|
плане прежнее название и SHALL NOT разрешать авто-раскладку: причина уходит в
|
||||||
|
перечень причин решения, раздача попадает в review. Проверка пригодности SHALL
|
||||||
|
стоять **после** санитайзинга, а не до него. Ронять распознавание или раскладку
|
||||||
|
такой матч SHALL NOT — год, провайдер и `provider_id` при этом подставляются как
|
||||||
|
обычно.
|
||||||
|
|
||||||
|
Санитайзинг, **изменивший** каноническое название, но оставивший его пригодным,
|
||||||
|
авто-раскладку SHALL NOT блокировать: в план идёт очищенное значение, и оно
|
||||||
|
предсказуемо — ради этого чистка и стоит.
|
||||||
|
|
||||||
При подтверждённом матче система SHALL дополнительно попытаться получить из базы
|
При подтверждённом матче система SHALL дополнительно попытаться получить из базы
|
||||||
**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как
|
**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как
|
||||||
недоверенное косметическое значение для вывода отображаемого имени. Тот же способ
|
недоверенное косметическое значение для вывода отображаемого имени. Тот же способ
|
||||||
@@ -77,6 +97,36 @@ plan-санитайзинге.
|
|||||||
- **WHEN** матч подтверждается
|
- **WHEN** матч подтверждается
|
||||||
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
|
- **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: Матч подтягивает режиссёра
|
#### Scenario: Матч подтягивает режиссёра
|
||||||
|
|
||||||
- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits
|
- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits
|
||||||
@@ -386,3 +436,51 @@ fallback: если первый проход подтвердил матч ил
|
|||||||
- **THEN** гейт даёт двух сильных кандидатов вместо одного
|
- **THEN** гейт даёт двух сильных кандидатов вместо одного
|
||||||
- **AND** подтверждённого матча нет, обе записи уходят кандидатами в review
|
- **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** исход подтверждения матча тот же, что был до этого изменения
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,25 @@ per-file `season`/`episode` (отдельного скалярного `season`
|
|||||||
последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать
|
последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать
|
||||||
кирилло-латинские homoglyph-двойники (см. ниже).
|
кирилло-латинские 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
|
||||||
оставлять без изменений (билингвальность реальна — кириллические названия
|
оставлять без изменений (билингвальность реальна — кириллические названия
|
||||||
@@ -187,10 +206,15 @@ per-file `season`/`episode` (отдельного скалярного `season`
|
|||||||
двойниками из доминирующего скрипта по курируемой таблице. При отсутствии
|
двойниками из доминирующего скрипта по курируемой таблице. При отсутствии
|
||||||
доминирующего скрипта (равенство) токен SHALL оставаться без изменений.
|
доминирующего скрипта (равенство) токен SHALL оставаться без изменений.
|
||||||
|
|
||||||
Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям.
|
Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям
|
||||||
|
**плана**. Применение того же санитайзинга к значениям, пришедшим из метабазы —
|
||||||
|
каноническому названию и названиям кандидатов, — заказано отдельно, см.
|
||||||
|
`metadata-match`.
|
||||||
`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с
|
`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с
|
||||||
реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться
|
реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться
|
||||||
основанием отклонить план, а не поводом «чинить» путь.
|
основанием отклонить план, а не поводом «чинить» путь. `director` система SHALL
|
||||||
|
NOT санитизировать в плане — он не участвует в пути на диске, и его очистка
|
||||||
|
применяется при рендере отображаемого имени (см. `metadata-match`).
|
||||||
|
|
||||||
Когда санитайзинг реально изменил значение поля, система SHALL логировать это на
|
Когда санитайзинг реально изменил значение поля, система SHALL логировать это на
|
||||||
уровне `Debug` (названия не относятся к секретам).
|
уровне `Debug` (названия не относятся к секретам).
|
||||||
@@ -233,3 +257,26 @@ per-file `season`/`episode` (отдельного скалярного `season`
|
|||||||
- **AND** несовпадение src приводит к отклонению плана (эскалация в review), а не к
|
- **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** значение не изменяется
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,20 @@ LLM; нет матча в базе или несколько кандидато
|
|||||||
считаться как выбор кандидата из списка совпадений, так и ручное добавление
|
считаться как выбор кандидата из списка совпадений, так и ручное добавление
|
||||||
источника по id/URL (оба закрепляют провайдера и каноническое название).
|
источника по id/URL (оба закрепляют провайдера и каноническое название).
|
||||||
|
|
||||||
|
Закрепляемое название источника SHALL проходить санитайзинг человекочитаемых
|
||||||
|
полей (см. `recognition`) и проверку пригодности как компонента пути (см.
|
||||||
|
`metadata-match`) — на **общей** точке сборки набора пинов источника, той же,
|
||||||
|
через которую строится предпросмотр. Отсюда следует свойство, на которое
|
||||||
|
опирается экран ревью: показанное для источника название и путь совпадают с тем,
|
||||||
|
что закрепится и разложится по выбору этого источника. Название, непригодное как
|
||||||
|
имя каталога, пином SHALL NOT становиться — в плане остаётся название
|
||||||
|
распознавания, а факт отказа SHALL быть наблюдаем в журнале.
|
||||||
|
|
||||||
|
Санитайзинг на закреплении SHALL применяться независимо от того, было ли значение
|
||||||
|
очищено при сохранении кандидата: гарантия чистоты не может держаться на времени
|
||||||
|
записи строки, иначе кандидаты, сохранённые прежними версиями, обходят её. Тот же
|
||||||
|
санитайзинг идемпотентен, поэтому на уже очищенном значении он ничего не меняет.
|
||||||
|
|
||||||
При закреплении выбранного/добавленного источника система SHALL best-effort
|
При закреплении выбранного/добавленного источника система SHALL best-effort
|
||||||
получить режиссёра этого источника из метабазы (credits по `provider:id`, см.
|
получить режиссёра этого источника из метабазы (credits по `provider:id`, см.
|
||||||
`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля
|
`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля
|
||||||
@@ -244,46 +258,35 @@ SHALL быть best-effort по отношению к qBittorrent: недост
|
|||||||
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
|
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
|
||||||
только при подтверждённом матче».
|
только при подтверждённом матче».
|
||||||
|
|
||||||
#### Scenario: Выбор кандидата обновляет имя
|
#### Scenario: Выбор кандидата переливает каноническое имя
|
||||||
|
|
||||||
- **GIVEN** загрузка в ревью с пустым или неинформативным `display_name`
|
- **GIVEN** загрузка в ревью с кандидатами метабазы
|
||||||
(например, «Unknown») и списком кандидатов
|
- **WHEN** человек выбирает кандидата
|
||||||
- **WHEN** пользователь выбирает кандидата, подтверждая матч
|
- **THEN** провайдер, id и каноническое название закрепляются как override
|
||||||
- **THEN** выбор кандидата закрепляется как и прежде
|
- **AND** отображаемое имя загрузки обновляется полным ярлыком
|
||||||
- **AND** `download.display_name` обновляется полным ярлыком
|
|
||||||
«Название (режиссёр, год)» (для сериала — со сводкой сезонов)
|
|
||||||
- **AND** раздача в qBittorrent переименовывается в то же имя
|
|
||||||
|
|
||||||
#### Scenario: Ручное добавление источника обновляет имя
|
#### Scenario: Название источника показано ровно таким, каким закрепится
|
||||||
|
|
||||||
- **GIVEN** загрузка в ревью без совпадений в списке
|
- **GIVEN** кандидат, название которого содержит zero-width символ или
|
||||||
- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч
|
кириллический двойник внутри латинского слова
|
||||||
- **THEN** источник закрепляется как и прежде
|
- **WHEN** строится список источников для экрана ревью
|
||||||
- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются
|
- **THEN** в строке источника и в его предпросмотре стоит очищенное название
|
||||||
полным ярлыком подтверждённого источника
|
- **AND** выбор этого источника закрепляет то же самое значение
|
||||||
|
|
||||||
#### Scenario: Выбор кандидата подтягивает режиссёра в ярлык
|
#### Scenario: Кандидат, сохранённый прежней версией, чистится на закреплении
|
||||||
|
|
||||||
- **GIVEN** загрузка в ревью, у выбранного кандидата в credits метабазы указан
|
- **GIVEN** кандидат, чьё название записано в хранилище без санитайзинга
|
||||||
режиссёр
|
- **WHEN** человек выбирает этого кандидата
|
||||||
- **WHEN** пользователь выбирает кандидата, подтверждая матч
|
- **THEN** закрепляется санитизированное значение, а не то, что лежит в хранилище
|
||||||
- **THEN** режиссёр best-effort извлекается из метабазы и закрепляется override
|
|
||||||
- **AND** `download.display_name` получает полный ярлык с этим режиссёром
|
|
||||||
|
|
||||||
#### Scenario: Режиссёр кандидата недоступен — выбор не ломается
|
#### Scenario: Непригодное название источника пином не становится
|
||||||
|
|
||||||
- **GIVEN** выбор кандидата, для которого credits недоступны или режиссёра нет
|
- **GIVEN** кандидат, название которого не содержит ни одной буквы и ни одной
|
||||||
- **WHEN** пользователь подтверждает матч
|
цифры
|
||||||
- **THEN** выбор источника выполнен, режиссёр берётся из сохранённого контекста
|
- **WHEN** человек выбирает этого кандидата
|
||||||
или остаётся пустым
|
- **THEN** название пином не становится, в плане остаётся название распознавания
|
||||||
- **AND** команда ревью не возвращает ошибку
|
- **AND** провайдер, id и год закрепляются как обычно
|
||||||
|
- **AND** отказ закрепить название виден в журнале
|
||||||
#### Scenario: Недоступность qBittorrent не ломает выбор кандидата
|
|
||||||
|
|
||||||
- **GIVEN** выбор кандидата в ревью
|
|
||||||
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой
|
|
||||||
- **THEN** выбор кандидата и обновление `download.display_name` выполнены
|
|
||||||
- **AND** команда ревью не возвращает ошибку
|
|
||||||
|
|
||||||
### Requirement: Инфо и предпросмотр выбранного источника
|
### Requirement: Инфо и предпросмотр выбранного источника
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user