From 397f8aa2c3c4e797d309aebe0a46e44625bb11de Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 7 Aug 2026 17:03:59 +0300 Subject: [PATCH] =?UTF-8?q?tasks:=20=D0=B4=D0=B2=D0=B0=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=84=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B8=D0=B7=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20tvdb-title-locale=20=D0=B2=D0=B7=D1=8F?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B2=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metadata-title-sanitize: название из метабазы подставляется в план мимо санитизации и уезжает в имя каталога дословно - long-title-to-review: имя длиннее ~237 байт роняет раскладку в failed вместо отправки на ревью --- docs/tasks/SPRINT.md | 2 + docs/tasks/items/long-title-to-review.md | 73 +++++++++++++++++++++ docs/tasks/items/metadata-title-sanitize.md | 73 +++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 docs/tasks/items/long-title-to-review.md create mode 100644 docs/tasks/items/metadata-title-sanitize.md diff --git a/docs/tasks/SPRINT.md b/docs/tasks/SPRINT.md index 5005351..4c6a29a 100644 --- a/docs/tasks/SPRINT.md +++ b/docs/tasks/SPRINT.md @@ -10,3 +10,5 @@ - [✨ Узаконить confidence-гейт авто-раскладки в спеке и сделать его выключаемым (дефолт 0.7)](items/auto-link-confidence-gate.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку - [🐞 Тормозить опрос qBittorrent бэкоффом при недоступности и эскалировать устойчивый сбой](items/background-error-noise.md) — недоступный qBittorrent опрашивается каждые 5 с и даёт WARN на каждом тике: нужен экспоненциальный бэкофф до минутного потолка со сбросом по первому успеху и ERROR на устойчивой деградации +- [🐞 Санитизировать название из метабазы перед подстановкой в план](items/metadata-title-sanitize.md) — plan.Title = match.Title подставляется ПОСЛЕ sanitizePlan — название из TMDB/TVDB уезжает в имя каталога Jellyfin дословно, с невидимыми символами и гомоглифами, и авто-раскладка это пропускает +- [🐞 Отправлять на ревью раздачу с непомещающимся именем вместо отказа](items/long-title-to-review.md) — название длиннее ~237 байт роняет раскладку в failed с текстом системной ошибки: пользователь видит «file name too long» вместо карточки ревью, где это чинится подсказкой diff --git a/docs/tasks/items/long-title-to-review.md b/docs/tasks/items/long-title-to-review.md new file mode 100644 index 0000000..6a01362 --- /dev/null +++ b/docs/tasks/items/long-title-to-review.md @@ -0,0 +1,73 @@ +# 🐞 Отправлять на ревью раздачу с непомещающимся именем вместо отказа + +- **Тип:** fix +- **Категория:** Ядро продукта +- **Зачем:** название длиннее ~237 байт роняет раскладку в failed с текстом системной ошибки: пользователь видит «file name too long» вместо карточки ревью, где это чинится подсказкой +- **Теги:** sprint:2026-08-06 + +Длина компонента пути ограничена файловой системой (255 байт на имя, минус +расширение и суффиксы — практический потолок около 237). Название такой длины +приходит из метабазы или из распознавания штатно: длинные оригинальные заголовки +и сериалы с подзаголовком в имя укладываются не всегда. + +Сегодня это не проверяется нигде: раскладка доходит до `link(2)`, получает от ядра +`file name too long`, и задача уходит в `failed` с текстом системной ошибки в +поле ошибки. Для владельца это тупик — состояние терминальное, а причина написана +языком ядра. + +Правильный исход — **review**: там человек правит название подсказкой, ровно как +при коллизии цели. Коллизия уже так и обрабатывается, то есть путь в системе +есть и его надо переиспользовать, а не изобретать. + +Провенанс: ревью изменения `tvdb-title-locale`, находка AD4 — +[отчёт триажа](../../../openspec/changes/archive/2026-08-07-tvdb-title-locale/review/report.md). +Понижена там не по доказательности, а по принадлежности к диффу: путь этим +изменением не тронут. + +## Воспроизведение + +Оракул из отчёта: подать раздачу, у которой имя целевого файла складывается в +250 байт. + +``` +n=250 → Apply err=… file name too long, results=0 +``` + +Наблюдаемо: задача в состоянии `failed`, в поле ошибки — текст системной ошибки, +ни одной ссылки не создано, карточки ревью нет. + +Ожидаемо: задача в `review` с названной причиной «имя не помещается», раскладка +не начата, подсказка человека чинит случай. + +## Затрагивает + +- `internal/layout` — проверка длины компонента до `link(2)` и доменная ошибка + вместо системной; +- `internal/worker` — ветка перевода задачи в `review` по этой ошибке, рядом с + существующей веткой коллизии цели; +- `openspec/specs/file-layout/spec.md` — требование о непомещающемся имени; + `state-reconciliation` — если переход в `review` заказывается там; +- текст причины в карточке ревью: веб-UI и Telegram показывают её человеку. + +## Критерии приёмки + +- Раздача с именем в 250 байт уходит в `review` с доменной причиной, а не в + `failed` с текстом ядра. **Оракул:** тест на временном каталоге прогона, + воспроизводящий случай из «Воспроизведения», — проверяет состояние задачи и + код причины. +- Ни одна ссылка не создана до отказа: частичной раскладки батча не остаётся. + **Оракул:** тот же тест — проверяет, что каталог цели пуст. +- Причина, показанная человеку, не содержит текста системной ошибки. **Оракул:** + утверждение теста на текст причины плюс конвенция + [errors.md](../../conventions/errors.md) — перевод доменной ошибки на внешней + границе. +- Проверка длины стоит **до** первой операции с файловой системой. **Оракул:** + чтение диффа на ревью; тест на пустоту каталога цели его подтверждает. + +## Рамки + +Обрезать или переименовывать название самостоятельно нельзя — это решение +человека, у него для этого есть подсказка на ревью. Предел длины зависит от +файловой системы; зашивать 255 как универсальную константу не стоит, но и +выяснять предел у ядра в рантайме ради этой задачи не требуется — достаточно +консервативного значения с объяснением, откуда оно. diff --git a/docs/tasks/items/metadata-title-sanitize.md b/docs/tasks/items/metadata-title-sanitize.md new file mode 100644 index 0000000..be0e0be --- /dev/null +++ b/docs/tasks/items/metadata-title-sanitize.md @@ -0,0 +1,73 @@ +# 🐞 Санитизировать название из метабазы перед подстановкой в план + +- **Тип:** fix +- **Категория:** Ядро продукта +- **Зачем:** plan.Title = match.Title подставляется ПОСЛЕ sanitizePlan — название из TMDB/TVDB уезжает в имя каталога Jellyfin дословно, с невидимыми символами и гомоглифами, и авто-раскладка это пропускает +- **Теги:** sprint:2026-08-06 + +Название, пришедшее из метабазы, подставляется в план **после** того, как план +прошёл санитизацию: `plan.Title = match.Title` стоит ниже `sanitizePlan`. +Значение уезжает в имя каталога библиотеки, не сверенное ни с чем — гейт +авто-раскладки сверяет `normalize(c.Title) || normalize(c.OriginalTitle)` и +проходит по второму полю, а в путь идёт первое. + +`layout.sanitizeComponent` ниже по потоку снимает `/\:*?"<>|` и байты `< 0x20`, +но категорию Cf (невидимые управляющие) не трогает и гомоглифы не сворачивает. +Итог — два визуально неотличимых каталога в медиатеке. + +**Класс пре-существующий, и это важно для рамок:** тот же путь у TMDB, задача +`tvdb-title-locale` его только распространила на второго провайдера. Инвариант +«целевой путь строго под библиотекой» **держится** — проверено на `Dune/../../etc`, +`" .. "`, `"..."` и на 400 символах, выхода из песочницы нет. Поэтому `major`, +а не `critical`. + +Провенанс: ревью изменения `tvdb-title-locale`, находка 2 — +[отчёт триажа](../../../openspec/changes/archive/2026-08-07-tvdb-title-locale/review/report.md). + +## Воспроизведение + +Падающий тест на реальном `Recognizer` (оракул добыт триажом, не рассуждением). +Подать кандидата метабазы с названием и посмотреть на `plan.Title` и решение +гейта: + +| Вход | Что происходит | +| --- | --- | +| три символа ZWSP (`U+200B`) | `auto=true`, в план уезжают невидимки; `sanitizeTitle` дал бы пустую строку | +| `Dunegnp.mkv` (`U+202E`) | `auto=true`, имя каталога переворачивается при отображении | +| `Dune\nHACK` | `auto=true`, перевод строки доезжает до плана | +| `Dunа` с кириллической `а` | `auto=true`, каталог визуально неотличим от латинского `Dune` | + +Во всех четырёх случаях значение в плане отличается от того, что дал бы +санитайзер, и ни одно не остановлено гейтом. + +## Затрагивает + +- `internal/recognize/recognize.go` — порядок подстановки `match.Title` и + `match.OriginalTitle` относительно `sanitizePlan`; +- `internal/layout` — состав `sanitizeComponent`, если решим закрывать категорию + Cf и гомоглифы здесь, а не на подстановке (выбор места — часть задачи); +- поведение **обоих** провайдеров, TMDB и TVDB: правка меняет уже работающий + TMDB, и это её главный риск; +- `openspec/specs/metadata-match/spec.md` либо `file-layout` — чьим требованием + станет «название из метабазы санитизируется перед попаданием в путь». + +## Критерии приёмки + +- Все четыре входа из «Воспроизведения» дают либо санитизированный `plan.Title`, + либо уход в review — но не авто-раскладку с исходным значением. **Оракул:** + тот самый падающий тест из отчёта триажа, перенесённый в дерево. +- Название, схлопывающееся санитизацией в пустую строку, не порождает каталог с + пустым именем и не роняет раскладку. **Оракул:** табличный тест на границе, + случай «три ZWSP». +- Поведение TMDB на нормальных названиях не изменилось. **Оракул:** существующие + тесты `internal/recognize` и `internal/metadata` зелёные без правок ожиданий. +- Место санитизации названо требованием спеки, а не только кодом. **Оракул:** + `openspec validate --strict` на дельте. + +## Рамки + +Гомоглифы сворачивать не обязательно — достаточно сделать значение в пути +предсказуемым и сверяемым; полноценная нормализация Unicode это отдельный +разговор. Инвариант «целевой путь строго под библиотекой» уже держится, ломать +его правкой нельзя. Правка задевает работающий TMDB — регресс на нём дороже +самого дефекта.