processCatched перед Add проверяет присутствие торрента в qBittorrent (один
листинг на тик): если раздача уже есть — усыновляем (promote catched→downloading
без повторного Add и без LLM-namer, имя из раздачи), иначе добавляем как раньше.
Это убирает бесконечный цикл дубль-Add → 409 → ретрай и лишние вызовы LLM.
Инвариант приёма «одна активная на infohash» делает различие «наш/чужой»
ненужным. source_type перечитывается под замком (сужение гонки апгрейда F6);
при недоступности qBittorrent тик пропускается без вызова LLM.
Дедуп на приёме (дубль на уже активную задачу) теперь отражается явным ответом
бота «дубль уже активной #id — добавление отменено».
Спека download-tracking обновлена (OpenSpec change заархивирован); закрыта
задача беклога review-f2-promote-without-add.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Голый 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>
Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической
буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось),
а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой
список кандидатов, ручной ввод id.
- recognition: санитайзинг человекочитаемых полей плана (title/original_title/
provider_hint) на границе разбора, до валидации: strip control/zero-width,
collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой
кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом).
- metadata-match: тот же fold в normalize (гейт) как defense-in-depth;
безгодовой второй проход сверки как fallback при известном годе и промахе
первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов
review. В fallback требуем известный год кандидата (год-unknown → review,
не авто); гейт год ±1 и инвариант авто-матча не двигаются.
Спеки recognition/metadata-match обновлены, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (provider, provider_id) вместо печати заново из выхода
LLM — так второй/последующий сезон ложится в ТУ ЖЕ папку, а не заводит
рядом почти одинаковую. Отдельная сущность «тайтл» не вводится.
- layout: Plan.FolderBase перекрывает базу в папке и именах файлов;
TitleFolder разбирает dst_path в папку тайтла и базу (снятие тега).
- store: LiveTitleFolders — dst_path живых ссылок того же матча.
- worker: resolveFolderBase (живость якоря — по наличию папки на диске,
os.Lstat, а не по статусу ссылки в БД) в linkPlan и в превью ревью
(превью=применение); рассинхрон нескольких живых папок → review из
linking (deferred→review в графе нет).
Схема БД не менялась. Change series-folder-convergence заархивирован,
требования влиты в openspec/specs/file-layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.
OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раздача активной (downloading) загрузки, исчезнувшая из qBittorrent (удалил
пользователь/другой клиент), делала задачу вечным зомби: поллинг промахивался
по torrentFor, писал Warn и continue каждый тик — состояние не менялось,
уведомления и телеметрии не было, checkTimeouts без торрента не срабатывал.
Пропажей источника у downloading не владел никто (сверка рассинхрона покрывает
только done/target_missing/orphaned, восстановление — failed/stuck).
Активный цикл Poll теперь применяет тот же дебаунс пропажи источника, что и
сверка рассинхрона (source_miss_count / source_missing_threshold): после порога
подряд идущих промахов задача уходит downloading → failed с distinct error_code
source_gone и уведомлением. До порога транзиентная недоступность qBit
(рестарт демона) задачу не роняет. source_gone восстановлению сверкой не
подлежит (удаление намеренно), но штатно retriable — Retry заново отдаёт
сохранённый источник; Retry сбрасывает source_miss_count, чтобы вернувшаяся
задача получила полное грейс-окно, а не упала снова на ближайшем тике.
Ребро downloading → failed уже было в графе, миграций/полей БД нет. Спека
download-tracking дополнена требованием, диаграмма workflow.md — ребром.
Change downloading-source-gone заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Команды ревью проверяли только наличие раздачи в qBittorrent, но не её
готовность. Недокачанную задачу можно припарковать в deferred, затем
«Распознать заново» → recognizing → авто-раскладка (Rerecognize/Refine/
SetType не ставят force_review) → хардлинки на неполные файлы. Даже ручной
Apply не имел preflight завершённости.
Вводим ensureSourceReady (classify(t.State)==classReady) вместо
ensureSourcePresent во всех командах, которым нужен источник (Relink/
Rerecognize/Refine/SetType), и inline-проверку класса в Apply — последний
рубеж перед хардлинками. Недокачанный источник → отдельный sentinel
ErrNotReady (409) с actionable-текстом «торрент ещё качается» в web и
Telegram, без reconcile (состояние deferred/review легитимно).
Change review-readiness-preflight заархивирован, дельта влита в
openspec/specs/review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и
Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает
там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум
контекста для распознавания без сети.
- internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и)
(v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени,
дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно
(недоверенный вход).
- Персистентность байтов: таблица-спутник download_torrent (миграция 0009);
пишется в транзакции создания загрузки, только на ветке создания (не при
дедупе). Байты живут весь срок строки — нужны для повторного добавления
при retry.
- ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref —
человекочитаемый референс (имя раздачи/файла), не адрес добавления.
- worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях —
processCatched и Retry (torrent добавляется файлом, не magnet-хешем).
- Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём
Telegram-документа (скачивание с редактированием токена из ошибок — секрет
не в логи; обработка до ветки pending/текста).
Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью
(дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change
архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Единый источник истины `allowedTransitions` (from → {разрешённые to}) в
internal/store; `setState` сверяет переход дополнительным SQL-предикатом
`state IN (<легальные источники>)` — необъявленное ребро (и не самопереход)
отклоняется атомарно, с точным сообщением. Гейт ортогонален гарду
терминальности: ребро из терминального состояния проходит только через
ActivateIfNoOtherActive. Без внешней библиотеки-FSM (обоснование — design.md).
Граф выведен построчно из воркера; ревью дизайна поймало 8 preflight-рёбер
(reconcileToReality → orphaned/deleted) и linking→cancel/defer после краха.
Тест-инвариант «cancel/defer достижимы из любого не-терминального» ловит класс
пропущенного ребра. Фикстуры тестов, форсившие состояния через SetDownloadState,
переведены на прямой UPDATE (forceState).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из
полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` —
ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent
вынесены в асинхронный шаг машины состояний, который двигает worker.
- store: состояние `catched` (нетерминальное, активная группа); атомарный
переход PromoteCatched (catched → downloading + display_name) с гардом
state='catched' (ре-валидация после сетевых вызовов вне блокировки)
- ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу
- worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий
переход; сбой add оставляет catched (ретрай тиком); предохранитель
catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи
- config: worker.catch_timeout (дефолт 10m)
- веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг
карточки/страницы до перехода в downloading; Telegram-текст без сырого catched
- OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change
заархивирован
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Синтезируем контекст из полей самой magnet-ссылки (dn, xl, tr/xs, kt) без
сети и дополняем им текст от транспорта: пользовательский текст первым, при
пустом — синтез единственный. Обогащённый контекст идёт только в
download.Context (его читают recognition и веб-UI); вход namer и отображаемое
имя не меняются — строки-факты (Размер:/Трекер:) в display_name не текут.
- magnet.Info: поля ExactLength/Sources/Keywords + Info.Context() (синтез,
отсев dn-заглушек *-topic-<id>, домен трекера, человекочитаемый размер)
- Parse устойчив к ссылке в процент-кодировке (разовый QueryUnescape)
- ingest: mergeContext → download.Context, namer на сыром req.Context
- Влита дельта capability ingest в openspec/specs, change заархивирован
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sync новых требований в openspec/specs/: web-ui («Действия обновляют
интерфейс на месте») и review («Петлевые действия ревью обновляют экран на
месте»). Change перемещён в changes/archive/2026-07-04-htmx-action-swap.
Конвенция web-ui.md актуализирована: сняты маркеры «будем» по реализованному.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Веб-UI: под шапкой страницы загрузки — дата добавления и относительная
давность («N дней назад»), как в карточках списка.
- Баг: ссылка-dereferrer TVDB для фильма вела на /series/; теперь строится
по типу запроса (/movie/ либо /series/). Тест + правка спеки metadata-match.
- Telegram: заголовки уведомлений (готово/ошибка/рассинхрон) берутся из
display_name — консистентно с веб-UI; сезон подтягивается автоматически.
- Telegram: в сообщениях об ошибке — и заголовок, и #id загрузки (ULID)
для быстрого поиска по логам.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Метки времени в SQLite переведены с формата datetime('now')
(«2006-01-02 15:04:05») на RFC 3339 всегда-UTC («2006-01-02T15:04:05Z»):
самоописываемое хранилище (зона в значении), валидный ISO 8601, единый
формат с логами. Фиксированная ширина сохраняет лексикографическую
сортировку TEXT = хронологию (COALESCE(source_added_at, created_at)).
- Единая точка генерации времени в Go: store.Now()/FormatTime; DEFAULT
(datetime('now')) снят со всех колонок — время всегда пишет приложение
(зеркально ident.NewID для id), fail-loud при забытой вставке (NOT NULL).
Все INSERT-сайты в store передают created_at/updated_at явно.
- Миграция 0008 (rebuild 7 таблиц без DEFAULT + backfill strftime, FK/PK/
индексы сохранены байт-в-байт по образцу 0006); симметричная down.
- Новая секция конфига [general] с полем timezone (дефолт UTC) — зона
ОТОБРАЖЕНИЯ в веб-UI; хранение остаётся UTC. Жёсткая валидация зоны на
старте; zoneinfo встроен (time/tzdata), заменён зашитый Europe/Moscow.
- Тесты: round-trip миграции (up/down, NULL source_added_at), валидация
зоны, сдвиг даты по зоне; обновлены фикстуры и TestUlidMigration.
- Docs: конвенции database/config, ER-схема; спека web-ui (таймзона).
OpenSpec change time-storage-rfc3339 (заархивирован).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «ID:» перед идентификатором, дата добавления
(абсолютная + относительная, всегда), размер раздачи и рейтинг отдачи.
Спойлер контекста убран — контекст смотрят на /download/{id}.
Данные:
- рейтинг и общий размер — из живого снимка воркера (qbt total_size →
worker.Live.TotalSize); размер доступен для любой раздачи в снимке;
- размер-фолбэк, когда торрента нет в qBittorrent (orphaned) — сумма
размеров разложенных файлов: новая колонка file_link.size, layouter
пишет размер при линковке, ридер LayoutSizeByDownload суммирует по
странице одним запросом (дедуп по dst_path);
- дата — source_added_at → фолбэк created_at, показ в TZ сервера.
handleIndex читает снимок для всех карточек (map-lookup), рейтинг/размер
статичны на рендере (без поллинга). Миграция 0007, ER-схема обновлена.
Change download-card-lifecycle-overview влит в спеки и заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
В карточке списка и шапке /download/{id} показываем и копируем download.id
(ULID) — тот же ключ, что в логах (download_id), удобно грепать. Infohash
остаётся в блоке «Информация о торренте». Поиск по списку расширен: матчит
любой идентификатор (download.id ИЛИ infohash), плюс название/контекст.
Удалены осиротевшие поля Infohash/InfohashShort и хелпер shortenHash.
Дельта web-ui влита в спеки, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать».
- httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для
candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и
BlockError; сводка сезонов (seasonSummary/seasonRanges)
- тип movie↔series убран из UI (read-only); удалён веб-роут /type и
handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты)
- шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного
label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries
- тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов
- openspec: спеки review/web-ui синхронизированы, change заархивирован
- беклог: сложные сериальные раздачи; oob-обновление панели действий
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
По итогам аудита соответствия спек и кода (11 сабагентов, по одному на capability):
Беклог:
- новый пункт: гейт авто-раскладки по confidence (спека говорит «вспомогательный
сигнал», код делает жёсткий AutoThreshold=0.85) — определиться, что правда
- новый пункт: привязка внешних субтитров к серии (спека требует, для сериала
связь субтитр→эпизод и пары .idx/.sub в коде не выражены)
- новый пункт: раздачи-копии диска DVD/BluRay (VIDEO_TS/BDMV — каталог целиком,
не пофайловый разбор)
- дополнен существующий баг TVDB /series/: спека metadata-match теперь тоже
кодифицирует баг — фикс должен править и требование
Спеки (правка на точность, поведение не меняется):
- download-tracking, review: «per-download блокировка» → «единая блокировка
воркера» (по факту глобальный w.mu, а не per-download)
openspec validate --strict — проходит.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Привёл набор capabilities в OpenSpec к цепочке обработки, чтобы имя capability
отвечало одному поведению. Чисто по спекам, код и поведение системы не меняются.
Change refactor-capability-boundaries (архивирован):
- recognition разделён на recognition (разбор LLM) + metadata-match (сверка с базами)
- review выделен из web-ui + мигрирован из docs/specs/review-ux.md
- новые capability из docs/specs: file-layout, download-tracking, notifications
- identity очищен до инфра-id; приём (инфохэши, дедуп, ядро приёма) — в ingest
- уведомление о рассинхроне перенесено из state-reconciliation в notifications
- дубль владения путём и безопасного undo оставлен в state-reconciliation
Итог: 11 capabilities, openspec validate --strict проходит (+37/−11 требований).
Источник истины по мигрированным темам переехал в openspec/specs (шапки в docs).
Снят пункт беклога «Пересмотр набора capabilities».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Влил 3 ADDED (единый список источников, ручное добавление по id/URL,
предпросмотр полей до фиксации) и 2 MODIFIED (превью для каждого источника;
матч ссылкой в списке источников) требования в openspec/specs/web-ui.
Change перенесён в changes/archive. Убрал реализованный пункт из беклога,
перецелил ссылки на review-ux.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase,
internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши
загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп
и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба
хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится.
Инвариант «не более одной активной загрузки на infohash» вместо снятого
unique-индекса держат guarded-методы store в одной write-транзакции
(_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом
недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ
до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет
терминал→активное как механический бэкстоп.
Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при
включённых FK, backfill ULID с timestamp из created_at (хронология id
сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id
в URL/логах/Telegram, REST-поля id (string) и infohashes (список).
Новая конвенция docs/conventions/database.md (без числовых PK), корреляция
в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability
identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены
ревью дизайна и кода (по 8 углов), все находки исправлены с
регрессионными тестами.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
(added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Воркер ведёт in-memory снимок телеметрии раздач (прогресс, скорость, ETA,
рейтинг, сиды/пиры, отдано) под отдельным RWMutex, обновляя его на каждом
тике поллинга сразу после построения byHash — без лишних вызовов qBittorrent
и без хранения в БД (волатильно). qbt.Torrent дополнен полями телеметрии.
Веб-UI читает снимок через узкий контракт LiveStatus: карточки активных
загрузок показывают живой прогресс-бар (htmx-поллинг фрагмента every 3s,
точечно — без сброса фильтров), на странице загрузки появилась секция
«Раздача» для сидирующих задач. Начальный кадр рендерится сразу со
значениями; при отсутствии данных UI деградирует штатно.
Капабилити live-status (OpenSpec), web-ui дополнен. Change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Новая capability web-ui: презентационный перенос готового дизайна
(семантический HTML + единый jellybit.css, тёмная тема по настройке ОС)
в html/template без шага сборки.
- Встроенная (go:embed) отдача статики под /static с Cache-Control и
cache-busting (?v=<hash> по содержимому css/js).
- Шрифты IBM Plex self-hosted (@font-face, cyrillic+latin), без CDN.
- Наколеночный менеджер зависимостей: вендор (htmx + шрифты) не хранится
в репо (gitignore), идемпотентно добывается `task assets` по
web/assets.manifest с проверкой sha256; task build/run зависят от assets.
- Общие партиалы: шапка, бейдж статуса (карта всех 14 состояний),
виджет «файл источника → раскладка» (общий для review и download).
- Страницы: список с фильтром/поиском, ревью, новая страница просмотра
загрузки (/download/{id}). deleted скрыт по умолчанию.
- Превью раскладки берётся из единой логики internal/layout
(buildFileRows), без дублирования правил имён в шаблонах.
- Убраны meta-refresh и инлайн-стили; copyHash на vanilla с fallback
на execCommand и честной индикацией (целевой деплой — HTTP LAN).
Вне scope (отдельный change): живые обновления прогресса и раздел
раздачи, клиентский режим ручной раскладки файл→серия (с Alpine.js).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Долгий metaDL больше не убивается агрессивным таймаутом: дефолт
magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта —
added_on из qBittorrent, а не created_at (переживает retry/усыновление).
Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по
нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и
продвинулся за условие падения (downloading/completed по статусу торрента);
qbit_error не воскрешается. Конфликт idempotency (infohash занят другой
активной задачей) — оставляем в failed.
Уведомления: любой переход в failed/stuck пингует автора (включая приёмный
qbit_add через ingest), с дебаунсом против спама при флаппинге stalled.
Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому
торренту вместо слепого Add.
Дельта state-reconciliation влита в живые спеки; обновлён workflow.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Каждый кандидат внешней базы метаданных (TMDB/TVDB/TVMaze) теперь несёт
URL на страницу элемента — при ревью можно кликнуть и проверить матч.
URL формируется клиентом провайдера при поиске, сохраняется в БД
(metadata_candidate.url) и отображается ссылкой в веб-интерфейсе.
Co-Authored-By: Claude <noreply@anthropic.com>
Завершённая загрузка ложно «воскресала» из deleted в orphaned, когда её
целевой путь переиспользовала другая загрузка (повторная закачка того же
фильма в другом качестве): сверка проверяла лишь существование пути, не
проверяя, что файл по нему — наша раскладка.
Вводим инвариант «один целевой путь — один владелец»:
- при успешной раскладке на освободившийся чужой путь владение переходит
к новой загрузке — прежние file_link на этот путь помечаются статусом
superseded и перестают считаться целью при сверке;
- deleted исключён из desyncStates — терминальное состояние больше не
переоценивается (источник к нему не вернётся из-за идемпотентности,
цель отбирается переходом владения);
- Undo снимает только реально свои разложенные ссылки (superseded
пропускает — файл по пути теперь чужой хардлинк);
- ошибку перехода владения трактуем как некритичную (WARN-and-continue):
файлы уже разложены, рассинхрон чужих задач исправит следующий тик.
Без миграции схемы (status — TEXT). Дельта влита в основную спеку,
обновлены workflow.md и jellyfin-layout.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сверка с метабазой промахивалась на иностранных фильмах с русским
релиз-именем (кейс «Тёмный рыцарь»): поиск шёл по одной строке
provider_hint||title и игнорировал original_title, а базы индексированы
прежде всего по оригинальным названиям.
- matchMetadata ищет по ключам original_title → title → provider_hint с
ранним стопом на первом единичном сильном матче; пустые и
нормализованно-дублирующие ключи пропускаются, кандидаты для review
копятся из всех заходов.
- Промпт требует всегда заполнять title и original_title (дублировать при
отсутствии оригинала / российском контенте; при неуверенности дублировать,
не выдумывать). Разбор остаётся мягким к пустому original_title.
- TMDB-поиск передаёт language (по умолчанию ru-RU, настраивается
[metadata.tmdb].language); original_title не зависит от локали.
- Нормализация названий сводит ё→е.
Инварианты не ослаблены: авто только при подтверждённом единичном матче +
структурной валидации + согласованности сигналов.
Capability recognition впервые перенесена в OpenSpec; change архивирован.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Распознаём ручное удаление источника (раздача в qBittorrent) и/или цели
(разложенные хардлинки) и отражаем его в состоянии задачи, без автодействий.
- Новая capability state-reconciliation (OpenSpec): фоновая сверка по матрице
«источник × цель» → состояния target_missing/orphaned/deleted, переходы и
самовосстановление (healing).
- worker: reconcileDesync в Poll (только разложенные/desync-задачи), дебаунс
пропажи источника (порог [worker].source_missing_threshold) и синхронный
preflight перед действиями (relink/recognize/apply/undo) — не доверяем
state в БД.
- layout.Undo: отказ снять последнюю копию (nlink<=1 или нет источника),
отказ всего батча без частичного отката (ErrLastCopy).
- store: единый список terminalStates для IsTerminal и FindActiveByInfohash
(иначе семантика «активности» разъезжается), столбец source_miss_count,
миграция 0003.
- httpapi/web и Telegram: показ новых состояний и уведомления о рассинхроне.
- Доки: workflow.md, jellyfin-layout.md, database.md (+0003), config.
Change заархивирован в openspec/changes/archive, дельта влита в openspec/specs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>