Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе, и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на распознанное название. Верное каноническое имя, вычисляемое позже при распознавании, никуда не переливалось. - naming: гард пустого входа в DeriveName (нет контекста и подсказки → "" без вызова LLM) + детерминированный форматтер FormatTitleYear. - qbt: операция RenameTorrent (переименование существующей раздачи). - store: SetDisplayName — обновление имени постфактум без гарда состояния. - worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического имени (эффективный план) в display_name + best-effort rename раздачи по реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add). - web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка, деградация без JS), видимая при наличии распознавания (вкл. done/orphaned). Спека: дельты ingest/review/web-ui влиты в openspec/specs; change refresh-display-name заархивирован. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
Context
Отображаемое имя (download.display_name) выводится один раз — на шаге
добавления пойманной загрузки в qBittorrent (worker.processCatched →
namer.DeriveName → PromoteCatched). Источник имени на этом шаге — только
пользовательский контекст и подсказка из полей источника (dn magnet / имя
.torrent). Дерева файлов ещё нет (magnet не разрезолвлен), поэтому для голого
magnet без контекста выводить имя не из чего.
Наблюдавшийся дефект (download_id=01kx5sk1q2vdm7xgyznrz5kheg): при пустом входе
DeriveName всё равно вызывает LLM, и модель возвращает мусорное непустое имя
(«Unknown»). Оно пишется в display_name и в rename qBittorrent и блокирует
UI-фолбэк downloadTitle (тот отдаёт непустой display_name вместо
распознанного названия). Между тем к моменту ревью каноническое имя уже
вычислено и сохранено: recognition.title/year, а при выборе кандидата —
пины title/year через SetOverride (chooseCandidateLocked) и
metadata_candidate.title/year. Ничто не переливает его обратно.
Ограничения: инвариант «источник неприкосновенен», отображаемое имя — исключительно косметика (не влияет на пути/распознавание/раскладку); переименовать можно только свою раздачу (адресация по infohash-владению).
Goals / Non-Goals
Goals:
- Убрать корень «Unknown»: не выводить имя из пустого входа (не звать LLM, когда нет ни контекста, ни подсказки).
- Дать способ обновить
display_nameи имя раздачи в qBittorrent на уже вычисленное каноническое имя — авто (при подтверждённом матче) и вручную (кнопка), без нового вызова LLM. - Добавить операцию переименования существующей раздачи в qBittorrent.
Non-Goals:
- Переизобретать вывод имени: новый LLM-вызов по контексту не делаем (контекст тот же скудный, что дал «Unknown»).
- Локализация/выбор языка названия — берём каноническое как есть (то же, что использует раскладка).
- Суффикс сезона в имени сериала — распознавание не несёт скалярного
season(сезоны — per-file); ограничиваемсяTitle (Year). - Изменения схемы БД — используем существующий
download.display_name.
Decisions
D1. Источник имени — эффективное распознанное название, не новый LLM
Обновлённое имя берём из эффективного распознанного названия: пины
title/year (если матч подтверждён выбором кандидата/ручным пином) поверх
recognition.title/year. Это ровно то, что использует раскладка, — значит имя
согласовано с тем, куда лягут файлы.
- Альтернатива A: заново звать
namer.DeriveNameпо контексту. Отклонено: контекст тот же скудный, что уже дал «Unknown»; распознавание видело дерево файлов и точнее. - Альтернатива B:
rec_titleкак есть.recognition.titleбез года — беднее; форматTitle (Year)информативнее и совпадает с ярлыком add-шага.
D2. Детерминированный формат имени
Формат — Title (Year) (год опционален; при отсутствии — просто Title),
очистка от управляющих символов и обрезка по длине переиспользуют существующую
логику naming (sanitize/truncate). Без сети, без LLM.
Форматтер Title (Year) живёт в пакете naming (там же, где sanitize/
truncate — их придётся экспортировать) и вызывается и авто-триггером, и ручным
эндпоинтом. Он не унифицируется с add-шаговым render(): тот даёт иной формат
Title (Director, Year) + . Сезон N; общими остаются только sanitize/
truncate, но не сам форматтер (у refresh нет режиссёра/скалярного сезона).
D3. Переименование раздачи в qBittorrent — новая best-effort операция
Добавляем qbt.Client.RenameTorrent(ctx, hash, name) → POST /api/v2/torrents/rename (form hash, name).
Адресация: qBittorrent индексирует раздачу собственным hash, который для
гибридных/v2-only раздач может не совпадать с нашим PrimaryInfohash() (первый из
сохранённых v1/v2). Поэтому helper резолвит раздачу через существующий
torrentByInfohash (review.go) по любому из наших хешей и передаёт в
RenameTorrent настоящий t.Hash. Побочно это бесплатно закрывает случай
«раздача удалена» — резолв не находит t, rename не вызывается (best-effort no-op).
Переименование в qBittorrent — best-effort: сбой (раздача уже удалена, qBit
недоступен) НЕ проваливает обновление имени. display_name в нашей БД
обновляется в любом случае; ошибку qBit логируем WARN (внешний вызов —
ext.service=qbittorrent). Обоснование: имя косметическое, недоступность qBit не
должна блокировать команду ревью или кнопку.
D4. Точки входа: авто при подтверждённом матче + ручная кнопка
Помощник расщеплён по контракту блокировки (конвенция суффикса *Locked в
worker): вся логика — в refreshDisplayNameLocked(ctx, id), вызывается под
w.mu; публичная обёртка RefreshDisplayName(ctx, id) берёт w.mu сама.
refreshDisplayNameLocked:
- читает загрузку и текущее распознавание с учётом пинов;
- формирует имя (D2); пустое имя → ничего не делаем (no-op);
- пишет
display_name(новый store-метод, D5); - резолвит раздачу через
torrentByInfohashи best-effort переименовывает её в qBittorrent (D3).
- Авто:
chooseCandidateLocked(уже подw.mu) вызываетrefreshDisplayNameLockedпосле успешногоSetCandidateChosen.chooseCandidateLocked— общий путь и для выбора кандидата из списка (ChooseCandidate), и для ручного добавления источника (AddManualSource); оба — подтверждение матча, оба получают авто-refresh (согласуется с инвариантом «авто-действие только при подтверждённом матче»). Наблюдавшийся кейс (matched=false→ ручной выбор кандидата) закрывается именно этим. - Ручной: HTTP-эндпоинт
POST /ui/downloads/{id}/refresh-nameвызывает обёрткуRefreshDisplayName(берётw.mu) и возвращает htmx-партиал заголовка. Доступен, когда у загрузки есть распознавание (в т.ч.done/orphaned, а не только reviewable-состояния — см. D8).
Замечание по блокировке: авто-путь делает сетевой резолв Torrents/RenameTorrent
под w.mu (как уже делают Apply/Delete), т.е. на время rename команды ревью
заблокированы. Приемлемо: rename — один короткий вызов; альтернатива (вынести из-под
mu) усложнила бы ре-валидацию state и не стоит того для косметики.
D5. Store-метод обновления имени пост-фактум
Текущий PromoteCatched пишет display_name только на переходе
catched→downloading. Нужен отдельный метод SetDisplayName(ctx, id, name)
(UPDATE display_name, updated_at), не завязанный на состояние: обновление
имени валидно в review/done и не двигает FSM.
D6. Политика перезаписи
- Ручная кнопка: всегда перезаписывает (явное действие пользователя).
- Авто при подтверждённом матче: перезаписывает, когда выведенное имя
непусто. Подтверждённый матч авторитетнее add-догадки; отдельного
пользовательского редактирования
display_nameв системе нет, затирать нечего.
D7. Гард пустого входа в naming (корень «Unknown»)
DeriveName: если strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" — сразу вернуть "", не вызывая ни LLM, ни фолбек. Приём добавляет
загрузку без rename, display_name пуст, UI берёт заголовок из фолбека
(распознанное имя или усечённый источник). Позже refresh донесёт каноническое имя.
D8. Видимость кнопки «Обновить имя» — по наличию распознавания
Кнопка гейтится наличием распознавания (rd.Recognition != nil/наличие плана),
а НЕ полем Reviewable. Иначе на done/orphaned (распознавание уже есть, но
состояние не reviewable) кнопка пропала бы — а именно там она и нужна, чтобы
переименовать раздачу постфактум. Так же это оставляет запас на будущее: при
включённых метабазах авто-раскладка (finishRecognition, Auto без review) минует
chooseCandidateLocked, и единственной точкой обновления имени для голого magnet
на done останется кнопка.
Risks / Trade-offs
- Переименование раздачи в qBittorrent удивит пользователя, следящего за клиентом. → Это и есть цель (осмысленное имя вместо «Unknown»); срабатывает только при подтверждённом матче или явной кнопке.
- Каноническое имя на языке оригинала может отличаться от ожидаемого. → Non-goal (D1/Non-Goals); берём то же имя, что и раскладка, — консистентность важнее локализации.
- Гонки: раздачу удалили между чтением и rename. → Best-effort (D3): rename
не проваливает операцию,
display_nameуже обновлён. - Затирание add-имени слегка иным форматом. → Приемлемо (D6): подтверждённый матч авторитетнее; формат совпадает с add-ярлыком.
Migration Plan
Обратная совместимость полная: схема БД не меняется, старые загрузки продолжают работать. Развёртывание — обычный деплой бинаря. Откат — откат бинаря; данные не мигрируют. Уже существующие «Unknown»-загрузки чинятся ручной кнопкой или повторным подтверждением кандидата.
Open Questions
Разрешено на ревью дизайна (чекпоинт №1):
- Авто-триггер держим узким: только
chooseCandidateLocked(выбор кандидата + ручное добавление источника) + ручная кнопка.SetProviderIDиApplyавто-refresh НЕ получают. Причины:SetProviderIDпинит пустыеtitle/year(sourcePins(...,"",0)) — эффективное имя всё равно падает наrecognition.title, «подтверждённости» меньше, а кнопка это закрывает;Applyотносится кfile-layout, вшивать туда косметику — размывать границы capability и дёргать rename на каждом повторном apply.