Compare commits

..
18 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 280204db18 Правки: дата на стр. загрузки, ссылка TVDB по типу, заголовки в Telegram
- Веб-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>
2026-07-04 11:50:04 +03:00
avandClaude Opus 4.8 5d5456fa68 Хранение времени: RFC 3339 (UTC) + таймзона отображения в конфиге
Метки времени в 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>
2026-07-04 11:32:07 +03:00
avandClaude Opus 4.8 bb245a90a3 Веб-UI: обзор жизненного цикла в карточке загрузки
Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «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>
2026-07-04 10:34:45 +03:00
avandClaude Opus 4.8 9ed732e49e Веб-UI: основной идентификатор карточек — download.id вместо infohash
В карточке списка и шапке /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>
2026-07-04 09:47:27 +03:00
avandClaude Opus 4.8 80e725eb9a Просмотр: ориг. название обычным шрифтом, не mono
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:29:29 +03:00
avandClaude Opus 4.8 1387afe94f Просмотр: блок «Распознано как» — таблица поле→значение, +сезон/режиссёр
Блок распознавания на /download/{id} переведён из карточки с постером в
единую таблицу kv (поле слева, значение справа) — убирает неоднозначность
трёх одинаковых названий. Поля: название, ориг. название, тип, сезон
(для сериала), год, режиссёр (зарезервировано «—»), база, уверенность.
Мёртвый CSS карточки/постера удалён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:26:43 +03:00
avandClaude Opus 4.8 1f4267a046 Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)
Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным 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>
2026-07-04 08:12:28 +03:00
avandClaude Opus 4.8 2ed9c9020f Беклог: 3 пункта из аудита спек↔код + правка формулировки per-download (docs)
По итогам аудита соответствия спек и кода (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>
2026-07-03 21:32:02 +03:00
avandClaude Opus 4.8 512567c8ba Рефакторинг границ capabilities: цепочка загрузка→матч→ревью→раскладка (openspec)
Привёл набор 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>
2026-07-03 21:17:51 +03:00
avandClaude Opus 4.8 b3d7c08f4a Записал в беклог баг: ссылка TVDB всегда /series/ (для фильмов неверна) (docs)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:41:49 +03:00
avandClaude Opus 4.8 322bd8aa5b Заархивировал review-source-selection: дельта web-ui влита в спеки (openspec)
Влил 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>
2026-07-03 10:34:10 +03:00
avandClaude Opus 4.8 aac3ced262 Реализовал выбор источника и предпросмотр в ревью (review-source-selection)
Экран ревью теперь показывает единый список источников совпадения: строка
«распознано нейронкой» наравне с кандидатами баз; выбор/переключение/снятие
в пользу нейронки; ручное добавление по id или URL (TMDB/IMDb — по URL,
TVDB — по числовому id); предпросмотр полей и целевых путей каждого источника
до применения (место под режиссёра зарезервировано). «Раскладка» осталась
отдельной секцией для активного источника, инлайн-превью неактивных — по клику.

Ядро: единая деривация «источник → overrides» (sourcePins), общая для превью
и коммита → preview == apply; заодно чинит латентный залипший override
title/year при переключении источника. Превью считается эфемерно, без записи
в БД; пользовательский URL только парсится (SSRF нет).

Ревью дизайна и кода пройдены; правки ревьюеров учтены (сообщение об ошибке
ручного ввода доходит до пользователя, URL без схемы принимается).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:29:27 +03:00
avandClaude Opus 4.8 3d3448d050 Уточнил детали реализации review-source-selection по итогам разбора (openspec)
Зафиксировал в design/tasks: []SourceOption в ReviewData (нейронка первой),
общая деривация overridesForSource, предпросмотр всех источников на сервере
с раскрытием по клику, секция «Раскладка» остаётся отдельной для активного
источника.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 09:57:13 +03:00
avandClaude Opus 4.8 e7fe88a986 Завёл change review-source-selection: выбор источника и предпросмотр в ревью (openspec)
Переработка экрана ревью: единый список источников (нейронка наравне с
кандидатами баз), выбор/переключение/снятие в пользу нейронки, ручное
добавление по id/URL, предпросмотр полей и целевых путей до применения.
Дизайн отревьюен: единая деривация «источник → overrides» (preview==apply,
чинит залипший override title/year). Ограничились существующими capabilities.

Беклог: добавил две идеи — «Пересмотр набора capabilities и рефакторинг спек»
и «Сила совпадения кандидата / пересмотр распознавания и матчинга».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 09:52:48 +03:00
avandClaude Opus 4.8 3df7f88fdc Убрал из беклога учёт стоимости и метрики LLM (docs)
Разные провайдеры и мониторинг запросов к LLM закрыты внешним LLM Gateway
(bifrost) на сервере; для наблюдения за состоянием пока достаточно slog.
Заодно поправил висячую ссылку на удалённый раздел в «Истории переходов».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 09:03:14 +03:00
avandClaude Opus 4.8 5075f6b116 Убрал из беклога реализованную задачу идентичности на ULID (docs)
Раздел «Идентичность загрузки: ULID + множество инфохэшей» закрыт коммитом
37f2f64 и заархивированным change ulid-identity — удаляю из беклога.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 08:57:50 +03:00
avandClaude Fable 5 37f2f6481a Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с 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>
2026-07-02 21:25:00 +03:00
avandClaude Fable 5 b808ceff25 Переработал черновик логической модели и беклог по итогам разбора (docs)
Итог explore-сессии: сущность title не вводим — download остаётся мостом
qBittorrent ↔ файлы, «второй сезон» решается правилом сходимости папки,
группировка тайтла вычисляется. Черновик перекроен под это решение
(отвергнутые варианты и триггер пересмотра зафиксированы), задачи беклога
обновлены и приоритезированы по калибровке болей.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:24:39 +03:00
131 changed files with 9755 additions and 1572 deletions
+6 -3
View File
@@ -124,9 +124,12 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
[docs/conventions/config.md](docs/conventions/config.md).
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
- Миграции БД (goose, `internal/store/migrations`) — при изменении структуры
(таблица/столбец/индекс/связь) в том же change обновляем ER-схему
[docs/specs/database.md](docs/specs/database.md).
- Идентификаторы — TEXT ULID (lowercase) через `internal/ident`, без числовых
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе:
[docs/conventions/database.md](docs/conventions/database.md).
- Миграции БД (goose, `internal/store/migrations`; SQL для DDL, Go — когда
нужен код) — при изменении структуры (таблица/столбец/индекс/связь) в том же
change обновляем ER-схему [docs/specs/database.md](docs/specs/database.md).
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в
[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec.
+1
View File
@@ -11,6 +11,7 @@ package main
import (
"os"
"strings"
_ "time/tzdata" // встроенная база zoneinfo: general.timezone работает на любом хосте
"git.vakhrushev.me/av/jellybit/internal/logging"
)
+6 -1
View File
@@ -148,6 +148,10 @@ func runServe(args []string) error {
logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL)
}
loc, err := cfg.DisplayLocation() // валидность уже проверена config.Load
if err != nil {
return err
}
router, err := httpapi.NewRouter(httpapi.Deps{
Logger: logger,
Ingestor: ingestor,
@@ -155,6 +159,7 @@ func runServe(args []string) error {
Reader: st,
Reviewer: wrk,
Live: wrk,
Loc: loc,
})
if err != nil {
return err
@@ -184,7 +189,7 @@ func runServe(args []string) error {
}, logger)
wrk.SetNotifier(bot)
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
ingestor.SetFailureNotifier(func(id int64) {
ingestor.SetFailureNotifier(func(id string) {
bot.Notify(context.Background(), id, worker.EventFailed)
})
go bot.Run(ctx)
+4
View File
@@ -4,6 +4,10 @@
# config.toml в рабочей директории; путь переопределяется опцией --config=path.
# Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db.
[general]
# Общие настройки приложения.
timezone = "UTC" # таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр. "Europe/Moscow"); хранение всегда в UTC. Пусто → UTC
[qbittorrent]
url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса
username = "admin" # логин WebUI
+183 -117
View File
@@ -14,17 +14,54 @@ _(идея)_ — их сперва надо проработать.
### Проблема второго сезона
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
распознавание должно привязать новый сезон к **тому же** названию и папке,
а не завести рядом почти одинаковую вторую папку. Ключ — стабильный
`provider_id`: один и тот же `[tvdbid-…]` → одна папка сериала, новые
`Season NN` доливаются внутрь. Нужно: при матче учитывать уже существующие
в библиотеке сериалы (или прошлые распознавания с тем же провайдер-id) и
склонять LLM/выбор кандидата к согласованности с ними.
новый сезон должен лечь в **ту же** папку сериала, а не завести рядом почти
одинаковую вторую. Разбор ([drafts/logical-title-model.md](drafts/logical-title-model.md))
показал: проблема не в группировке, а в **сходимости папки** — папка каждый
раз печатается заново из выхода LLM, и совпадение `provider_id` не
гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год
сериала). Отдельная сущность «тайтл» **не вводится**; решение — правило
сходимости при построении плана: при подтверждённом матче наследовать базу
папки от живых `file_link`'ов загрузок с тем же `(provider, provider_id)`,
игнорируя LLM-выход; якоря нет — папка из распознавания, как сейчас (первая
загрузка «печатает» имя).
Связано: [recognition.md](specs/recognition.md) (модель уверенности,
- [ ] lookup живых ссылок по `(provider, provider_id)` через current recognition
- [ ] наследование базы папки (имя + год) при построении плана раскладки
- [ ] рассинхрон (несколько живых папок с одним матчем) → review, не молча
- [ ] тесты: сходимость, отсутствие якоря (свежая папка), смена провайдера
Связано: [drafts/logical-title-model.md](drafts/logical-title-model.md) §5.2,
[recognition.md](specs/recognition.md) (модель уверенности,
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
сериала с провайдер-id).
### Раздачи с докачиванием (слияние при повторном добавлении)
Свежий сериал раздают по мере выхода: торрент содержит 5 эпизодов из 10,
позже его перезаливают целиком, и пользователь добавляет раздачу повторно.
Решение проработано ([drafts/logical-title-model.md](drafts/logical-title-model.md)
§6.2): новая загрузка приходит в ту же папку за счёт правила сходимости, а
раскладка становится **merge** — доложить только недостающее. Существующие
пути не трогаем (never-overwrite, владение остаётся у старой загрузки),
новые кладём (владеет новая). Split-ownership сезона (серии поделены между
загрузками) принят как норма per-path модели; обе раздачи сидируют
независимо.
- [ ] в плане раскладки отличать «путь занят живой ссылкой того же матча»
(→ пропустить) от настоящей коллизии (→ review, как сейчас)
- [ ] merge-раскладка: существующее пропустить, недостающее доложить
- [ ] показать итог в карточке: сколько доложено, сколько уже было
- [ ] решить «слияние загрузок» при перезаливе той же вещи (одна строка
`download` + новый infohash vs новая загрузка) — открытый вопрос
черновика §10
Зависит от правила сходимости ([«Проблема второго
сезона»](#проблема-второго-сезона)) и выигрывает от ULID-идентичности.
Связано: [jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка,
идемпотентность), [workflow.md](specs/workflow.md) (повторный прогон
загрузки).
### Удаление средствами jellybit («единое окно», path 2)
Распознавание **ручного** удаления (источник из qBittorrent / цель из
@@ -35,68 +72,29 @@ preflight перед действиями. См. `openspec/specs/state-reconcili
[workflow.md](specs/workflow.md) → «Сверка с реальностью».
Осталось (path 2) — продолжение «единого окна»: удалять просмотренное
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Нужно
продумать: команду удаления (снять наши хардлинки + опц. удалить раздачу из
qBittorrent с файлами), подтверждение осознанности (а не случайный клик) и
как это сочетается с инвариантом «источник неприкосновенен», когда
пользователь сам просит убрать источник.
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Решения из
разбора ([drafts/logical-title-model.md](drafts/logical-title-model.md)
§5.3, §6.4): «тайтл» — вычисляемая группа загрузок по
`(provider, provider_id)` / общей папке, без новой сущности; удаление
целиком — обход загрузок группы штатным undo; удаление раздачи из
qBittorrent — осознанный выход за инвариант «источник неприкосновенен»,
только по явному подтверждению (не случайному клику).
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
- [ ] удаление одной загрузки: снять её живые хардлинки (штатный undo,
`superseded` пропускаем, `nlink`-гард) + опц. удалить раздачу из
qBittorrent с файлами — с осознанным подтверждением
- [ ] вычисляемая группа «тайтл» в UI: состав сериала/фильма (загрузки,
сезоны, файлы) одним экраном
- [ ] удаление тайтла целиком: обход загрузок группы + опц. снос
опустевшей папки
- [ ] после полного удаления память о тайтле не остаётся (линза без
содержимого не нужна)
Связано: [drafts/logical-title-model.md](drafts/logical-title-model.md),
[ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
[architecture.md](specs/architecture.md) → «Раскладка файлов»,
[workflow.md](specs/workflow.md).
### Ревью: выбор источника совпадения и предпросмотр
Переработать страницу ревью так, чтобы показывать **все** совпавшие
результаты по метабазам списком и дать выбрать из них. Принцип: совпадение
есть **всегда** — мы лишь выбираем источник. Поэтому матч нейронки — это
отдельная строка в том же списке (наравне с кандидатами TMDB/TVDB), а не
особый режим.
Возможности экрана:
- список кандидатов из баз + строка «распознано нейронкой»;
- выбрать один кандидат, переключиться на другой, отменить матч с базой в
пользу нейронки;
- добавить кандидат вручную (по id/url базы), когда автопоиск промахнулся;
- при выборе/переключении — **предпросмотр полей** (название, режиссёр,
год) и **предпросмотр раскладки** (целевые пути) до применения.
Развивает уже реализованный показ матча в вебе (страница загрузки и экран
ревью — провайдер, id, ссылка на запись) и детальный экран `/download/{id}`;
пересекается с быстрым выбором в Telegram. Веб остаётся точкой точных правок.
Связано: [review-ux.md](specs/review-ux.md) (выбор кандидата, «без базы»,
переключатель типа), [recognition.md](specs/recognition.md) (кандидаты
матча, провайдер-id), [«Улучшения UI: показывать матч»](#улучшения-ui-показывать-матч-с-записью-метабазы),
пакет `httpapi`.
### Наблюдаемость: метрики и учёт стоимости LLM
Сейчас единственное окно в систему — `slog`. Нет быстрых ответов на
вопросы «сколько задач висит в review», «сколько токенов и денег съело
распознавание», «какова медиана времени ingest → done». Нужны метрики:
эндпоинт `/metrics` (Prometheus-формат) со счётчиками загрузок по
состояниям, длительностями стадий и расходом LLM (токены/стоимость на
задачу).
**Расход LLM уже снимается с провода**`llm.openai` парсит `usage`
(prompt/completion/total tokens и `cost`, который отдаёт шлюз) в
`llm.Response.Usage` и пишет в лог. Не хватает только **персистентности и
отображения**: сохранять `usage` у попытки распознавания (`recognition`) и
показывать в карточке загрузки + агрегатом в `/metrics`. Где провайдер не
шлёт `cost` — считать из токенов по таблице «модель → цена» в конфиге.
Отдельный LLM-прокси (LiteLLM и т.п.) для этого **не нужен** и противоречит
принципам «один бинарь» / «минимум компонентов»: подсчёт токенов уже в коде,
а роль мульти-модельного шлюза играет используемый OpenAI-совместимый
эндпоинт (он и возвращает `cost`); разные модели подключаются сменой
`[llm].model` или новым типом провайдера за интерфейсом `llm.Provider`.
Связано: [architecture.md](specs/architecture.md) → «Логирование»,
[recognition.md](specs/recognition.md) (провайдер LLM, `[llm].type`),
пакеты `worker`, `llm`, `httpapi`.
### Ретеншн и очистка БД
Терминальные задачи (`done`/`cancelled`/`failed`/`reverted`), их попытки
@@ -167,21 +165,23 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
[docs/conventions](conventions/README.md),
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
### Автогенерируемый идентификатор загрузки (ULID/UUID)
### Сила совпадения кандидата и пересмотр распознавания/матчинга _(идея)_
Сейчас загрузка фактически идентифицируется хешем торрента (`infohash`). Это
хрупко: у одной логической загрузки может быть **несколько** хешей
(перезаливы, докачивание, репаки, v1/v2 infohash), и привязка домена к хешу
мешает слиянию и истории. Ввести собственный стабильный идентификатор
(ULID/UUID), генерируемый при приёме, как первичный ключ домена;
`infohash`(ы) — отдельный атрибут/таблица «многие к одному», по которому
**остаётся** поиск и дедуп для обратной совместимости. Enabler для
«докачивания», «второго сезона», «версий/качества» и истории переходов.
Сейчас у кандидата метабазы нет метрики силы совпадения (`metadata_candidate`
хранит provider/id/title/year/url), а решение «авто vs review» — по правилу
«единственный сильный матч + валидация», не по числовой уверенности. Для
ревью это значит: список кандидатов нечем отсортировать/подсветить по
уверенности — берём порядок сбора. Идея — ввести на этапе матча **силу
совпадения кандидата** (точное совпадение названия+года vs частичное) для
сортировки и подсказки в UI. Шире — отдельно продумать **сам процесс
распознавания и матчинга**: границы «разбор LLM / поиск в базе / сверка»,
что храним у кандидата, как считаем и показываем уверенность. Требует
проработки перед реализацией.
Связано: [database.md](specs/database.md) (PK `download`, `infohash`),
[«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
[architecture.md](specs/architecture.md) → «Идентификация торрента», пакет
`store`.
Связано: [recognition.md](specs/recognition.md) (модель уверенности),
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md),
[review-ux.md](specs/review-ux.md) (выбор источника в ревью, реализовано),
[«Пересмотр набора capabilities»](#пересмотр-набора-capabilities-и-рефакторинг-спек).
### История переходов загрузки
@@ -193,10 +193,11 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
Естественно ложится на собственный идентификатор загрузки.
Связано: детальный экран загрузки (`/download/{id}`) уже реализован — лог
переходов ложится в него; [workflow.md](specs/workflow.md) (граф состояний),
[«Наблюдаемость: метрики»](#наблюдаемость-метрики-и-учёт-стоимости-llm)
(длительности стадий), [database.md](specs/database.md), пакеты `worker`,
`store`.
переходов ложится в него;
[drafts/logical-title-model.md](drafts/logical-title-model.md) §5.4 (схема
`state_transition`, actor `worker|human|reconcile`),
[workflow.md](specs/workflow.md) (граф состояний),
[database.md](specs/database.md), пакеты `worker`, `store`.
### Машина состояний на go-библиотеке
@@ -222,23 +223,6 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
веб = точные правки), [architecture.md](specs/architecture.md) →
«Транспорты».
### Раздачи с докачиванием (слияние при повторном добавлении)
Свежий сериал часто раздают по мере выхода: торрент содержит 5 эпизодов из
10. Позже его перезаливают целиком (или добавляют недостающие серии), и
пользователь повторно добавляет тот же торрент. Нужно распознать, что это
**та же** раздача/сезон, и повторить раскладку с **слиянием**: доложить
недостающие хардлинки, не дублируя уже разложенное и не перезаписывая
существующее (инвариант «существующее не трогаем»). Перекликается с
«Проблемой второго сезона», но здесь доливаются эпизоды внутри одного
сезона, а не новый сезон. Нужно продумать: как опознать повторное
добавление (хеш торрента / провайдер-id + сезон), как сверять состав файлов
и доливать только новые.
Связано: [«Проблема второго сезона»](#проблема-второго-сезона),
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка, идемпотентность),
[workflow.md](specs/workflow.md) (повторный прогон загрузки).
### Улучшения UI: показывать матч с записью метабазы
Web-сторона реализована: страница загрузки `/download/{id}` и экран ревью
@@ -246,12 +230,32 @@ Web-сторона реализована: страница загрузки `/d
загрузка — провайдер, id и ссылку на запись. Осталось довести то же в
**Telegram**: в уведомлениях/подтверждениях показывать запись матча (название,
год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота.
Полный выбор источника в вебе — см. [«Ревью: выбор источника
совпадения»](#ревью-выбор-источника-совпадения-и-предпросмотр).
Полный выбор источника в вебе уже реализован — см.
[review-ux.md](specs/review-ux.md).
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
(матч в базе), [architecture.md](specs/architecture.md) → «Транспорты».
### Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки _(идея)_
Обычный случай сериальной раздачи — один сезон (его номер надо сразу видеть
глазами и сверять на ревью — под это сделана сводка сезонов в инфо-части, см.
`openspec/specs/review`). Но в редких заказах раздача бывает сложнее: **все
сезоны сериала разом**, **пак нескольких сезонов**, смешанная нумерация, вложенные
папки сезонов, разнобойные имена файлов. Сейчас `PlanFile.Season` задаётся на
каждом файле (мультисезон в принципе выразим), но целостно эти сценарии не
проработаны: как надёжно распознать многосезонную раздачу, как показать её на
ревью (сводка — лишь страховка, не полноценный разбор по сезонам), как разложить
и как это стыкуется со сходимостью папки и merge-докачиванием. Проработать
крайние случаи и решить, что поддерживаем явно, а что уводим в ревью как «сложную
раскладку».
Связано: [recognition.md](specs/recognition.md) (сезон-паки, нумерация),
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка сезонов),
[review-ux.md](specs/review-ux.md) (крайние сценарии, сводка сезонов),
[«Проблема второго сезона»](#проблема-второго-сезона),
[«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении).
### Аниме с абсолютной нумерацией
Релизы аниме часто нумеруют серии сквозным числом (`#137`) без сезонов, а
@@ -286,20 +290,6 @@ qBittorrent, без исходящих запросов на пользоват
Связано: [architecture.md](specs/architecture.md) → «Деплой» (data-том,
«бекапить-и-не-терять»), пакет `store`.
### Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
Фильм уже разложен, позже добавили раздачу лучшего качества — сейчас это
просто новая задача, упирающаяся в «коллизию цели → review», без понятия
«это та же вещь, заменить версию». Нужно осознанно обработать апгрейд
качества: распознать тот же тайтл, предложить замену существующей раскладки
либо сосуществование версий (Jellyfin поддерживает несколько версий одного
фильма). Близко к «докачиванию», но про качество, а не про эпизоды.
Связано: [«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
[jellyfin-layout.md](specs/jellyfin-layout.md) (never-overwrite, коллизия),
[architecture.md](specs/architecture.md) → «Идентификация торрента»
(репаки = разные infohash → разные задачи).
### Глубокий healthcheck и статус зависимостей
`/healthz` проверяет только сам сервис. Если qBittorrent, LLM или метабаза
@@ -321,8 +311,72 @@ qBittorrent, без исходящих запросов на пользоват
[«Многоступенчатая верификация»](#многоступенчатая-верификация-привязки-идея),
[architecture.md](specs/architecture.md) → «Хранилище» (`hint`, `override`).
### Гейт авто-раскладки по `confidence`: спека vs код
Аудит спек↔код (2026-07-03) нашёл расхождение в модели уверенности.
Спека `recognition` (унаследовано из `recognition.md`) утверждает, что
самооценка LLM `confidence`**вспомогательный сигнал, НЕ единственный гейт**:
при подтверждённом матче в базе + чистой структурной валидации + согласованности
сигналов авто-раскладка допускается. Код же (`internal/recognize/validate.go`,
`confidence < AutoThreshold`, дефолт 0.85) делает `confidence` **жёстким
блокирующим условием**: план с матчем и чистой валидацией, но `confidence` 0.5
уйдёт в review вопреки сценарию спеки. Нужно определиться, что правда: либо
признать порог `AutoThreshold` в спеке как легитимный гейт (скорее так — код его
осознанно ввёл конфигом), либо ослабить код. Заодно `AutoThreshold` как
конфигурируемый гейт спекой не описан.
Связано: `openspec/specs/recognition` (требование «Модель уверенности и решение
auto/review»),
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md),
пакет `recognize`.
### Привязка внешних субтитров к серии (сериалы)
Аудит спек↔код (2026-07-03): спека `recognition` требует «внешние субтитры SHALL
привязываться к соответствующему видео». Для **фильма** это работает — раскладка
именует субтитр по базе видеофайла. Для **сериала** связь субтитр→конкретная
серия не выражена: в `PlanFile` (`internal/recognize`) нет поля привязки, и нет
логики спаривания VobSub `.idx`+`.sub`. Нужно смоделировать привязку субтитра к
эпизоду (поле на `PlanFile` или роль с указанием `season`/`episode`) и спаривание
`.idx`+`.sub`, либо — если поддержку откладываем — сузить формулировку спеки до
реального поведения.
Связано: `openspec/specs/recognition` (требование «Роли файлов на краях»),
[jellyfin-layout.md](specs/jellyfin-layout.md) (имена субтитров), пакеты
`recognize`, `layout`.
### Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)
Иногда для очень редких фильмов скачивается не один видеофайл, а **полная копия
диска** — структура `VIDEO_TS/` (DVD: `VIDEO_TS.IFO`, `VTS_01_1.VOB`…) или
`BDMV/` (BluRay: `BDMV/STREAM/*.m2ts`, `index.bdmv`). Сейчас распознавание и
раскладка заточены под пофайловый разбор (один main-видеофайл фильма / серии
сериала), а тут «фильм» — это **каталог целиком**. Jellyfin такие раскладки
поддерживает (папка фильма с вложенным `VIDEO_TS`/`BDMV`), нам нужно: распознать,
что раздача — это образ диска (по наличию `VIDEO_TS`/`BDMV`), не пытаться
разбирать её по отдельным VOB/m2ts как серии, и разложить весь каталог диска
хардлинками в папку фильма Jellyfin (`Название (Год)/VIDEO_TS/…`). Крайний, но
реальный случай для редких изданий; частота низкая, поэтому в «Среднем».
Связано: [recognition.md](specs/recognition.md) (роли файлов, что игнорируем),
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка фильма, крайние
случаи), пакеты `recognize`, `layout`.
## Низкий
### Панель действий ревью вне htmx-свопа блока источника
При выборе источника одним кликом обновляется только блок источника
(`#source-block`) htmx-свопом, а нижняя панель действий (кнопка «Применить»,
завязанная на `HasLinks`) — вне блока и не обновляется до полной перезагрузки.
Практически не мешает (хардлинки только по явному «Применить»,
`Apply` без плана вернёт ошибку), но в краевом случае (источник с пустым
предпросмотром из-за коллизии) кнопка «Применить» может остаться/пропасть не
синхронно. Решение намечено в дизайне `review-unified-source-block`
(Risks/Trade-offs): обновлять панель `hx-swap-oob` из того же партиала.
Связано: `openspec/specs/review`, `openspec/specs/web-ui`, пакет `httpapi`.
### Мгновенные обновления через SSE
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто
@@ -336,6 +390,19 @@ qBittorrent, без исходящих запросов на пользоват
Связано: [architecture.md](specs/architecture.md) → «Транспорты»,
[review-ux.md](specs/review-ux.md), пакет `httpapi`.
### Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
По калибровке болей (2026-07-02,
[drafts/logical-title-model.md](drafts/logical-title-model.md) §6.3) —
**не боль**, из приоритета выпало. Сосуществование версий доступно уже
сейчас (Jellyfin multi-version, другой целевой путь), коллизия на тот же
путь штатно уходит в review. Явный replace (undo старого хардлинка → lay
нового → супересид владения путём) — отдельный change, если/когда станет
болью.
Связано: [«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
[jellyfin-layout.md](specs/jellyfin-layout.md) (never-overwrite, коллизия).
### Многоступенчатая верификация привязки _(идея)_
Несколько раз извлекать данные из раздачи и контекста разными промптами,
@@ -352,8 +419,7 @@ qBittorrent, без исходящих запросов на пользоват
Когда распознавание даёт несколько подходящих кандидатов в метабазе,
предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча
брать первый/лучший. Веб остаётся точкой точных правок (полный выбор
источника — см. [«Ревью: выбор источника
совпадения»](#ревью-выбор-источника-совпадения-и-предпросмотр)), бот —
источника уже реализован — см. [review-ux.md](specs/review-ux.md)), бот —
быстрый выбор из готового короткого списка.
Связано: [review-ux.md](specs/review-ux.md) (боты — быстрые действия, веб —
+3
View File
@@ -16,3 +16,6 @@
(Ansible+Vault), валидация на старте.
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`,
трансляция на внешней границе.
- [database.md](database.md) — БД и идентификаторы: TEXT ULID PK через
`internal/ident` (без AUTOINCREMENT), lowercase + нормализация на границах,
естественные ключи у деталей.
+8
View File
@@ -112,9 +112,17 @@ Ansible + Vault). Приложение просто читает TOML — отд
- диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1],
`llm.max_retries` ≥ 0;
- длительности парсятся (`llm.timeout`, `worker.poll_interval`, …);
- `general.timezone` — распознаваемая IANA-зона (валидируется
`time.LoadLocation`; zoneinfo встроен через `time/tzdata`, поэтому ошибка =
битое имя, а не отсутствие базы в окружении);
- включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`;
`jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled``token`.
**Таймзоны.** Хранение времени в БД и логи — всегда UTC. Зона **отображения** в
веб-UI задаётся `[general].timezone` (дефолт `UTC`); только она конфигурируема,
на хранение/сортировку/логи не влияет. Бизнес-логика оперирует временем с явным
TZ (см. [CLAUDE.md](../../CLAUDE.md)).
## Структура в коде
- Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая
+53
View File
@@ -0,0 +1,53 @@
# Конвенция: база данных и идентификаторы
Как мы устраиваем таблицы и ключи в SQLite. Актуальная схема —
[../specs/database.md](../specs/database.md); обоснование выбора ULID —
`openspec/changes/ulid-identity/design.md` (после архивации — в истории git).
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи. `INTEGER PRIMARY KEY
AUTOINCREMENT` в новых таблицах не используем.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL/логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален across таблиц — поиск по голому id находит
все записи сущности в логах.
- **Единственная точка генерации и разбора — `internal/ident`**:
`ident.NewID()` при создании (в Create-методах `store`), `ident.Parse()`
на входных границах. Никаких самодельных генераторов.
## Канонический вид — lowercase
- Генерим и храним id в **нижнем регистре**. Сравнение строк в SQLite
побайтовое, поэтому любой внешний id (URL, форма, callback-data)
ОБЯЗАТЕЛЬНО проходит `ident.Parse` до запроса к БД — он валидирует формат
и нормализует регистр (base32 ULID case-insensitive при декодировании).
- Синтаксически невалидный id трактуем как несуществующую сущность (404),
без похода в БД.
## Естественные и составные ключи — для деталей
- У таблиц-деталей/связей допустим естественный или составной ключ вместо
ULID, когда он есть по природе данных: `download_infohash` — PK
`(infohash, download_id)`, `override``UNIQUE(download_id, field)`.
Отдельный ULID там — мёртвый вес.
- Прочие генерируемые идентификаторы (например, `apply_batch_id`) — тоже
через `ident.NewID()`: единый формат, сортируемость, корреляция в логах.
## Прочее
- Enum-поля (`state`, `kind`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код (`internal/store`).
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, напр.
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT = хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение: `store.Now()` + `store.FormatTime`/
`ParseTime` (аналогично `ident.NewID` для id); `DEFAULT (datetime('now'))` на
колонках **не используется** (fail-loud при забытой вставке: `NOT NULL` без
дефолта). Зона хранения всегда UTC; таймзона отображения в UI — конфиг
`[general].timezone`.
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
id, backfill). При изменении структуры обновляем ER-схему
[../specs/database.md](../specs/database.md) в том же change.
+13 -8
View File
@@ -18,7 +18,7 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
```json
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"a1b2","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
```
## Сообщение
@@ -100,14 +100,16 @@ log.Info(fmt.Sprintf("download %s accepted as movie", id))
Если когда-нибудь поедем в несколько инстансов, добавим `service.version`
одной строкой при старте.
## Корреляция по download_id
## Корреляция по id сущности
Отдельный случайный `trace_id` не заводим — у загрузки уже есть стабильный
осмысленный ключ: `download_id` (и `infohash`), он лежит в SQLite.
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
осмысленные ключи: ULID-идентификаторы (`download_id`, `recognition_id`,
`batch_id`, см. [database.md](database.md)), они лежат в SQLite.
- Заводим scoped-логгер на загрузку и протаскиваем его через
`context.Context` сквозь асинхронные стадии (приём → скачивание →
распознавание → раскладка), чтобы ключ дописывался на каждую запись сам:
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
Для загрузки — scoped-логгер, протаскиваемый через `context.Context`
сквозь асинхронные стадии (приём → скачивание → распознавание →
раскладка), чтобы ключ дописывался на каждую запись сам:
```go
log := log.With("download_id", id, "infohash", ih)
@@ -115,7 +117,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
```
- Все записи одной загрузки собираются одним фильтром:
`jq 'select(.download_id=="a1b2")' app.jsonl`.
`jq 'select(.download_id=="01jz2k7f8q9r3s4t5v6w7x8y9z")' app.jsonl`.
- ULID глобально уникален across сущностей, поэтому штатно работает и
простой grep по голому id — он находит все упоминания сущности независимо
от имени поля: `grep 01jz2k7f8q9r3s4t5v6w7x8y9z app.jsonl`.
## Ошибки
+286
View File
@@ -0,0 +1,286 @@
# Черновик: идентичность загрузки и группировка тайтла (без сущности title)
> **Статус:** черновик-размышление (explore), не источник истины и не принятое
> решение. Начат 2026-07-01; **переработан 2026-07-02** после второго захода
> обсуждения. Когда/если решим делать — переезжает в OpenSpec change(и) и
> `docs/specs`/`docs/adr`.
>
> **Итог разбора:** отдельную сущность `title` **не вводим**. Все целевые
> сценарии решаются идентичностью загрузки (ULID + множество инфохэшей),
> **правилом сходимости папки** при раскладке и вычисляемой группировкой.
> Отвергнутые варианты и триггер пересмотра — в §7.
## 1. Зачем это
Сейчас домен идентифицирует загрузку **инфохэшем**, а целевые файлы принадлежат
**отдельной загрузке** по целевому пути. Этого хватает для базового потока, но
плохо ложится на то, что один логический тайтл (фильм/сериал) складывается из
**нескольких загрузок** во времени: сезоны, докачивание серий, перезаливы.
Калибровка по реальным болям (зафиксирована в обсуждении 2026-07-02):
- **боль:** второй сезон должен лечь в ту же папку сериала;
- **боль:** докачивание/перезалив серий (E01–10 вместо E01–05) — доложить
недостающее;
- **боль:** удалить тайтл целиком одним действием (включая опц. раздачи);
- **не боль:** апгрейд качества — из приоритета выпадает (коллизия по-прежнему
уходит в review, coexist через Jellyfin-версии доступен).
Связано с беклогом: «Идентичность загрузки: ULID + множество инфохэшей»,
«Проблема второго сезона», «Раздачи с докачиванием», «История переходов
загрузки», «Удаление средствами jellybit (path 2)».
## 2. Что уже есть (текущая модель)
```
download (INTEGER id PK, AUTOINCREMENT)
├─ source_type, source_ref, display_name, context
├─ infohash (nullable), idempotency_key (UNIQUE если NOT NULL)
├─ state, error_code/msg, source_miss_count, source_added_at
└─ created_at / updated_at
├─(1—N)→ recognition (is_current, media_type, title, year,
│ provider, provider_id, confidence, plan JSON, …)
│ └─(1—N)→ metadata_candidate (provider, provider_id, url, chosen)
├─(1—N)→ hint / override
└─(1—N)→ file_link (apply_batch_id, src_path, dst_path, kind, status)
```
Ключевые инварианты сегодня:
- **Идентичность загрузки = infohash** (`idempotency_key`), дедуп через
`FindActiveByInfohash`. Воркер сопоставляет по трём хешам (hash/v1/v2).
- **Владение целевым путём:** один `dst_path` — один владелец-`file_link`.
`SupersedeForeignLinks(downloadID, dstPaths)` при раскладке помечает
`status='superseded'` у ссылок **других** загрузок на те же пути
(last-writer-owns). Статусы: `linked|copied|exists|collision|superseded`.
- **Источник неприкосновенен**, **существующее не перезаписываем**
(`collision` → review), **откат снимает лишний хардлинк, а не последнюю
копию** (`nlink<=1` → отказ).
- **Сверка «источник × цель»** двигает рассинхрон в
`target_missing`/`orphaned`/`deleted`.
Владеют **путями**, а не «папкой сериала» — поэтому разные сезоны (разные пути)
уже сосуществуют без конфликтов, супересида между ними нет.
## 3. Что не решено сегодня
- **Сходимость папки.** Папка строится каждый раз заново из выхода
распознавания (`internal/layout/name.go`): `"Название (Год) [tmdbid-123]"`.
Совпадение `provider_id` **не гарантирует** совпадение строки папки: LLM
может дать «Fargo» и «Фарго», год сезона вместо года сериала — и второй
сезон уедет в соседнюю папку при верном матче. Это ядро «проблемы второго
сезона»: она **не про группировку, а про сходимость папки**.
- **Докачивание** — «просто новая загрузка», упирающаяся в коллизию цели →
review, без логики «доложить недостающее».
- **«Удалить сериал целиком»** — ручной сбор всех причастных загрузок.
- **Идентичность на infohash хрупкая** (v1/v2/гибрид, перезаливы) — см. §6.
## 4. Итог разбора: почему БЕЗ сущности title
Главный аргумент: **download — мост между раздачей в qBittorrent и набором
файлов на диске**, и каждая сущность цепочки отвечает на свои операции:
```
qBittorrent ──1:1── download ──владение──▶ файлы на диске
(раздача) (мост) (пути)
pause/cancel/retry FSM, ULID undo/relay, per-path
```
У `title` при разборе **не нашлось ни одной собственной операции**: сходимость
папки — правило при построении плана; merge докачивания — per-path логика;
удаление целиком — цикл по вычисляемой группе. Сущность без собственных
операций — это линза, а линзу достаточно вычислять, не хранить.
Второе: «папка — это title-уровневое состояние, ей нужен дом» (аргумент за
хранимый title) разбивается о то, что **дом у папки уже есть** — файловая
система и `dst_path` живых `file_link`'ов. Реестр дублировал бы то, что и так
записано в БД в N экземплярах. Причём вычисляемый якорь **корректнее**
хранимого: если все файлы сериала снесли, живых ссылок нет — и новая загрузка
честно создаёт свежую папку; хранимый `title.folder` указывал бы в пустоту.
Третье: отказ от сущности **устраняет** (а не решает) целый хвост развилок:
жизненный цикл тайтла (рождение/смерть/пустой тайтл), слияние тайтлов, ad-hoc
тайтл без провайдера, обратная миграция существующих строк, title-лог.
## 5. Целевая модель
Три элемента: стабильная идентичность загрузки, правило сходимости папки,
вычисляемая группировка. Плюс опциональная история переходов.
### 5.1 Идентичность: ULID + download_infohash
```
download download_infohash
id TEXT PK (ULID, генерим download_id FK→download
при приёме) infohash TEXT
…остальное как сейчас, kind v1|v2
минус idempotency_key UNIQUE(infohash) ← дедуп переезжает сюда
```
- `download.id` = ULID — публичный стабильный ключ домена; переживает
перезаливы, не завязан на хеш.
- `download_infohash` — множество хешей одной загрузки (v1/v2, в будущем —
«этот перезалив — та же загрузка»). Поиск при приёме и в поллинге — по
любому из хешей.
### 5.2 Правило сходимости папки
При построении плана раскладки для загрузки с **подтверждённым матчем**
`(provider, provider_id)`:
```
1. найти ЖИВЫЕ file_link'и (status IN linked|copied|exists) загрузок,
чей current recognition имеет тот же (provider, provider_id)
2. есть → база папки (имя+год) наследуется из существующего dst_path;
LLM-выход для папки игнорируется ← якорь
3. нет → папка из распознавания, как сейчас ← первая
загрузка «печатает» имя, остальные наследуют
```
- Это join по существующим таблицам (`file_link → download →
recognition(is_current)`), **ни одной новой сущности**.
- Правило локальное: download остаётся мостом, распознавание — недоверенным,
безопасность — на валидации пути (инварианты не трогаем).
- Человек/Jellyfin переименовал папку на диске → сверка переведёт ссылки в
`target_missing` → якорь исчезает → следующая загрузка печатает заново.
Истина — живые пути, отдельного «источника истины по папке» нет.
- Без подтверждённого матча авто-раскладки нет (инвариант) → раскладка идёт
через review, папку выбирает человек. Сходимость «без базы» не автоматизируем.
### 5.3 Вычисляемая группировка (тайтл как линза)
- «Из чего состоит сериал» = `GROUP BY (provider, provider_id)` текущих
распознаваний с живыми ссылками; эквивалентно — по общей папке в `dst_path`.
- «Удалить целиком» = перечислить загрузки группы → штатный undo каждой
(`superseded` пропускаем — путь у другого владельца; `nlink<=1` — отказ) →
опц. удалить раздачи из qBittorrent (осознанный выход за инвариант «источник
неприкосновенен», только по явному подтверждению) → опц. снести опустевшую
папку.
- На домашнем масштабе `GROUP BY` бесплатен; денормализации не нужны.
### 5.4 История переходов (опционально, дёшево)
```
state_transition (download_id, from_state, to_state, reason, actor, at)
actor ∈ {worker, human, reconcile}
```
Питает таймлайн на `/download/{id}` и метрики длительности стадий. Композиция
тайтла во времени («B долил Season 02») выводима из `download` + `file_link` +
`state_transition` — отдельный лог не нужен.
## 6. Разбор операций
### 6.1 Второй сезон
```
S1 ──lay──▶ …/Fargo (2014) [tvdbid-269613]/Season 01/… (владеет A)
S2: матч tvdb=269613 → живые ссылки A найдены → папка унаследована
S2 ──lay──▶ …/Fargo (2014) [tvdbid-269613]/Season 02/… (владеет B)
```
Пути не пересекаются → супересида нет, A не трогаем. Сходимость дало правило
§5.2, группировку — линза §5.3.
Принятая цена: если S1 заматчился через один провайдер, а S2 — через другой
(смена конфига метабаз), якорь по `(provider, provider_id)` не склеит — случай
редкий, штатно уходит в review.
### 6.2 Докачивание серий (merge)
```
существует: Season 01/E01..E05 (владеет A)
C приносит: Season 01/E01..E10 (та же папка — за счёт сходимости)
merge: E01..E05 — уже есть → не перезаписываем (владение у A)
E06..E10 — кладём (владеет C)
```
Целевая merge-логика: **доложить только недостающее**. Владение сезоном
делится между A и C по путям — нормально в per-path модели (split-ownership
принят как дефолт). Обе раздачи сидируют независимо.
### 6.3 Апгрейд качества — вне приоритета
Не боль. Коллизия на тот же `dst_path` по-прежнему → review; сосуществование
версий (Jellyfin multi-version, другой `dst`) доступно без спец-логики. Явный
replace (undo старого → lay нового → супересид) — отдельный change, если/когда
понадобится.
### 6.4 Удаление (частичное и целиком)
Частичное (одна загрузка/сезон) — уже штатный undo. Целиком — по группе §5.3.
Никакой «памяти о тайтле» после полного удаления не остаётся — и не должно
(линза без содержимого не нужна; «список того, что смотрел» — дрейф в
медиатеку, см. §7).
## 7. Отвергнутые варианты и триггер пересмотра
Разбирались и были отвергнуты (2026-07-02):
- **L2: `title` с ключом `(provider, provider_id)`** — привязывает
долгоживущую сущность к провайдеру, который может смениться.
- **L2-min: `title` со своим ULID + `title_external_id`** (провайдерные ID —
множество-атрибут, симметрично `download_infohash`). Красивая схема: решает
смену провайдера, ad-hoc тайтлы, слияние. Отвергнута потому, что у тайтла
**нет собственных операций** (§4) — все сценарии закрылись правилом
сходимости и вычисляемой группировкой, а сущность тянула жизненный цикл,
миграцию и четыре развилки.
- **L3 (title-центрично, медиатека)** — сонарр, осознанно не идём: не ходим в
индексеры, не мониторим тайтлы, не ведём профили качества, контент приносит
пользователь. См. таблицу ответственности в истории документа (git) либо
BRIEF.
**Триггер пересмотра** (чтобы не гонять этот круг заново): сущность `title`
возвращается в обсуждение, только когда появится **операция или состояние,
которому реально негде жить** в download+file_link — например, «переименовать
сериал целиком с переносом ссылок» как регулярное действие или заметки уровня
группы. До того — вычисляем.
## 8. Идентичность: ULID vs infohash (памятка)
infohash надёжен как ключ конкретной метадаты-раздачи в одном инстансе
qBittorrent, но: v1/v2/гибрид дают разные значения; перезалив/репак/докачка →
другой хеш; один логический объект → много хешей. Поэтому доменный PK — ULID,
а инфохэши — many-to-one атрибут (§5.1).
## 9. Этапность (не обязательство)
```
1. ULID загрузки + download_infohash (дедуп переезжает). ← фундамент
2. правило сходимости папки при плане раскладки. ← «второй сезон»
3. merge-раскладка (докачивание: доложить недостающее). ← §6.2
4. группа «тайтл» в UI (вычисляемая) + удаление целиком (path 2). ← §6.4
(state_transition — вставить, когда захочется таймлайн/метрики)
```
Каждый шаг — отдельный OpenSpec change; 1–2 самодостаточны и закрывают главную
боль.
## 10. Открытые вопросы (оставшиеся)
- **Несколько живых папок с одним `(provider, provider_id)`** (уже случившийся
рассинхрон до внедрения сходимости): какой якорь брать — самую свежую, самую
населённую, или отдавать в review? Скорее review: молча выбирать нехорошо.
- **Слияние загрузок при перезаливе «той же вещи»**: когда несколько инфохэшей
считать одной загрузкой (одна строка `download` + много `infohash`) vs
разными загрузками? Влияет на семантику `download_infohash` и merge §6.2.
- **Явный replace при апгрейде** — отложен целиком; вернуться, если станет
болью.
## 11. Мини-словарь (для согласованности имён)
- **Тайтл** — логический фильм/сериал; **вычисляемая группа** загрузок по
`(provider, provider_id)` / общей папке, не хранимая сущность.
- **Загрузка (download)** — один приём/раздача-вклад; свой ULID; несколько
инфохэшей; мост qBittorrent ↔ файлы.
- **Владение путём** — `file_link` отвечает за конкретный `dst_path`.
- **Супересид** — переход владения путём к более новой загрузке.
- **Сходимость папки** — наследование базы папки от живых ссылок с тем же
`(provider, provider_id)` вместо выхода LLM.
---
_Дальше по этому черновику: при желании — `opsx:propose` на шаг 1 (ULID +
download_infohash) как фундамент; шаг 2 (сходимость папки) — следующим
отдельным change._
+49 -28
View File
@@ -10,15 +10,23 @@
> документации.
>
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`.
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
> `DEFAULT` убран).
Назначение таблиц и почему так — [architecture.md](architecture.md) →
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
(`internal/ident`) — см. [конвенцию](../conventions/database.md). Метки времени
(`created_at`/`updated_at`) — TEXT в RFC 3339, UTC (суффикс `Z`); пишет
приложение (`store.Now`/`FormatTime`), без `DEFAULT` на колонках.
## ER-диаграмма
```mermaid
erDiagram
download ||--o{ download_infohash : "инфохэши (v1/v2)"
download ||--o{ recognition : "распознавания"
download ||--o{ hint : "подсказки"
download ||--o{ override : "ручные правки"
@@ -26,25 +34,30 @@ erDiagram
recognition ||--o{ metadata_candidate : "кандидаты базы"
download {
INTEGER id PK "AUTOINCREMENT"
TEXT id PK "ULID (lowercase), генерится приложением"
TEXT source_type "NOT NULL; magnet|torrent|url"
TEXT source_ref "NOT NULL; magnet/url/путь"
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
TEXT context "NOT NULL DEFAULT ''"
TEXT infohash "nullable; может появиться позже приёма"
TEXT idempotency_key "nullable; UNIQUE если NOT NULL"
TEXT state "NOT NULL; см. workflow.md"
TEXT state "NOT NULL; см. workflow.md; активность выводится только из state"
TEXT error_code "nullable"
TEXT error_msg "nullable"
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
TEXT updated_at "NOT NULL DEFAULT datetime('now')"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
download_infohash {
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)"
TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)"
TEXT kind "NOT NULL; v1|v2"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
recognition {
INTEGER id PK "AUTOINCREMENT"
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
INTEGER attempt_no "NOT NULL DEFAULT 1"
INTEGER is_current "NOT NULL DEFAULT 1; 0/1"
TEXT media_type "nullable; movie|series"
@@ -57,54 +70,61 @@ erDiagram
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
TEXT raw_llm "nullable; сырой ответ LLM"
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
hint {
INTEGER id PK "AUTOINCREMENT"
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT text "NOT NULL"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
override {
INTEGER id PK "AUTOINCREMENT"
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT field "NOT NULL; UNIQUE(download_id, field)"
TEXT value "NOT NULL"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
metadata_candidate {
INTEGER id PK "AUTOINCREMENT"
INTEGER recognition_id FK "NOT NULL; ON DELETE CASCADE"
TEXT id PK "ULID"
TEXT recognition_id FK "NOT NULL; ON DELETE CASCADE"
TEXT provider "NOT NULL"
TEXT provider_id "NOT NULL"
TEXT title "nullable"
INTEGER year "nullable"
TEXT url "nullable; ссылка на страницу на сайте провайдера"
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
file_link {
INTEGER id PK "AUTOINCREMENT"
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT apply_batch_id "NOT NULL; батч для точечного undo"
TEXT src_path "NOT NULL; исходный файл раздачи"
TEXT dst_path "NOT NULL; целевой хардлинк"
TEXT kind "NOT NULL; video|subtitle|..."
TEXT status "NOT NULL; linked|..."
TEXT created_at "NOT NULL DEFAULT datetime('now')"
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
```
## Связи и кардинальность
- `download` 1 — N `recognition` / `hint` / `override` / `file_link`;
`recognition` 1 — N `metadata_candidate`. Все дочерние — с
`ON DELETE CASCADE`: удаление загрузки уносит её распознавания, подсказки,
правки и ссылки.
- `download` 1 — N `download_infohash` / `recognition` / `hint` / `override`
/ `file_link`; `recognition` 1 — N `metadata_candidate`. Все дочерние — с
`ON DELETE CASCADE`: удаление загрузки уносит её хеши, распознавания,
подсказки, правки и ссылки.
- `download_infohash` — множество хешей одной загрузки (v1/v2 гибридного
торрента); один и тот же infohash может принадлежать нескольким загрузкам
во времени (повторный приём после терминального состояния). Инвариант «не
более одной активной загрузки на infohash» держат guarded-методы store
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
write-транзакции — на уровне схемы он не выражается (условие на `state`).
- `download``file_link` — один источник (раздача) ко многим разложенным
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
@@ -112,8 +132,9 @@ erDiagram
## Индексы и ограничения
- `download`: `UNIQUE(idempotency_key) WHERE idempotency_key IS NOT NULL`;
индекс по `state`.
- `download`: индекс по `state`.
- `download_infohash`: PK `(infohash, download_id)` (он же индекс поиска по
хешу); индекс по `download_id`.
- `recognition`: индекс по `download_id`.
- `override`: `UNIQUE(download_id, field)`.
- `metadata_candidate`: индекс по `recognition_id`.
+5
View File
@@ -1,5 +1,10 @@
# Конвенции раскладки Jellyfin
> **Источник истины переехал в OpenSpec** — `openspec/specs/file-layout/` (имена,
> хардлинки, коллизия, copy-fallback). Владение путём (`superseded`) и безопасный
> undo (`nlink<=1`) — в `openspec/specs/state-reconciliation/`. Этот файл —
> справочный нарратив; при расхождении верна спека OpenSpec.
Целевые имена и структура, в которые jellybit раскладывает файлы
хардлинками. Источники:
[Movies](https://jellyfin.org/docs/general/server/media/movies),
+5
View File
@@ -1,5 +1,10 @@
# Распознавание контента
> **Источник истины переехал в OpenSpec.** Актуальные требования —
> `openspec/specs/recognition/` (разбор сигналов LLM) и
> `openspec/specs/metadata-match/` (сверка с внешними базами). Этот файл остаётся
> справочным нарративом; при расхождении верна спека OpenSpec.
## Задача
По доступным сигналам определить: фильм или сериал; каноническое название
+19 -4
View File
@@ -1,5 +1,9 @@
# Ревью раскладки человеком
> **Источник истины переехал в OpenSpec** — `openspec/specs/review/`. Этот файл
> остаётся справочным нарративом (UI-макеты, разбор сценариев); при расхождении
> верна спека OpenSpec.
Что происходит, когда система не уверена в распознавании и не
раскладывает файлы автоматически. Когда именно наступает ревью — см.
[recognition.md](recognition.md); место состояния `review` в общем потоке —
@@ -49,7 +53,14 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
Контекст: «второй сезон, рус+англ дорожки» [+ добавить → 🔁 перераспознать]
Тип: ( ) фильм (•) сериал Название: Фарго Год: 2015 Сезон: 02
База: [TMDB поиск…] [TVDB поиск…] выбрано: — (без базы) [ввести id]
Источник совпадения (единый список — выбираем источник, а не режим):
(•) распознано нейронкой (без базы) [активен]
( ) tvdb Fargo · 2014 id 269613 [запись↗] [предпросмотр▸] [выбрать]
( ) tmdb Fargo id 60622 [запись↗] [предпросмотр▸] [выбрать]
+ добавить вручную: [tmdb▾] [id или URL записи] [Добавить]
предпросмотр▸ раскрывает поля (тип/название/год, место под режиссёра) и
целевые пути ЭТОГО источника — до выбора, ничего не меняя
Файлы → серии:
# | файл | размер | роль | S | E
@@ -141,9 +152,13 @@ Telegram = одобрить / подсказать / выбрать кандид
## Объём по версиям
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
заново», переключатель типа, выбор кандидата базы / ручной ввод id /
«без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
заново», переключатель типа, **единый список источников совпадения**
(нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу
нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по
URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого
источника до применения** (место под режиссёра зарезервировано), пометка
файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново».
В Telegram — подтверждение с reply-подсказкой
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
пинги о входе в review и готовности.
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
+7
View File
@@ -1,5 +1,12 @@
# Жизненный цикл загрузки и машина состояний
> **Источник истины переехал в OpenSpec.** Прямой путь FSM (downloading →
> completed → stuck/failed, поллинг, усыновление) — `openspec/specs/
> download-tracking/`; сверка с реальностью — `openspec/specs/
> state-reconciliation/`; уведомления — `openspec/specs/notifications/`. Этот
> файл — справочный нарратив по графу состояний; при расхождении верна спека
> OpenSpec.
Как загрузка проходит путь от приёма источника до разложенных файлов:
состояния, переходы и то, что их вызывает. Кто владеет переходами и общее
устройство — в [architecture.md](architecture.md); детали распознавания —
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/jmoiron/sqlx v1.4.0
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
github.com/oklog/ulid/v2 v2.1.1
github.com/pelletier/go-toml/v2 v2.2.3
github.com/pressly/goose/v3 v3.22.1
modernc.org/sqlite v1.34.1
+3
View File
@@ -30,6 +30,9 @@ github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+30
View File
@@ -17,6 +17,7 @@ const DefaultPath = "config.toml"
// Config — корневая конфигурация сервиса (см. config.example.toml).
type Config struct {
General General `toml:"general"`
QBittorrent QBittorrent `toml:"qbittorrent"`
Paths Paths `toml:"paths"`
Storage Storage `toml:"storage"`
@@ -30,6 +31,15 @@ type Config struct {
Log Log `toml:"log"`
}
// General — общие настройки приложения.
type General struct {
// Timezone — таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр.
// "Europe/Moscow"). Хранение всегда UTC; настройка влияет только на рендеринг.
// Пусто → UTC. База зон встроена (time/tzdata), поэтому имя валидируется
// одинаково на любом хосте (см. DisplayLocation).
Timezone string `toml:"timezone"`
}
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
type QBittorrent struct {
URL string `toml:"url"`
@@ -152,10 +162,26 @@ func (d *Duration) UnmarshalText(text []byte) error {
// Std возвращает обычный time.Duration.
func (d Duration) Std() time.Duration { return time.Duration(d) }
// DisplayLocation возвращает таймзону отображения времени в веб-UI (пусто → UTC).
// Ошибка — если имя зоны не распознано; валидируется на старте (validate).
// Зоны доступны на любом хосте: база zoneinfo встроена в бинарь (time/tzdata),
// поэтому ошибка означает именно битое имя, а не отсутствие zoneinfo.
func (c *Config) DisplayLocation() (*time.Location, error) {
if c.General.Timezone == "" {
return time.UTC, nil
}
loc, err := time.LoadLocation(c.General.Timezone)
if err != nil {
return nil, fmt.Errorf("general.timezone %q: %w", c.General.Timezone, err)
}
return loc, nil
}
// Default возвращает конфиг с разумными умолчаниями; значения из файла
// перекрывают их при загрузке.
func Default() *Config {
return &Config{
General: General{Timezone: "UTC"},
QBittorrent: QBittorrent{
URL: "http://qbit:8989",
Username: "admin",
@@ -229,6 +255,10 @@ func (c *Config) validate() error {
if c.LLM.Type != "openai-compat" {
errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type))
}
// Таймзона отображения: имя должно распознаваться (zoneinfo встроен).
if _, err := c.DisplayLocation(); err != nil {
errs = append(errs, err)
}
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
for _, p := range []struct{ name, path string }{
+20
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
// validCfg возвращает минимально валидный конфиг поверх Default() с медиа-путями
@@ -55,6 +56,24 @@ func TestValidate_KeylessLocalLLM(t *testing.T) {
}
}
// TestDisplayLocation — зона отображения: пусто → UTC, валидная зона грузится,
// zoneinfo встроен (time/tzdata) → доступна на любом хосте.
func TestDisplayLocation(t *testing.T) {
empty := &Config{}
if loc, err := empty.DisplayLocation(); err != nil || loc != time.UTC {
t.Fatalf("пустая зона → UTC, got %v, %v", loc, err)
}
c := &Config{General: General{Timezone: "Europe/Moscow"}}
loc, err := c.DisplayLocation()
if err != nil {
t.Fatalf("Europe/Moscow должна грузиться (tzdata встроен): %v", err)
}
if loc.String() != "Europe/Moscow" {
t.Fatalf("loc = %q, want Europe/Moscow", loc.String())
}
}
func TestValidate_Errors(t *testing.T) {
cases := []struct {
name string
@@ -74,6 +93,7 @@ func TestValidate_Errors(t *testing.T) {
{"jellyfin enabled no url", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "" }, "jellyfin.url"},
{"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"},
{"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"},
{"bad timezone", func(c *Config) { c.General.Timezone = "Mars/Phobos" }, "general.timezone"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+19 -7
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strconv"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
@@ -12,16 +13,17 @@ import (
// --- Страница просмотра одной загрузки ---
type downloadDetailView struct {
ID int64
ID string
Title string
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
Infohash string
InfohashShort string
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
Context string
State string
Error string
Note string
Added string // дата добавления (source_added_at → created_at), как в списке
AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить
CreatedAt string
UpdatedAt string
@@ -31,6 +33,7 @@ type downloadDetailView struct {
IsSeries bool
RecTitle string
OriginalTitle string
Season string // сводка сезонов для сериала (пусто для фильма)
Year int
Provider string
ProviderID string
@@ -65,7 +68,8 @@ func detailTitle(d store.Download, rd *worker.ReviewData) string {
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
// Невалидный id = несуществующая сущность; в БД не ходим.
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
@@ -85,8 +89,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
Title: detailTitle(d, rd),
SourceType: string(d.SourceType),
SourceFull: d.SourceRef,
Infohash: d.Infohash.String,
InfohashShort: shortenHash(d.Infohash.String),
Infohashes: d.HashList(),
Context: d.Context,
State: string(d.State),
Error: d.ErrorMsg.String,
@@ -100,6 +103,12 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
d.State == store.StateTargetMissing,
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
}
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
// как в порядке и карточках списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok {
view.Added = fmtDate(t, s.deps.Loc)
view.AddedAgo = humanizeAge(t, time.Now())
}
if rd.Recognition != nil {
view.HasPlan = len(rd.Plan.Files) > 0
@@ -107,6 +116,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
view.IsSeries = rd.Plan.Type == "series"
view.RecTitle = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle
if view.IsSeries {
view.Season = seasonSummary(rd.Plan)
}
view.Year = rd.Plan.Year
switch rd.Provider {
case "", "none":
@@ -126,7 +138,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
// Живая статистика раздачи — со значениями уже в первом кадре; секция
// деградирует (пустой контейнер), если торрент не сидирует/данных нет.
l, ok := s.deps.Live.Live(d.Infohash.String)
l, ok := s.liveFor(d)
view.Seeding = buildSeeding(id, l, ok)
s.render(w, "download.html", view)
+67
View File
@@ -1,6 +1,10 @@
package httpapi
import (
"sort"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
@@ -36,6 +40,69 @@ func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
return rows
}
// seasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по
// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому
// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры:
// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски».
func seasonSummary(plan recognize.Plan) string {
seen := map[int]bool{}
specials := false
for _, f := range plan.Files {
if f.Role != recognize.RoleEpisode {
continue
}
n := 0
if f.Season != nil {
n = *f.Season
}
if n <= 0 {
specials = true
continue
}
seen[n] = true
}
nums := make([]int, 0, len(seen))
for n := range seen {
nums = append(nums, n)
}
sort.Ints(nums)
var parts []string
switch {
case len(nums) == 1:
parts = append(parts, "Сезон "+strconv.Itoa(nums[0]))
case len(nums) > 1:
parts = append(parts, "Сезоны "+seasonRanges(nums))
}
if specials {
if len(parts) == 0 {
parts = append(parts, "Спецвыпуски")
} else {
parts = append(parts, "спецвыпуски")
}
}
return strings.Join(parts, ", ")
}
// seasonRanges схлопывает возрастающие номера сезонов в диапазоны:
// [1,2,3] → «13», [1,3,4] → «1, 34».
func seasonRanges(nums []int) string {
var out []string
for i := 0; i < len(nums); {
j := i
for j+1 < len(nums) && nums[j+1] == nums[j]+1 {
j++
}
if j == i {
out = append(out, strconv.Itoa(nums[i]))
} else {
out = append(out, strconv.Itoa(nums[i])+""+strconv.Itoa(nums[j]))
}
i = j + 1
}
return strings.Join(out, ", ")
}
// roleLabel — человекочитаемая роль файла раскладки.
func roleLabel(role string) string {
switch role {
+53
View File
@@ -0,0 +1,53 @@
package httpapi
import (
"testing"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
func TestSeasonSummary(t *testing.T) {
// ep — эпизодный файл с заданным (или nil) сезоном.
ep := func(season *int) recognize.PlanFile {
return recognize.PlanFile{Role: recognize.RoleEpisode, Season: season}
}
n := func(v int) *int { return &v }
cases := []struct {
name string
files []recognize.PlanFile
want string
}{
{"пусто", nil, ""},
{"один сезон", []recognize.PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"},
{"диапазон", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 13"},
{"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 34"},
{"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 13"},
{"только спецвыпуски (nil)", []recognize.PlanFile{ep(nil)}, "Спецвыпуски"},
{"только спецвыпуски (0)", []recognize.PlanFile{ep(n(0))}, "Спецвыпуски"},
{"сезоны и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"},
{"один сезон и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := seasonSummary(recognize.Plan{Files: c.files})
if got != c.want {
t.Errorf("seasonSummary = %q, want %q", got, c.want)
}
})
}
}
// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов.
func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) {
s := 2
plan := recognize.Plan{Files: []recognize.PlanFile{
{Role: recognize.RoleEpisode, Season: &s},
{Role: recognize.RoleMain},
{Role: recognize.RoleSubtitle},
{Role: recognize.RoleIgnore},
}}
if got := seasonSummary(plan); got != "Сезон 2" {
t.Errorf("seasonSummary = %q, want «Сезон 2»", got)
}
}
+142
View File
@@ -0,0 +1,142 @@
package httpapi
import (
"net/http"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// TestFmtDateZone — дата отображается в переданной таймзоне: полуночное UTC-время
// сдвигается на следующий день под Europe/Moscow (UTC+3), в UTC остаётся прежним.
func TestFmtDateZone(t *testing.T) {
// 2026-06-14 22:30 UTC == 2026-06-15 01:30 MSK.
ts := time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC)
if got := fmtDate(ts, time.UTC); got != "2026-06-14" {
t.Fatalf("UTC: got %q, want 2026-06-14", got)
}
msk, err := time.LoadLocation("Europe/Moscow")
if err != nil {
t.Fatalf("load Europe/Moscow (tzdata встроен): %v", err)
}
if got := fmtDate(ts, msk); got != "2026-06-15" {
t.Fatalf("MSK: got %q, want 2026-06-15", got)
}
}
func TestHumanizeAge(t *testing.T) {
now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC)
cases := []struct {
ago time.Duration
want string
}{
{30 * time.Second, "только что"},
{time.Minute, "1 минуту назад"},
{5 * time.Minute, "5 минут назад"},
{2 * time.Hour, "2 часа назад"},
{24 * time.Hour, "1 день назад"},
{5 * 24 * time.Hour, "5 дней назад"},
{40 * 24 * time.Hour, "1 месяц назад"},
{400 * 24 * time.Hour, "1 год назад"},
}
for _, c := range cases {
if got := humanizeAge(now.Add(-c.ago), now); got != c.want {
t.Errorf("humanizeAge(-%s) = %q, want %q", c.ago, got, c.want)
}
}
// Будущее (рассинхрон часов) не должно давать «-N»: схлопывается в «только что».
if got := humanizeAge(now.Add(time.Hour), now); got != "только что" {
t.Errorf("humanizeAge(future) = %q, want «только что»", got)
}
}
func TestPlural(t *testing.T) {
cases := []struct {
n int
want string
}{{1, "день"}, {2, "дня"}, {4, "дня"}, {5, "дней"}, {11, "дней"}, {14, "дней"}, {21, "день"}, {22, "дня"}, {25, "дней"}}
for _, c := range cases {
if got := plural(c.n, "день", "дня", "дней"); got != c.want {
t.Errorf("plural(%d) = %q, want %q", c.n, got, c.want)
}
}
}
func TestSizeAndRatioText(t *testing.T) {
// Снимок есть → размер из total_size, рейтинг из снимка.
l := worker.Live{TotalSize: 2 << 30, Ratio: 1.42}
if got := sizeText(l, true, 0); got != fmtBytes(2<<30) {
t.Errorf("size (снимок) = %q, want %q", got, fmtBytes(2<<30))
}
if got := ratioText(l, true); got != "1.42" {
t.Errorf("ratio (снимок) = %q, want 1.42", got)
}
// Снимка нет → размер из фолбэка по файлам, рейтинг «—».
if got := sizeText(worker.Live{}, false, 512); got != fmtBytes(512) {
t.Errorf("size (фолбэк) = %q, want %q", got, fmtBytes(512))
}
if got := ratioText(worker.Live{}, false); got != "—" {
t.Errorf("ratio (нет снимка) = %q, want «—»", got)
}
// Ни снимка, ни файлов → «—».
if got := sizeText(worker.Live{}, false, 0); got != "—" {
t.Errorf("size (нет данных) = %q, want «—»", got)
}
}
func TestAddedTimeFallback(t *testing.T) {
// source_added_at приоритетнее created_at.
added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
created := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
d := store.Download{
CreatedAt: store.FormatTime(created),
SourceAddedAt: store.NullString(store.FormatTime(added)),
}
got, ok := addedTime(d)
if !ok || !got.Equal(added) {
t.Errorf("addedTime = %v (ok=%v), want %v", got, ok, added)
}
// Без source_added_at — фолбэк на created_at.
d.SourceAddedAt = store.NullString("")
got, ok = addedTime(d)
if !ok || !got.Equal(created) {
t.Errorf("addedTime (фолбэк) = %v (ok=%v), want %v", got, ok, created)
}
// Нечего парсить — ok=false.
if _, ok := addedTime(store.Download{}); ok {
t.Error("addedTime пустой должен вернуть ok=false")
}
}
// TestIndexCardMeta: карточка списка несёт обзорную мета-строку (метка ID, дата
// добавления, размер и рейтинг из снимка) и НЕ показывает контекст.
func TestIndexCardMeta(t *testing.T) {
added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
dl := store.Download{
ID: testULID, SourceRef: "Dune", DisplayName: "Dune (2024)", Context: "секретный контекст",
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ihmeta", Kind: store.HashV1}},
State: store.StateDone,
SourceAddedAt: store.NullString(store.FormatTime(added)),
}
lv := stubLive{m: map[string]worker.Live{"ihmeta": {Seeding: true, TotalSize: 2 << 30, Ratio: 1.42}}}
h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv)
rr := get(t, h, "/")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{"ID:", "2026-06-30", "назад", "1.42", fmtBytes(2 << 30)} {
if !strings.Contains(body, want) {
t.Errorf("карточка не содержит %q", want)
}
}
// Контекст из карточки убран (доступен на /download/{id}).
if strings.Contains(body, "секретный контекст") {
t.Error("контекст всё ещё показан в карточке списка")
}
}
+99 -43
View File
@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -35,15 +36,18 @@ type Ingestor interface {
// Commander исполняет команды над задачей (worker.Worker).
type Commander interface {
Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
}
// Reader читает задачи (store.Store).
type Reader interface {
ListDownloads(ctx context.Context) ([]store.Download, error)
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
GetDownload(ctx context.Context, id int64) (*store.Download, error)
GetDownload(ctx context.Context, id string) (*store.Download, error)
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
}
// Deps — зависимости транспорта.
@@ -54,6 +58,8 @@ type Deps struct {
Reader Reader
Reviewer Reviewer
Live LiveStatus
// Loc — таймзона отображения дат в веб-UI (хранение всегда UTC). nil → UTC.
Loc *time.Location
}
type server struct {
@@ -85,6 +91,9 @@ func NewRouter(d Deps) (http.Handler, error) {
if d.Live == nil {
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
}
if d.Loc == nil {
d.Loc = time.UTC // таймзона отображения не задана — показываем в UTC
}
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
r := chi.NewRouter()
@@ -113,10 +122,10 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/apply", s.handleApply)
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
r.Post("/ui/downloads/{id}/type", s.handleSetType)
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/source", s.handleAddSource)
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
@@ -178,11 +187,8 @@ type pageLink struct {
}
type downloadView struct {
ID int64
ID string
Title string // отображаемый заголовок карточки
Infohash string // полный (для копирования)
InfohashShort string // усечённый (для показа)
Context string
State string
Error string
Terminal bool
@@ -193,6 +199,12 @@ type downloadView struct {
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Retriable bool // failed/stuck — можно повторить попытку
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
// Обзор жизненного цикла (мета-строка карточки).
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
AddedAgo string // относительная давность, «5 дней назад»
Size string // размер раздачи (снимок → фолбэк по файлам → «—»)
Ratio string // рейтинг отдачи (снимок → «—»)
}
// listChips — определения чипов фильтра списка (порядок = порядок показа).
@@ -264,12 +276,29 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
})
}
// Суммарный размер разложенных файлов по странице — фолбэк размера раздачи,
// когда торрента нет в живом снимке (один батч-запрос, не N+1).
ids := make([]string, len(downloads))
for i, d := range downloads {
ids[i] = d.ID
}
layoutSizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), ids)
if err != nil {
s.deps.Logger.Error("layout sizes", "error", err)
layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает
}
now := time.Now()
for _, d := range downloads {
v := toView(d)
v := s.toView(d, now)
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
l, ok := s.liveFor(d)
v.Ratio = ratioText(l, ok)
v.Size = sizeText(l, ok, layoutSizes[d.ID])
// Живой прогресс активных загрузок — со значениями уже в первом кадре
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
if v.IsDownloading {
l, ok := s.deps.Live.Live(d.Infohash.String)
v.Progress = buildProgress(d.ID, true, l, ok)
}
view.Downloads = append(view.Downloads, v)
@@ -394,9 +423,9 @@ func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
// --- REST API ---
type downloadDTO struct {
ID int64 `json:"id"`
ID string `json:"id"` // ULID (lowercase)
SourceType string `json:"source_type"`
Infohash string `json:"infohash,omitempty"`
Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2)
Context string `json:"context,omitempty"`
State string `json:"state"`
ErrorCode string `json:"error_code,omitempty"`
@@ -411,8 +440,8 @@ type addRequest struct {
}
type addResponse struct {
ID int64 `json:"id"`
Infohash string `json:"infohash"`
ID string `json:"id"` // ULID (lowercase)
Infohashes []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO
State string `json:"state"`
Deduplicated bool `json:"deduplicated"`
}
@@ -420,7 +449,7 @@ type addResponse struct {
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
downloads, err := s.deps.Reader.ListDownloads(r.Context())
if err != nil {
s.apiErr(w, r, err, 0)
s.apiErr(w, r, err, "")
return
}
out := make([]downloadDTO, 0, len(downloads))
@@ -433,7 +462,8 @@ func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
@@ -448,7 +478,7 @@ func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
var req addRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", 0))
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", ""))
return
}
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
@@ -465,7 +495,7 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, status, addResponse{
ID: res.DownloadID,
Infohash: res.Infohash,
Infohashes: res.Infohashes,
State: string(res.State),
Deduplicated: res.Deduplicated,
})
@@ -479,10 +509,11 @@ func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
s.apiCommand(w, r, s.deps.Commander.Retry)
}
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, string) error) {
id, err := pathID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
return
}
if err := cmd(r.Context(), id); err != nil {
@@ -494,7 +525,7 @@ func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(con
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusOK, map[string]int64{"id": id})
writeJSON(w, http.StatusOK, map[string]string{"id": id})
return
}
writeJSON(w, http.StatusOK, toDTO(*d))
@@ -506,7 +537,7 @@ func toDTO(d store.Download) downloadDTO {
return downloadDTO{
ID: d.ID,
SourceType: string(d.SourceType),
Infohash: d.Infohash.String,
Infohashes: d.HashList(),
Context: d.Context,
State: string(d.State),
ErrorCode: d.ErrorCode.String,
@@ -516,14 +547,11 @@ func toDTO(d store.Download) downloadDTO {
}
}
func toView(d store.Download) downloadView {
func (s *server) toView(d store.Download, now time.Time) downloadView {
state := string(d.State)
return downloadView{
v := downloadView{
ID: d.ID,
Title: downloadTitle(d),
Infohash: d.Infohash.String,
InfohashShort: shortenHash(d.Infohash.String),
Context: d.Context,
State: state,
Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(),
@@ -535,6 +563,13 @@ func toView(d store.Download) downloadView {
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Note: desyncNote(d.State),
}
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
// как в порядке списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok {
v.Added = fmtDate(t, s.deps.Loc)
v.AddedAgo = humanizeAge(t, now)
}
return v
}
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
@@ -557,12 +592,19 @@ func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
func shortenHash(h string) string {
if len(h) <= 12 {
return h
// addedTime — базис даты добавления карточки: время добавления в источник
// (source_added_at, qBittorrent added_on) с фолбэком на время создания загрузки
// (created_at), согласованно с порядком списка. ok=false — распарсить нечего.
func addedTime(d store.Download) (time.Time, bool) {
s := d.CreatedAt
if d.SourceAddedAt.Valid && d.SourceAddedAt.String != "" {
s = d.SourceAddedAt.String
}
return h[:10] + "…" + h[len(h)-2:]
t, err := store.ParseTime(s)
if err != nil {
return time.Time{}, false
}
return t, true
}
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
@@ -586,12 +628,21 @@ func shorten(s string, n int) string {
return s[:n] + "…"
}
func pathID(r *http.Request) (int64, error) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
return 0, errors.New("invalid id")
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
// любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id
// трактуется вызывающими как несуществующая сущность (404).
func pathID(r *http.Request) (string, error) {
return ident.Parse(chi.URLParam(r, "id"))
}
return id, nil
// liveFor достаёт живую телеметрию по любому из хешей загрузки.
func (s *server) liveFor(d store.Download) (worker.Live, bool) {
for _, h := range d.HashList() {
if l, ok := s.deps.Live.Live(h); ok {
return l, true
}
}
return worker.Live{}, false
}
func redirectErr(w http.ResponseWriter, r *http.Request, msg string) {
@@ -619,6 +670,11 @@ func classifyErr(err error) (int, string) {
case errors.Is(err, worker.ErrConflict):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
return http.StatusConflict, "действие недоступно в текущем состоянии"
case errors.Is(err, errManualSource):
// Ошибка ручного ввода источника — показываем пользователю как есть.
return http.StatusBadRequest, errManualSource.Error()
case errors.Is(err, errInvalidCandidate):
return http.StatusBadRequest, errInvalidCandidate.Error()
default:
return http.StatusInternalServerError, "внутренняя ошибка"
}
@@ -627,9 +683,9 @@ func classifyErr(err error) (int, string) {
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
// для владельца (download_id, если операция привязана к загрузке, иначе
// request_id запроса), по которому он найдёт полную ошибку в логах.
func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
func errBody(r *http.Request, msg string, downloadID string) map[string]any {
body := map[string]any{"error": msg}
if downloadID > 0 {
if downloadID != "" {
body["download_id"] = downloadID
} else {
body["request_id"] = middleware.GetReqID(r.Context())
@@ -638,7 +694,7 @@ func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
}
// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID int64) {
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID string) {
status, msg := classifyErr(err)
writeJSON(w, status, errBody(r, msg, downloadID))
}
@@ -646,10 +702,10 @@ func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downl
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
func userErr(r *http.Request, err error, downloadID int64) string {
func userErr(r *http.Request, err error, downloadID string) string {
_, msg := classifyErr(err)
if downloadID > 0 {
return fmt.Sprintf("%s (download_id=%d)", msg, downloadID)
if downloadID != "" {
return fmt.Sprintf("%s (download_id=%s)", msg, downloadID)
}
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
}
+247 -108
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
@@ -21,6 +22,14 @@ import (
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// Валидные lowercase-ULID для маршрутов (pathID валидирует формат).
const (
tid = "01arz3ndektsv4rrffq69g5fav"
tid2 = "01arz3ndektsv4rrffq69g5fb0"
cid = "01arz3ndektsv4rrffq69g5fc0"
cid2 = "01arz3ndektsv4rrffq69g5fd0"
)
type fakeIngestor struct {
res ingest.Result
err error
@@ -33,12 +42,12 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
}
type fakeCommander struct {
cancelled []int64
retried []int64
cancelled []string
retried []string
err error
}
func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
func (f *fakeCommander) Cancel(_ context.Context, id string) error {
if f.err != nil {
return f.err
}
@@ -46,7 +55,7 @@ func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
return nil
}
func (f *fakeCommander) Retry(_ context.Context, id int64) error {
func (f *fakeCommander) Retry(_ context.Context, id string) error {
if f.err != nil {
return f.err
}
@@ -76,12 +85,15 @@ func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter)
return f.list, total, nil
}
func (f *fakeReader) GetDownload(_ context.Context, id int64) (*store.Download, error) {
func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
if f.get != nil {
return f.get, nil
}
return &store.Download{ID: id, State: store.StateCancelled}, nil
}
func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) {
return nil, nil
}
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
t.Helper()
@@ -98,7 +110,7 @@ func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
}
func TestAPIAdd(t *testing.T) {
ing := &fakeIngestor{res: ingest.Result{DownloadID: 1, Infohash: "abc", State: store.StateDownloading}}
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, Infohashes: []string{"abc"}, State: store.StateDownloading}}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
@@ -112,7 +124,7 @@ func TestAPIAdd(t *testing.T) {
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if got["id"].(float64) != 1 || got["state"] != "downloading" {
if got["id"] != tid || got["state"] != "downloading" {
t.Errorf("body = %v", got)
}
if ing.lastReq.Context != "Дюна" {
@@ -138,8 +150,9 @@ func TestAPIAddBadInput(t *testing.T) {
func TestAPIList(t *testing.T) {
reader := &fakeReader{list: []store.Download{
{ID: 2, SourceType: store.SourceMagnet, State: store.StateCompleted, Infohash: store.NullString("abc")},
{ID: 1, SourceType: store.SourceMagnet, State: store.StateDownloading},
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
Infohashes: []store.Infohash{{DownloadID: tid2, Infohash: "abc", Kind: store.HashV1}}},
{ID: tid, SourceType: store.SourceMagnet, State: store.StateDownloading},
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
@@ -153,7 +166,8 @@ func TestAPIList(t *testing.T) {
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0]["state"] != "completed" || got[0]["infohash"] != "abc" {
hashes, _ := got[0]["infohashes"].([]any)
if got[0]["state"] != "completed" || len(hashes) != 1 || hashes[0] != "abc" {
t.Errorf("first = %v", got[0])
}
}
@@ -162,7 +176,7 @@ func TestAPICancel(t *testing.T) {
cmd := &fakeCommander{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
@@ -170,7 +184,7 @@ func TestAPICancel(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != 5 {
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != tid {
t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
}
}
@@ -179,7 +193,7 @@ func TestUIRetry(t *testing.T) {
cmd := &fakeCommander{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/ui/downloads/5/retry", "application/x-www-form-urlencoded", nil)
resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/retry", "application/x-www-form-urlencoded", nil)
if err != nil {
t.Fatal(err)
}
@@ -187,7 +201,7 @@ func TestUIRetry(t *testing.T) {
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if len(cmd.retried) != 1 || cmd.retried[0] != 5 {
if len(cmd.retried) != 1 || cmd.retried[0] != tid {
t.Errorf("retry вызван неверно: %v", cmd.retried)
}
}
@@ -197,7 +211,7 @@ func TestAPICommandConflict(t *testing.T) {
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
@@ -207,14 +221,14 @@ func TestAPICommandConflict(t *testing.T) {
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if got["download_id"].(float64) != 5 {
if got["download_id"] != tid {
t.Errorf("download_id корреляции нет: %v", got)
}
}
func TestIndexRenders(t *testing.T) {
reader := &fakeReader{list: []store.Download{
{ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
{ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
@@ -239,7 +253,7 @@ func (e ingestErr) Error() string { return string(e) }
func TestIndexTitleFromDisplayName(t *testing.T) {
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
reader := &fakeReader{list: []store.Download{{
ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading,
}}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
@@ -301,11 +315,11 @@ func TestIndexPageClamp(t *testing.T) {
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
rd := &worker.ReviewData{
Download: store.Download{
ID: 1, State: store.StateReview, SourceType: store.SourceMagnet,
ID: tid, State: store.StateReview, SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна",
Infohash: store.NullString("deadbeef"),
Infohashes: []store.Infohash{{DownloadID: tid, Infohash: "deadbeef", Kind: store.HashV1}},
},
Recognition: &store.Recognition{ID: 1, DownloadID: 1, IsCurrent: true},
Recognition: &store.Recognition{ID: "1", DownloadID: tid, IsCurrent: true},
Plan: recognize.Plan{
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
@@ -316,7 +330,7 @@ func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.
}
if chosenID != "" {
rd.Candidates = []store.MetadataCandidate{{
ID: 10, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
ID: cid, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
URL: store.NullString(chosenURL),
}}
}
@@ -329,7 +343,7 @@ func TestDownloadMatchLinkFromCandidate(t *testing.T) {
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/1")
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
@@ -347,7 +361,7 @@ func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/1")
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
@@ -366,7 +380,7 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/1")
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
@@ -384,105 +398,117 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
type fakeReviewer struct {
data *worker.ReviewData
applyErr error
refined map[int64]string
typed map[int64]string
ignored map[int64]string
chosen map[int64]int64
providerSet map[int64]string
applied []int64
deferred []int64
undone []int64
relinked []int64
rerecognized []int64
cleared []int64
refined map[string]string
ignored map[string]string
chosen map[string]string
providerSet map[string]string
sourceAdded map[string]string
applied []string
deferred []string
undone []string
relinked []string
rerecognized []string
cleared []string
}
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) {
return f.data, nil
}
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
if f.applyErr != nil {
return f.applyErr
}
f.applied = append(f.applied, id)
return nil
}
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
if f.refined == nil {
f.refined = map[int64]string{}
f.refined = map[string]string{}
}
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
if f.typed == nil {
f.typed = map[int64]string{}
}
f.typed[id] = t
return nil
}
func (f *fakeReviewer) IgnoreFile(_ context.Context, id int64, src string) error {
func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error {
if f.ignored == nil {
f.ignored = map[int64]string{}
f.ignored = map[string]string{}
}
f.ignored[id] = src
return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
f.deferred = append(f.deferred, id)
return nil
}
func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
func (f *fakeReviewer) Undo(_ context.Context, id string) error {
f.undone = append(f.undone, id)
return nil
}
func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
f.relinked = append(f.relinked, id)
return nil
}
func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error {
func (f *fakeReviewer) Rerecognize(_ context.Context, id string) error {
f.rerecognized = append(f.rerecognized, id)
return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
if f.chosen == nil {
f.chosen = map[int64]int64{}
f.chosen = map[string]string{}
}
f.chosen[id] = candidateID
return nil
}
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
func (f *fakeReviewer) SetProviderID(_ context.Context, id string, provider, providerID string) error {
if f.providerSet == nil {
f.providerSet = map[int64]string{}
f.providerSet = map[string]string{}
}
f.providerSet[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
func (f *fakeReviewer) AddManualSource(_ context.Context, id, provider, providerID string) error {
if f.sourceAdded == nil {
f.sourceAdded = map[string]string{}
}
f.sourceAdded[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
f.cleared = append(f.cleared, id)
return nil
}
func seriesReviewData() *worker.ReviewData {
s, e := 2, 1
return &worker.ReviewData{
Download: store.Download{ID: 1, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
Recognition: &store.Recognition{
ID: 1, DownloadID: 1, IsCurrent: true, Reasons: `["нет матча в базе"]`,
},
Plan: recognize.Plan{
plan := recognize.Plan{
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
Files: []recognize.PlanFile{
{Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e},
},
},
Preview: []layout.Link{
}
preview := []layout.Link{
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
}
return &worker.ReviewData{
Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
Recognition: &store.Recognition{
ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["нет матча в базе"]`,
},
Plan: plan,
Preview: preview,
Candidates: []store.MetadataCandidate{
{ID: 10, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
{ID: cid, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
Year: sql.NullInt64{Int64: 2014, Valid: true}},
{ID: 11, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
{ID: cid2, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
},
// Единый список источников: нейронка активна (матча нет) + кандидаты.
Sources: []worker.SourceOption{
{Kind: worker.SourceNeural, Provider: "none", Title: "Фарго", Year: 2015,
Type: "series", Active: true, Plan: plan, Preview: preview},
{Kind: worker.SourceCandidate, CandidateID: cid, Provider: "tvdb", ProviderID: "269613",
URL: "https://www.thetvdb.com/dereferrer/series/269613", Title: "Fargo", Year: 2014,
Type: "series", Plan: plan, Preview: preview},
{Kind: worker.SourceCandidate, CandidateID: cid2, Provider: "tmdb", ProviderID: "60622",
Title: "Fargo", Type: "series", Plan: plan, Preview: preview},
},
Hints: []string{"второй сезон"},
}
@@ -500,7 +526,7 @@ func TestReviewRenders(t *testing.T) {
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/review/1")
resp, err := http.Get(srv.URL + "/review/" + tid)
if err != nil {
t.Fatal(err)
}
@@ -511,25 +537,31 @@ func TestReviewRenders(t *testing.T) {
}
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
"Season 02", "Применить", "Уточнить",
"Источник совпадения", "269613", "выбрать", "Без базы"} {
"Источник и раскладка", "269613", "распознано нейронкой", "Добавить",
"Сезон 2"} {
if !strings.Contains(string(body), want) {
t.Errorf("страница ревью не содержит %q", want)
}
}
// Кнопки «выбрать» больше нет — выбор одним кликом по радио.
if strings.Contains(string(body), ">выбрать<") {
t.Error("страница ревью всё ещё содержит кнопку «выбрать»")
}
}
func TestReviewShowsMatchLink(t *testing.T) {
// Подтверждённый матч в блоке «Источник совпадения» — ссылкой на запись.
// Подтверждённый матч в списке источников — ссылкой на запись.
rd := seriesReviewData()
rd.Provider = "tvdb"
rd.ProviderID = "269613"
rd.Candidates[0].Chosen = true // tvdb 269613 в seriesReviewData
rd.Candidates[0].URL = store.NullString("https://www.thetvdb.com/dereferrer/series/269613")
// Активен кандидат tvdb (индекс 1: нейронка, tvdb, tmdb).
rd.Sources[0].Active = false
rd.Sources[1].Active = true
rv := &fakeReviewer{data: rd}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/review/1")
resp, err := http.Get(srv.URL + "/review/" + tid)
if err != nil {
t.Fatal(err)
}
@@ -545,16 +577,16 @@ func TestChooseCandidate(t *testing.T) {
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/candidate",
map[string][]string{"candidate_id": {"10"}})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/candidate",
map[string][]string{"candidate_id": {cid}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if rv.chosen[1] != 10 {
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
if rv.chosen[tid] != cid {
t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
t.Errorf("Location = %q", loc)
}
}
@@ -565,28 +597,108 @@ func TestSetProviderAndNoBase(t *testing.T) {
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/provider",
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/provider",
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
t.Fatal(err)
}
if rv.providerSet[1] != "tvdb:269613" {
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
if rv.providerSet[tid] != "tvdb:269613" {
t.Errorf("SetProviderID получил %q", rv.providerSet[tid])
}
if _, err := cl.Post(srv.URL+"/ui/downloads/1/nobase", "", nil); err != nil {
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/nobase", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.cleared) != 1 || rv.cleared[0] != 1 {
if len(rv.cleared) != 1 || rv.cleared[0] != tid {
t.Errorf("ClearProvider = %v", rv.cleared)
}
}
func TestAddManualSource(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
// URL записи → провайдер+id разбираются на границе, воркер получает их.
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/source",
map[string][]string{"provider": {"tmdb"}, "provider_id": {"https://www.themoviedb.org/tv/60622-fargo"}}); err != nil {
t.Fatal(err)
}
if rv.sourceAdded[tid] != "tmdb:60622" {
t.Errorf("AddManualSource получил %q, want tmdb:60622", rv.sourceAdded[tid])
}
}
func TestAddManualSource_RejectsBadURL(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
// URL TVDB со slug (без числового id) не разбирается → отклонён, источник
// не добавлен, пользователю уходит сообщение (не «внутренняя ошибка»).
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/source",
map[string][]string{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if _, called := rv.sourceAdded[tid]; called {
t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, "/review/"+tid) || !strings.Contains(loc, "err=") {
t.Errorf("Location = %q, want редирект на ревью с ?err=", loc)
}
if strings.Contains(loc, "%D0%B2%D0%BD%D1%83%D1%82%D1%80%D0%B5%D0%BD") { // «внутрен…»
t.Errorf("ошибка ввода подана как внутренняя: %q", loc)
}
}
// TestAddManualSource_HTMXError: невалидный ручной ввод на htmx-пути возвращает
// партиал блока с ошибкой (не редирект, не «внутренняя ошибка»), источник не
// добавлен.
func TestAddManualSource_HTMXError(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
form := url.Values{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}}
req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/source",
strings.NewReader(form.Encode()))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err := noRedirectClient().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (партиал с ошибкой)", resp.StatusCode)
}
if _, called := rv.sourceAdded[tid]; called {
t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
}
if !strings.Contains(string(body), `id="source-block"`) {
t.Error("htmx-ответ не содержит блок источника")
}
if !strings.Contains(string(body), "block-error") {
t.Error("htmx-ответ не содержит баннер ошибки блока")
}
if strings.Contains(string(body), "внутренняя ошибка") {
t.Error("ошибка ввода подана как внутренняя")
}
}
func TestApplyRedirectsToIndex(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil)
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil)
if err != nil {
t.Fatal(err)
}
@@ -607,13 +719,13 @@ func TestApplyCollisionRedirectsToReview(t *testing.T) {
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil)
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
t.Errorf("Location = %q, want /review/1?err=...", loc)
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
t.Errorf("Location = %q, want /review/{id}?err=...", loc)
}
}
@@ -622,40 +734,67 @@ func TestRefinePostsHint(t *testing.T) {
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/refine",
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refine",
map[string][]string{"hint": {"это второй сезон"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if rv.refined[1] != "это второй сезон" {
t.Errorf("Refine получил %q", rv.refined[1])
if rv.refined[tid] != "это второй сезон" {
t.Errorf("Refine получил %q", rv.refined[tid])
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
t.Errorf("Location = %q", loc)
}
}
func TestIgnoreAndType(t *testing.T) {
func TestIgnoreFile(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/ignore",
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/ignore",
map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil {
t.Fatal(err)
}
if rv.ignored[1] != "Fargo/sample.mkv" {
t.Errorf("IgnoreFile получил %q", rv.ignored[1])
if rv.ignored[tid] != "Fargo/sample.mkv" {
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
}
}
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/type",
map[string][]string{"type": {"movie"}}); err != nil {
// TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока
// источника (а не полную страницу и не редирект), обновлённый под выбор.
func TestChooseCandidateHTMX(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/candidate",
strings.NewReader("candidate_id="+cid))
if err != nil {
t.Fatal(err)
}
if rv.typed[1] != "movie" {
t.Errorf("SetType получил %q", rv.typed[1])
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err := noRedirectClient().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (партиал, не редирект)", resp.StatusCode)
}
if rv.chosen[tid] != cid {
t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
}
// Ответ — партиал блока, а не полная страница.
if strings.Contains(string(body), "<!doctype html>") {
t.Error("htmx-ответ должен быть партиалом, а не полной страницей")
}
if !strings.Contains(string(body), `id="source-block"`) {
t.Error("htmx-ответ не содержит корневой контейнер блока #source-block")
}
}
@@ -665,10 +804,10 @@ func TestUndoAndDefer(t *testing.T) {
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.Post(srv.URL+"/ui/downloads/1/undo", "", nil); err != nil {
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/undo", "", nil); err != nil {
t.Fatal(err)
}
if _, err := cl.Post(srv.URL+"/ui/downloads/1/defer", "", nil); err != nil {
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/defer", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.undone) != 1 || len(rv.deferred) != 1 {
@@ -682,11 +821,11 @@ func TestRelink(t *testing.T) {
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.Post(srv.URL+"/ui/downloads/1/relink", "", nil); err != nil {
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/relink", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.relinked) != 1 || rv.relinked[0] != 1 {
t.Errorf("relinked = %v, want [1]", rv.relinked)
if len(rv.relinked) != 1 || rv.relinked[0] != tid {
t.Errorf("relinked = %v, want [%s]", rv.relinked, tid)
}
}
@@ -696,10 +835,10 @@ func TestRerecognize(t *testing.T) {
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil {
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/rerecognize", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != tid {
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
}
}
+83 -9
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
@@ -27,7 +28,7 @@ func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
// /progress). Active управляется store-состоянием (downloading), а не qbt:
// когда задача покидает downloading, фрагмент возвращается без поллинга.
type progressView struct {
ID int64
ID string
Active bool // store-состояние downloading → показываем бар и поллим
Has bool // есть данные снимка
Percent int
@@ -39,7 +40,7 @@ type progressView struct {
// Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер, поллинг прекращается).
type seedingView struct {
ID int64
ID string
Has bool
Percent int
Ratio string
@@ -49,7 +50,7 @@ type seedingView struct {
UpSpeed string
}
func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
func buildProgress(id string, active bool, l worker.Live, ok bool) progressView {
v := progressView{ID: id, Active: active}
if ok {
v.Has = true
@@ -64,7 +65,7 @@ func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
return v
}
func buildSeeding(id int64, l worker.Live, ok bool) seedingView {
func buildSeeding(id string, l worker.Live, ok bool) seedingView {
v := seedingView{ID: id}
if ok && l.Seeding {
v.Has = true
@@ -82,7 +83,7 @@ func buildSeeding(id int64, l worker.Live, ok bool) seedingView {
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
http.Error(w, "не найдено", http.StatusNotFound)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
@@ -91,7 +92,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
return
}
active := d.State == store.StateDownloading
l, ok := s.deps.Live.Live(d.Infohash.String)
l, ok := s.liveFor(*d)
s.render(w, "progress", buildProgress(id, active, l, ok))
}
@@ -99,7 +100,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
http.Error(w, "не найдено", http.StatusNotFound)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
@@ -107,13 +108,13 @@ func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
s.fragErr(w, err, id)
return
}
l, ok := s.deps.Live.Live(d.Infohash.String)
l, ok := s.liveFor(*d)
s.render(w, "seeding", buildSeeding(id, l, ok))
}
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
func (s *server) fragErr(w http.ResponseWriter, err error, id int64) {
func (s *server) fragErr(w http.ResponseWriter, err error, id string) {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "не найдено", http.StatusNotFound)
return
@@ -184,3 +185,76 @@ func fmtRatio(r float64) string {
}
return fmt.Sprintf("%.2f", r)
}
// fmtDate — абсолютная дата добавления для карточки в таймзоне отображения
// (general.timezone; хранение всегда UTC). loc не бывает nil — NewRouter
// подставляет UTC по умолчанию.
func fmtDate(t time.Time, loc *time.Location) string {
return t.In(loc).Format("2006-01-02")
}
// humanizeAge — относительная давность («5 дней назад») от now до t. Будущее
// (рассинхрон часов) схлопывается в «только что». Единицы огрубляются к
// минутам/часам/дням/месяцам/годам — для обзора возраста этого достаточно.
func humanizeAge(t, now time.Time) string {
d := now.Sub(t)
if d < time.Minute {
return "только что"
}
switch {
case d < time.Hour:
n := int(d / time.Minute)
return fmt.Sprintf("%d %s назад", n, plural(n, "минуту", "минуты", "минут"))
case d < 24*time.Hour:
n := int(d / time.Hour)
return fmt.Sprintf("%d %s назад", n, plural(n, "час", "часа", "часов"))
case d < 30*24*time.Hour:
n := int(d / (24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "день", "дня", "дней"))
case d < 365*24*time.Hour:
n := int(d / (30 * 24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "месяц", "месяца", "месяцев"))
default:
n := int(d / (365 * 24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "год", "года", "лет"))
}
}
// plural выбирает русскую форму по числу (1 файл / 2 файла / 5 файлов).
func plural(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if m := n % 100; m >= 11 && m <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// sizeText — размер раздачи для карточки: живой общий размер из снимка, иначе
// суммарный размер разложенных файлов (фолбэк для orphaned), иначе «—».
func sizeText(l worker.Live, ok bool, layoutSize int64) string {
if ok && l.TotalSize > 0 {
return fmtBytes(l.TotalSize)
}
if layoutSize > 0 {
return fmtBytes(layoutSize)
}
return "—"
}
// ratioText — рейтинг отдачи для карточки: из живого снимка, иначе «—» (торрента
// нет в qBittorrent).
func ratioText(l worker.Live, ok bool) string {
if !ok {
return "—"
}
return fmtRatio(l.Ratio)
}
+16 -12
View File
@@ -12,16 +12,16 @@ import (
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом,
// значениями снимка и атрибутами htmx-поллинга.
func TestFragProgressDownloading(t *testing.T) {
dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDownloading}
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading}
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
rr := get(t, h, "/fragments/downloads/5/progress")
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/5/progress", "width:42%", "42%"} {
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/" + testULID + "/progress", "width:42%", "42%"} {
if !strings.Contains(body, want) {
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
}
@@ -31,10 +31,10 @@ func TestFragProgressDownloading(t *testing.T) {
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading,
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается).
func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDone}
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
rr := get(t, h, "/fragments/downloads/5/progress")
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
@@ -46,13 +46,13 @@ func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
// TestFragSeeding: сидирующая задача → секция «Раздача» со статистикой и
// поллингом.
func TestFragSeeding(t *testing.T) {
dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone}
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih9", Kind: store.HashV1}}, State: store.StateDone}
lv := stubLive{m: map[string]worker.Live{"ih9": {
Seeding: true, Progress: 1, Ratio: 2.41, Seeds: 38, Peers: 14, Uploaded: 1 << 30, UpSpeed: 1153433,
}}}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
rr := get(t, h, "/fragments/downloads/9/seeding")
rr := get(t, h, "/fragments/downloads/"+testULID+"/seeding")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
@@ -66,10 +66,10 @@ func TestFragSeeding(t *testing.T) {
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
func TestFragSeedingDegrades(t *testing.T) {
dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone}
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih9", Kind: store.HashV1}}, State: store.StateDone}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
rr := get(t, h, "/fragments/downloads/9/seeding")
rr := get(t, h, "/fragments/downloads/"+testULID+"/seeding")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
@@ -82,7 +82,7 @@ func TestFragSeedingDegrades(t *testing.T) {
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
// в первом кадре (значения снимка) и атрибуты поллинга.
func TestIndexCardShowsLiveProgress(t *testing.T) {
dl := store.Download{ID: 3, SourceRef: "The.Bear.S03", Infohash: store.NullString("ih3"), State: store.StateDownloading}
dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading}
lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}}
h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv)
@@ -91,7 +91,7 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/3/progress"} {
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/progress"} {
if !strings.Contains(body, want) {
t.Errorf("карточка без живого прогресса: нет %q", want)
}
@@ -101,7 +101,11 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
// TestFragNotFound: фрагмент несуществующей задачи → 404.
func TestFragNotFound(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
if rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/progress"); rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
// Невалидный id → 404 без похода в БД.
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
t.Fatalf("status(invalid id) = %d, want 404", rr.Code)
}
}
+39
View File
@@ -32,6 +32,45 @@ func TestProviderURL(t *testing.T) {
}
}
func TestParseManualSource(t *testing.T) {
cases := []struct {
name string
provider string
raw string
wantProvider string
wantID string
wantErr bool
}{
{"id с провайдером", "tmdb", "60622", "tmdb", "60622", false},
{"id обрезается позже воркером", "TVDB", "269613", "tvdb", "269613", false},
{"URL TMDB со slug", "", "https://www.themoviedb.org/tv/60622-fargo", "tmdb", "60622", false},
{"URL TMDB без схемы", "", "themoviedb.org/tv/60622", "tmdb", "60622", false},
{"URL TMDB movie", "", "https://www.themoviedb.org/movie/693134", "tmdb", "693134", false},
{"URL IMDb", "", "https://www.imdb.com/title/tt0111161/", "imdb", "tt0111161", false},
{"URL TVDB dereferrer", "", "https://www.thetvdb.com/dereferrer/series/269613", "tvdb", "269613", false},
{"URL TVDB slug — без id → ошибка", "", "https://www.thetvdb.com/series/fargo", "", "", true},
{"мусорный URL → ошибка", "", "https://example.com/foo/bar", "", "", true},
{"пустой ввод → ошибка", "tmdb", " ", "", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p, id, err := parseManualSource(c.provider, c.raw)
if c.wantErr {
if err == nil {
t.Fatalf("ожидалась ошибка, получено %q:%q", p, id)
}
return
}
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if p != c.wantProvider || id != c.wantID {
t.Errorf("parseManualSource(%q,%q) = %q:%q, want %q:%q", c.provider, c.raw, p, id, c.wantProvider, c.wantID)
}
})
}
}
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
// матч текстом — сценарий «URL записи неизвестен»).
+50 -18
View File
@@ -17,39 +17,46 @@ import (
type stubReader struct {
list []store.Download
one *store.Download
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
}
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
return s.list, len(s.list), nil
}
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) {
if s.one == nil {
return nil, store.ErrNotFound
}
return s.one, nil
}
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
return s.sizes, nil
}
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
type stubReviewer struct{ data *worker.ReviewData }
func (s stubReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
func (s stubReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
if s.data == nil {
return nil, store.ErrNotFound
}
return s.data, nil
}
func (stubReviewer) Apply(context.Context, int64) error { return nil }
func (stubReviewer) Refine(context.Context, int64, string) error { return nil }
func (stubReviewer) SetType(context.Context, int64, string) error { return nil }
func (stubReviewer) IgnoreFile(context.Context, int64, string) error { return nil }
func (stubReviewer) Defer(context.Context, int64) error { return nil }
func (stubReviewer) Undo(context.Context, int64) error { return nil }
func (stubReviewer) Relink(context.Context, int64) error { return nil }
func (stubReviewer) Rerecognize(context.Context, int64) error { return nil }
func (stubReviewer) ChooseCandidate(context.Context, int64, int64) error { return nil }
func (stubReviewer) SetProviderID(context.Context, int64, string, string) error { return nil }
func (stubReviewer) ClearProvider(context.Context, int64) error { return nil }
func (stubReviewer) Apply(context.Context, string) error { return nil }
func (stubReviewer) Refine(context.Context, string, string) error { return nil }
func (stubReviewer) SetType(context.Context, string, string) error { return nil }
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
func (stubReviewer) Defer(context.Context, string) error { return nil }
func (stubReviewer) Undo(context.Context, string) error { return nil }
func (stubReviewer) Relink(context.Context, string) error { return nil }
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
func (stubReviewer) SetProviderID(context.Context, string, string, string) error { return nil }
func (stubReviewer) AddManualSource(context.Context, string, string, string) error {
return nil
}
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
// stubLive — заглушка источника живой телеметрии.
type stubLive struct{ m map[string]worker.Live }
@@ -85,9 +92,14 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
return rr
}
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
const testULID = "01arz3ndektsv4rrffq69g5fav"
// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
func TestRouterRendersPages(t *testing.T) {
dl := store.Download{ID: 7, SourceRef: "Fargo.S02", Infohash: store.NullString("a1b2c3d4e5f6a7b8"), State: store.StateReview}
dl := store.Download{ID: testULID, SourceRef: "Fargo.S02",
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "a1b2c3d4e5f6a7b8", Kind: store.HashV1}},
State: store.StateReview}
h := testRouter(t,
stubReader{list: []store.Download{dl}, one: &dl},
stubReviewer{data: &worker.ReviewData{Download: dl}},
@@ -99,8 +111,8 @@ func TestRouterRendersPages(t *testing.T) {
t.Errorf("index не содержит бейдж статуса")
}
if rr := get(t, h, "/download/7"); rr.Code != http.StatusOK {
t.Fatalf("GET /download/7 = %d, want 200", rr.Code)
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusOK {
t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
}
}
@@ -122,7 +134,27 @@ func TestStaticServed(t *testing.T) {
// TestDownloadNotFound — несуществующая загрузка → 404.
func TestDownloadNotFound(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
if rr := get(t, h, "/download/999"); rr.Code != http.StatusNotFound {
t.Fatalf("GET /download/999 = %d, want 404", rr.Code)
if rr := get(t, h, "/download/01arz3ndektsv4rrffq69g5fff"); rr.Code != http.StatusNotFound {
t.Fatalf("GET /download/{missing} = %d, want 404", rr.Code)
}
}
// TestDownloadInvalidID — синтаксически невалидный id → 404 без похода в БД.
func TestDownloadInvalidID(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
for _, path := range []string{"/download/999", "/download/abc!!!", "/review/12"} {
if rr := get(t, h, path); rr.Code != http.StatusNotFound {
t.Fatalf("GET %s = %d, want 404 (невалидный id = несуществующая сущность)", path, rr.Code)
}
}
}
// TestDownloadUppercaseIDNormalized — uppercase-вариант id ведёт на ту же
// страницу (нормализация на входной границе).
func TestDownloadUppercaseIDNormalized(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "X", State: store.StateReview}
h := testRouter(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}})
if rr := get(t, h, "/download/"+strings.ToUpper(testULID)); rr.Code != http.StatusOK {
t.Fatalf("GET /download/{UPPERCASE} = %d, want 200", rr.Code)
}
}
+202 -49
View File
@@ -6,31 +6,33 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// Reviewer — операции ревью и раскладки (worker.Worker).
type Reviewer interface {
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
Apply(ctx context.Context, id int64) error
Refine(ctx context.Context, id int64, hint string) error
SetType(ctx context.Context, id int64, mediaType string) error
IgnoreFile(ctx context.Context, id int64, src string) error
Defer(ctx context.Context, id int64) error
Undo(ctx context.Context, id int64) error
Relink(ctx context.Context, id int64) error
Rerecognize(ctx context.Context, id int64) error
ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) error
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
Apply(ctx context.Context, id string) error
Refine(ctx context.Context, id string, hint string) error
IgnoreFile(ctx context.Context, id string, src string) error
Defer(ctx context.Context, id string) error
Undo(ctx context.Context, id string) error
Relink(ctx context.Context, id string) error
Rerecognize(ctx context.Context, id string) error
ChooseCandidate(ctx context.Context, id, candidateID string) error
SetProviderID(ctx context.Context, id string, provider, providerID string) error
AddManualSource(ctx context.Context, id, provider, providerID string) error
ClearProvider(ctx context.Context, id string) error
}
// --- Представление страницы ревью ---
type reviewView struct {
ID int64
ID string
Source string
Context string
State string
@@ -41,6 +43,7 @@ type reviewView struct {
Title string
OriginalTitle string
Year int
SeasonSummary string // сводка сезонов для сериала (пусто для фильма)
Provider string
ProviderID string
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
@@ -51,23 +54,30 @@ type reviewView struct {
HasPlan bool
HasLinks bool // есть хотя бы один целевой путь → можно применять
NoBase bool // выбрано «без базы»
Candidates []candidateView
Sources []sourceView // единый список источников совпадения
BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
}
type candidateView struct {
ID int64
// sourceView — строка единого списка источников на экране ревью: нейронка или
// кандидат базы. Инфо и предпросмотр раскладки показываются для активного
// источника из верхнеуровневых полей reviewView, поэтому per-source превью
// строка не несёт.
type sourceView struct {
Kind string // "neural" | "candidate"
CandidateID string
Provider string
ProviderID string
Title string
Year int
URL string
Chosen bool
MatchURL string
Active bool
}
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
// Невалидный id = несуществующая сущность; в БД не ходим.
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
@@ -81,12 +91,20 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
return
}
s.render(w, "review.html", buildReviewView(id, rd, r.URL.Query().Get("err")))
}
// buildReviewView собирает представление страницы ревью из доменных данных.
// Общий для полной страницы (handleReview) и htmx-свопа блока источника
// (reviewBlockAction); errMsg — верхний баннер из ?err= (пусто на htmx-пути,
// там ошибка идёт в BlockError).
func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView {
view := reviewView{
ID: id,
Source: shorten(rd.Download.SourceRef, 80),
Context: rd.Download.Context,
State: string(rd.Download.State),
Error: r.URL.Query().Get("err"),
Error: errMsg,
StateError: rd.Download.ErrorMsg.String,
Hints: rd.Hints,
}
@@ -96,6 +114,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
view.Title = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle
view.Year = rd.Plan.Year
if view.IsSeries {
view.SeasonSummary = seasonSummary(rd.Plan)
}
view.Reasons = rec.ReasonList()
switch rd.Provider {
case "", "none":
@@ -111,20 +132,23 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
view.Files = buildFileRows(rd.Plan, rd.Preview)
view.HasPlan = len(rd.Plan.Files) > 0
view.HasLinks = len(rd.Preview) > 0
for _, c := range rd.Candidates {
view.Candidates = append(view.Candidates, candidateView{
ID: c.ID,
Provider: c.Provider,
ProviderID: c.ProviderID,
Title: c.Title.String,
Year: int(c.Year.Int64),
URL: c.URL.String,
Chosen: c.Chosen,
})
for _, src := range rd.Sources {
sv := sourceView{
Kind: string(src.Kind),
CandidateID: src.CandidateID,
Provider: src.Provider,
ProviderID: src.ProviderID,
Title: src.Title,
Year: src.Year,
Active: src.Active,
}
if src.Kind == worker.SourceCandidate {
sv.MatchURL = sourceMatchURL(src)
}
view.Sources = append(view.Sources, sv)
}
}
s.render(w, "review.html", view)
return view
}
// --- Действия ревью (POST → redirect) ---
@@ -145,36 +169,30 @@ func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
}
func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
return s.deps.Reviewer.Refine(ctx, id, r.PostForm.Get("hint"))
})
}
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
return s.deps.Reviewer.Rerecognize(ctx, id)
})
}
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
return s.deps.Reviewer.SetType(ctx, id, r.PostForm.Get("type"))
})
}
func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
return s.deps.Reviewer.IgnoreFile(ctx, id, r.PostForm.Get("src"))
})
}
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
candidateID, err := strconv.ParseInt(r.PostForm.Get("candidate_id"), 10, 64)
// Входная граница: id кандидата из формы валидируется как ULID.
candidateID, err := ident.Parse(r.PostForm.Get("candidate_id"))
if err != nil {
return errInvalidCandidate
}
@@ -183,20 +201,113 @@ func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
}
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
})
}
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
return s.deps.Reviewer.ClearProvider(ctx, id)
})
}
// handleAddSource добавляет источник вручную по id или URL записи метабазы и
// выбирает его. Разбор ввода — на входной границе транспорта.
func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
if err != nil {
return err
}
return s.deps.Reviewer.AddManualSource(ctx, id, provider, providerID)
})
}
var errInvalidCandidate = errors.New("некорректный id кандидата")
var errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)")
// parseManualSource разбирает ручной ввод источника: либо id (с выбранным в
// форме провайдером), либо URL записи метабазы (провайдер и id из URL).
func parseManualSource(provider, raw string) (string, string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", errManualSource
}
if looksLikeURL(raw) {
// URL без схемы (`themoviedb.org/tv/1`) url.Parse кладёт в Path, а не в
// Host → достраиваем схему, иначе валидный копипаст отвергнется.
u := raw
if !strings.Contains(u, "://") {
u = "https://" + u
}
p, id, ok := parseProviderURL(u)
if !ok {
return "", "", errManualSource
}
return p, id, nil
}
return strings.ToLower(strings.TrimSpace(provider)), raw, nil
}
func looksLikeURL(s string) bool {
return strings.Contains(s, "://") || strings.HasPrefix(s, "www.") ||
strings.Contains(s, ".org/") || strings.Contains(s, ".com/")
}
// parseProviderURL — обратная к providerURL: URL записи → (provider, id).
// TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
func parseProviderURL(raw string) (provider, id string, ok bool) {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "", "", false
}
host := strings.ToLower(u.Host)
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
switch {
case strings.Contains(host, "themoviedb.org"):
if len(parts) >= 2 && (parts[0] == "movie" || parts[0] == "tv") {
if d := leadingDigits(parts[1]); d != "" {
return "tmdb", d, true
}
}
case strings.Contains(host, "imdb.com"):
if len(parts) >= 2 && parts[0] == "title" && strings.HasPrefix(parts[1], "tt") {
return "imdb", parts[1], true
}
case strings.Contains(host, "thetvdb.com"):
if len(parts) >= 3 && parts[0] == "dereferrer" {
if d := leadingDigits(parts[2]); d != "" {
return "tvdb", d, true
}
}
}
return "", "", false
}
// leadingDigits возвращает ведущие цифры строки (TMDB-URL вида
// `/movie/60622-fargo` → «60622»).
func leadingDigits(s string) string {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
return s[:i]
}
// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
// есть, иначе канонический URL из provider/id (обратный порядок к matchURL).
func sourceMatchURL(src worker.SourceOption) string {
if src.URL != "" {
return src.URL
}
return providerURL(src.Provider, src.ProviderID, src.Type)
}
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
@@ -240,7 +351,7 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
// ревью (с ошибкой в ?err при неудаче).
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) {
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
@@ -255,6 +366,48 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
redirectReview(w, r, id, "")
}
// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу).
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// reviewBlockAction — помощник для действий выбора источника: выполнить
// операцию и вернуть свежий блок источника. На htmx-запрос перечитывает
// состояние и рендерит партиал `review_source_block` (ошибку кладёт в
// BlockError, активный источник не меняется); без htmx деградирует до
// PRG-редиректа, как reviewAction.
func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
actionErr := fn(r.Context(), id)
if !isHTMX(r) {
msg := ""
if actionErr != nil {
msg = userErr(r, actionErr, id)
}
redirectReview(w, r, id, msg)
return
}
// htmx: перечитываем состояние (уже с новым активным источником при успехе)
// и рендерим свежий партиал блока.
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.deps.Logger.Error("review data", "id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
view := buildReviewView(id, rd, "")
if actionErr != nil {
view.BlockError = userErr(r, actionErr, id)
}
s.render(w, "review_source_block", view)
}
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
// выбранного кандидата, но только если его provider+id совпадают с эффективными
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
@@ -296,8 +449,8 @@ func providerURL(provider, id, mediaType string) string {
}
}
func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string) {
u := "/review/" + strconv.FormatInt(id, 10)
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
u := "/review/" + id
if msg != "" {
u += "?err=" + url.QueryEscape(msg)
}
+50
View File
@@ -0,0 +1,50 @@
// Package ident — единственная точка генерации и разбора идентификаторов
// домена. Идентификатор — ULID (26 символов Crockford base32), канонический
// вид — lowercase; на входных границах (URL, формы) значение прогоняется
// через Parse до обращения к хранилищу, потому что сравнение строк в SQLite
// побайтовое.
package ident
import (
"crypto/rand"
"fmt"
"strings"
"sync"
"time"
"github.com/oklog/ulid/v2"
)
// entropy — monotonic-источник: при равной миллисекунде timestamp'а энтропия
// инкрементируется, так что id, выданные подряд, сохраняют порядок выдачи.
var (
mu sync.Mutex
entropy = ulid.Monotonic(rand.Reader, 0)
)
// NewID возвращает новый идентификатор (текущее время).
func NewID() string {
return NewIDAt(time.Now())
}
// NewIDAt возвращает идентификатор с timestamp-частью из t. Используется
// миграциями для бэкфилла: сортировка id сохраняет историческую хронологию
// (created_at имеет секундное разрешение — равные метки упорядочивает
// monotonic-энтропия в порядке вызовов).
func NewIDAt(t time.Time) string {
mu.Lock()
defer mu.Unlock()
id := ulid.MustNew(ulid.Timestamp(t.UTC()), entropy)
return strings.ToLower(id.String())
}
// Parse валидирует внешний идентификатор и нормализует его к каноническому
// lowercase-виду. Регистр входа не важен (base32 ULID case-insensitive).
func Parse(s string) (string, error) {
s = strings.TrimSpace(s)
u, err := ulid.ParseStrict(strings.ToUpper(s))
if err != nil {
return "", fmt.Errorf("ident: parse %q: %w", s, err)
}
return strings.ToLower(u.String()), nil
}
+70
View File
@@ -0,0 +1,70 @@
package ident
import (
"strings"
"testing"
"time"
)
func TestNewIDLowercaseAndValid(t *testing.T) {
id := NewID()
if len(id) != 26 {
t.Fatalf("len(%q) = %d, want 26", id, len(id))
}
if id != strings.ToLower(id) {
t.Fatalf("id %q is not lowercase", id)
}
if _, err := Parse(id); err != nil {
t.Fatalf("Parse(NewID()) failed: %v", err)
}
}
func TestNewIDSortedByIssueOrder(t *testing.T) {
prev := NewID()
for range 100 {
next := NewID()
if next <= prev {
t.Fatalf("ids out of order: %q then %q", prev, next)
}
prev = next
}
}
func TestNewIDAtEqualTimestampsKeepOrder(t *testing.T) {
ts := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
prev := NewIDAt(ts)
for range 100 {
next := NewIDAt(ts) // одна и та же миллисекунда → monotonic-энтропия
if next <= prev {
t.Fatalf("ids out of order at equal timestamp: %q then %q", prev, next)
}
prev = next
}
}
func TestNewIDAtChronology(t *testing.T) {
older := NewIDAt(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
newer := NewIDAt(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
if older >= newer {
t.Fatalf("chronology broken: %q >= %q", older, newer)
}
}
func TestParseNormalizesCase(t *testing.T) {
id := NewID()
got, err := Parse(strings.ToUpper(id))
if err != nil {
t.Fatalf("Parse(upper) failed: %v", err)
}
if got != id {
t.Fatalf("Parse(upper) = %q, want %q", got, id)
}
}
func TestParseRejectsGarbage(t *testing.T) {
for _, bad := range []string{"", "abc", "abc!!!", "123", strings.Repeat("z", 26), strings.Repeat("a", 27)} {
if _, err := Parse(bad); err == nil {
t.Fatalf("Parse(%q) unexpectedly succeeded", bad)
}
}
}
+56 -19
View File
@@ -24,9 +24,17 @@ const errCodeQbitAdd = "qbit_add"
// Store — нужная ingest часть хранилища.
type Store interface {
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
// FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer);
// авторитетная проверка — внутри CreateDownloadIfNoActive.
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
}
// QBittorrent — нужная ingest часть клиента qBittorrent.
@@ -58,7 +66,7 @@ type Service struct {
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
notifyFailed func(downloadID int64)
notifyFailed func(downloadID string)
}
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
@@ -68,7 +76,7 @@ func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *S
}
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn }
// Request — входной запрос приёма.
type Request struct {
@@ -78,8 +86,8 @@ type Request struct {
// Result — итог приёма.
type Result struct {
DownloadID int64
Infohash string
DownloadID string
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
State store.State
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
}
@@ -100,16 +108,14 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
ctx = logctx.With(ctx, log)
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohash); err != nil {
// Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная)
// проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из
// хешей источника: гибридный magnet несёт и v1, и v2.
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
} else if existing != nil {
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
return Result{
DownloadID: existing.ID,
Infohash: info.Infohash,
State: existing.State,
Deduplicated: true,
}, nil
return s.attached(ctx, info, existing), nil
}
// Отображаемое имя для списка qBit — best-effort: не валит приём.
@@ -126,14 +132,26 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
SourceRef: source,
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
Context: req.Context,
Infohash: store.NullString(info.Infohash),
IdempotencyKey: store.NullString(info.Infohash),
State: store.StateDownloading,
}
id, err := s.store.CreateDownload(ctx, d)
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
if err != nil {
return Result{}, fmt.Errorf("ingest: create download: %w", err)
}
if existing != nil {
// Гонка с параллельным приёмом/discover: активная задача появилась
// после быстрого чека — присоединяемся к ней (хеши донёс сам
// CreateDownloadIfNoActive).
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
return Result{
DownloadID: existing.ID,
Infohashes: info.Infohashes,
State: existing.State,
Deduplicated: true,
}, nil
}
id := d.ID
log = log.With("download_id", id)
ctx = logctx.With(ctx, log)
@@ -156,14 +174,33 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// сами, чтобы приёмные провалы тоже доходили до автора.
go s.notifyFailed(id)
}
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
return Result{DownloadID: id, Infohashes: info.Infohashes, State: store.StateFailed},
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
}
log.Info("download accepted", "category", s.cfg.Category)
return Result{
DownloadID: id,
Infohash: info.Infohash,
Infohashes: info.Infohashes,
State: store.StateDownloading,
}, nil
}
// attached — итог дедупа на быстром чеке: присоединились к уже активной
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
// принести хеш, которого задача ещё не знает; guarded-путь через
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
// хеша с другой активной задачей логируется, приём не валится.
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result {
if len(info.Infohashes) > len(existing.Infohashes) {
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil {
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
}
}
return Result{
DownloadID: existing.ID,
Infohashes: info.Infohashes,
State: existing.State,
Deduplicated: true,
}
}
+55 -18
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
@@ -19,29 +20,39 @@ const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
type fakeStore struct {
active *store.Download
created []store.Download
nextID int64
hashes [][]string
toppedUp []string
stateCalls []stateCall
}
type stateCall struct {
id int64
id string
state store.State
code string
msg string
}
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ string) (*store.Download, error) {
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
return f.active, nil
}
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
f.nextID++
d.ID = f.nextID
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) {
if f.active != nil {
return f.active, nil
}
d.ID = ident.NewID()
f.created = append(f.created, *d)
return f.nextID, nil
f.hashes = append(f.hashes, hashes)
return nil, nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
f.toppedUp = append(f.toppedUp, hashes...)
_ = id
return nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
return nil
}
@@ -90,8 +101,8 @@ func TestIngestHappyPath(t *testing.T) {
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if res.Infohash != sampleInfohash {
t.Errorf("infohash = %q", res.Infohash)
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
t.Errorf("infohashes = %v", res.Infohashes)
}
if res.State != store.StateDownloading || res.Deduplicated {
t.Errorf("res = %+v", res)
@@ -99,9 +110,12 @@ func TestIngestHappyPath(t *testing.T) {
if len(fs.created) != 1 {
t.Fatalf("создано задач: %d, want 1", len(fs.created))
}
if got := fs.created[0]; got.Context != "Дюна 2" || got.Infohash.String != sampleInfohash {
if got := fs.created[0]; got.Context != "Дюна 2" {
t.Errorf("сохранённая задача: %+v", got)
}
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
t.Errorf("хеши задачи: %v", fs.hashes)
}
if len(fq.added) != 1 {
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
}
@@ -149,15 +163,15 @@ func TestIngestEmptyNameOmitsRename(t *testing.T) {
}
func TestIngestIdempotent(t *testing.T) {
existing := &store.Download{ID: 7, State: store.StateDownloading}
existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateDownloading}
fs := &fakeStore{active: existing}
fq := &fakeQbt{}
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if !res.Deduplicated || res.DownloadID != 7 {
t.Errorf("ожидалось присоединение к задаче 7: %+v", res)
if !res.Deduplicated || res.DownloadID != existing.ID {
t.Errorf("ожидалось присоединение к существующей задаче: %+v", res)
}
if len(fs.created) != 0 {
t.Error("не должно создаваться новой задачи")
@@ -167,6 +181,29 @@ func TestIngestIdempotent(t *testing.T) {
}
}
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
// гибридного magnet (иначе последующий приём по второму хешу создал бы
// вторую активную задачу).
func TestIngestDedupTopsUpHashes(t *testing.T) {
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
existing := &store.Download{
ID: "01hzzzexisting000000000000", State: store.StateDownloading,
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
}
fs := &fakeStore{active: existing}
res, err := newService(fs, &fakeQbt{}).Ingest(context.Background(),
Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if !res.Deduplicated {
t.Fatalf("ожидался дедуп: %+v", res)
}
if len(fs.toppedUp) != 2 {
t.Errorf("хеши не донесены существующей задаче: %v", fs.toppedUp)
}
}
func TestIngestQbitErrorMarksFailed(t *testing.T) {
fs := &fakeStore{}
fq := &fakeQbt{err: errors.New("connection refused")}
@@ -186,16 +223,16 @@ func TestIngestQbitErrorNotifies(t *testing.T) {
fs := &fakeStore{}
fq := &fakeQbt{err: errors.New("connection refused")}
svc := newService(fs, fq)
got := make(chan int64, 1)
svc.SetFailureNotifier(func(id int64) { got <- id })
got := make(chan string, 1)
svc.SetFailureNotifier(func(id string) { got <- id })
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
t.Fatal("ожидалась ошибка")
}
select {
case id := <-got:
if id == 0 {
t.Errorf("уведомление с нулевым id")
if id == "" {
t.Errorf("уведомление с пустым id")
}
case <-time.After(2 * time.Second):
t.Fatal("уведомление о падении приёма не пришло")
+12 -1
View File
@@ -224,6 +224,7 @@ const (
type Result struct {
Link Link
Status LinkStatus
Size int64 // размер разложенного файла (байт); 0, если stat не удался
}
// ErrCollision — цель существует и это другой файл (нужен review).
@@ -261,7 +262,17 @@ func (l *Layouter) Apply(ctx context.Context, links []Link) ([]Result, error) {
}
log.Debug("layout link applied",
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
results = append(results, Result{Link: ln, Status: status})
// Размер разложенного файла для показа в веб-UI (фолбэк размера раздачи,
// когда торрента нет в снимке). Best-effort: файл только что слинкован/
// скопирован/уже существовал — stat должен пройти; сбой не валит
// раскладку, размер остаётся 0.
var size int64
if fi, serr := os.Stat(ln.Dst); serr == nil {
size = fi.Size()
} else {
log.Debug("layout stat size failed", "dst", ln.Dst, "error", serr)
}
results = append(results, Result{Link: ln, Status: status, Size: size})
}
return results, nil
}
+3
View File
@@ -145,6 +145,9 @@ func TestApply_CreatesHardlink(t *testing.T) {
if len(res) != 1 || res[0].Status != StatusLinked {
t.Fatalf("res = %+v", res)
}
if res[0].Size != int64(len("data")) {
t.Errorf("res.Size = %d, want %d", res[0].Size, len("data"))
}
// Тот же inode, источник цел.
si, _ := os.Stat(src)
di, _ := os.Stat(links[0].Dst)
+10 -6
View File
@@ -16,7 +16,8 @@ import (
// Info — разобранная magnet-ссылка.
type Info struct {
Infohash string // нормализованный нижний hex (40 для v1, 64 для v2)
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2
DisplayName string // dn — человекочитаемое имя, если задано
Trackers []string // tr — трекеры
}
@@ -49,16 +50,19 @@ func Parse(raw string) (Info, error) {
}
}
infohash := v1
if infohash == "" {
infohash = v2
var hashes []string
for _, h := range []string{v1, v2} {
if h != "" {
hashes = append(hashes, h)
}
if infohash == "" {
}
if len(hashes) == 0 {
return Info{}, fmt.Errorf("magnet without a usable infohash (xt)")
}
return Info{
Infohash: infohash,
Infohash: hashes[0],
Infohashes: hashes,
DisplayName: vals.Get("dn"),
Trackers: vals["tr"],
}, nil
+18
View File
@@ -47,6 +47,24 @@ func TestParse(t *testing.T) {
}
}
// Гибридный magnet несёт оба хеша: Infohash — v1 (приоритет), Infohashes — оба.
func TestParseHybridKeepsBothHashes(t *testing.T) {
const (
v1 = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
got, err := Parse("magnet:?xt=urn:btih:" + v1 + "&xt=urn:btmh:1220" + v2)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got.Infohash != v1 {
t.Errorf("primary = %q, want v1", got.Infohash)
}
if len(got.Infohashes) != 2 || got.Infohashes[0] != v1 || got.Infohashes[1] != v2 {
t.Errorf("infohashes = %v, want [v1 v2]", got.Infohashes)
}
}
func TestParseErrors(t *testing.T) {
cases := []string{
"https://example.com/file.torrent", // не magnet
+1 -1
View File
@@ -176,7 +176,7 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
ID: r.TVDBID,
Title: r.Name,
Year: year,
URL: "https://www.thetvdb.com/dereferrer/series/" + r.TVDBID,
URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID,
})
}
return out, nil
+24
View File
@@ -83,6 +83,30 @@ func TestTVDB_SearchAndLoginCached(t *testing.T) {
}
}
// Для фильма ссылка-dereferrer должна вести на /movie/, а не /series/.
func TestTVDB_MovieDereferrerURL(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":{"token":"tok"}}`))
})
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("type") != "movie" {
t.Errorf("type = %q, want movie", r.URL.Query().Get("type"))
}
_, _ = w.Write([]byte(`{"data":[{"tvdb_id":"3015","name":"The Last Unicorn","year":"1982"}]}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
got, err := newTVDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Last Unicorn"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 || got[0].URL != "https://www.thetvdb.com/dereferrer/movie/3015" {
t.Fatalf("candidate = %+v", got)
}
}
func TestTVDB_SeasonEpisodeCounts(t *testing.T) {
srv := fakeTVDB(t, nil)
counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613")
+1
View File
@@ -58,6 +58,7 @@ type Torrent struct {
Tags string `json:"tags"` // через запятую
Progress float64 `json:"progress"`
AmountLeft int64 `json:"amount_left"`
TotalSize int64 `json:"total_size"` // полный размер раздачи, байт
AddedOn int64 `json:"added_on"`
InfohashV1 string `json:"infohash_v1"`
InfohashV2 string `json:"infohash_v2"`
+391 -82
View File
@@ -8,6 +8,10 @@ import (
"slices"
"strings"
"time"
"github.com/jmoiron/sqlx"
"git.vakhrushev.me/av/jellybit/internal/ident"
)
// State — состояние загрузки в машине состояний (см. architecture.md).
@@ -35,27 +39,26 @@ const (
)
// terminalStates — единый список окончательно остановленных состояний:
// источник истины и для IsTerminal, и для выборки «активных» задач
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
// снимается по IsTerminal, а активность считалась бы по другому списку).
// источник истины и для IsTerminal, и для выборки «активных» задач.
// Активность выводится ТОЛЬКО из state (отдельного ключа идемпотентности
// нет); любое новое терминальное состояние добавляется ТОЛЬКО сюда — иначе
// семантика «активности» разъедется.
//
// Состояния рассинхрона (target_missing/orphaned/deleted), а также
// failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
// двигает либо человек (relink из target_missing, retry из failed/stuck), либо
// фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
// при оживлении источника, см. state-reconciliation) — напрямую через
// SetDownloadState, который восстановит ключ для нетерминального целевого
// состояния.
// Состояния рассинхрона (target_missing/orphaned/deleted), а также failed —
// терминальны для активности, но не «мертвы»: дальше двигает либо человек
// (relink из target_missing, retry из failed), либо фоновая сверка (healing/
// прогрессия desync; авто-восстановление при оживлении источника, см.
// state-reconciliation) — через ActivateIfNoOtherActive, который атомарно
// проверяет инвариант «не более одной активной загрузки на infohash».
var terminalStates = []State{
StateDone, StateCancelled, StateFailed, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
}
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
// состояний снимается ключ идемпотентности — тот же infohash можно завести
// заново новой задачей (см. architecture.md, «повторное добавление»).
// stuck терминальным не считается: задача восстановима (retry).
// IsTerminal сообщает, завершена ли задача окончательно. Терминальная задача
// не «владеет» своими инфохэшами — тот же infohash можно завести заново
// новой задачей (см. architecture.md, «повторное добавление»). stuck
// терминальным не считается: задача восстановима (retry).
func (s State) IsTerminal() bool {
return slices.Contains(terminalStates, s)
}
@@ -69,15 +72,41 @@ const (
SourceURL SourceType = "url"
)
// Виды инфохэша (download_infohash.kind).
const (
HashV1 = "v1" // SHA-1, 40 hex
HashV2 = "v2" // SHA-256, 64 hex
)
// HashKind — вид инфохэша по длине hex-строки: 64 — v2, иначе v1.
func HashKind(h string) string {
if len(h) == 64 {
return HashV2
}
return HashV1
}
// NormalizeHash приводит инфохэш к канонической форме хранения (нижний hex —
// в этом же виде его отдаёт qBittorrent).
func NormalizeHash(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
// Infohash — строка таблицы download_infohash: один из хешей загрузки
// (у одной загрузки их несколько: v1/v2 гибридного торрента).
type Infohash struct {
DownloadID string `db:"download_id"`
Infohash string `db:"infohash"`
Kind string `db:"kind"`
}
// Download — строка таблицы download.
type Download struct {
ID int64 `db:"id"`
ID string `db:"id"` // ULID (lowercase), публичный ключ домена
SourceType SourceType `db:"source_type"`
SourceRef string `db:"source_ref"`
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
Context string `db:"context"`
Infohash sql.NullString `db:"infohash"`
IdempotencyKey sql.NullString `db:"idempotency_key"`
State State `db:"state"`
ErrorCode sql.NullString `db:"error_code"`
ErrorMsg sql.NullString `db:"error_msg"`
@@ -86,33 +115,58 @@ type Download struct {
SourceMissCount int `db:"source_miss_count"`
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
// формате sqliteTimeLayout (UTC), как created_at.
// формате RFC 3339 (UTC, суффикс Z), как created_at.
SourceAddedAt sql.NullString `db:"source_added_at"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
Infohashes []Infohash `db:"-"`
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
// прочих выборках остаётся пустым.
RecTitle sql.NullString `db:"rec_title"`
}
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
const sqliteTimeLayout = "2006-01-02 15:04:05"
// HashList — все хеши загрузки списком (для сопоставления с qBittorrent).
func (d Download) HashList() []string {
out := make([]string, len(d.Infohashes))
for i, h := range d.Infohashes {
out[i] = h.Infohash
}
return out
}
// ParseTime разбирает временную метку SQLite (datetime('now'), всегда UTC).
// PrimaryInfohash — первый известный хеш (v1 приоритетно) для показа и
// scoped-логгера; пустая строка, если хешей нет.
func (d Download) PrimaryInfohash() string {
if len(d.Infohashes) == 0 {
return ""
}
return d.Infohashes[0].Infohash
}
// ParseTime разбирает временную метку хранилища (RFC 3339, всегда UTC).
func ParseTime(s string) (time.Time, error) {
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
return time.Parse(time.RFC3339, s)
}
// FormatTime форматирует время в формат меток SQLite (datetime('now'), UTC) —
// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи-
// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в
// сортировке списка) давало верный хронологический порядок.
// FormatTime форматирует время в формат меток хранилища — RFC 3339 в UTC
// (суффикс Z), напр. «2006-01-02T15:04:05Z». Единый формат всех меток; DEFAULT
// в схеме нет, время всегда пишет приложение через Now (см. ниже). Фиксированная
// ширина сохраняет лексикографическое сравнение строк времени = хронологию
// (COALESCE(source_added_at, created_at) в сортировке списка).
func FormatTime(t time.Time) string {
return t.UTC().Format(sqliteTimeLayout)
return t.UTC().Format(time.RFC3339)
}
// Now — единая точка получения «сейчас» для меток времени store (UTC). Всё время
// в БД генерирует приложение через неё (аналогично ident.NewID для id), а не
// SQLite DEFAULT: один источник формата, тестируемая точка.
func Now() time.Time { return time.Now().UTC() }
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
@@ -121,31 +175,165 @@ func NullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""}
}
// CreateDownload вставляет загрузку и возвращает её id.
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
const q = `
INSERT INTO download (source_type, source_ref, display_name, context, infohash, idempotency_key, state)
VALUES (?, ?, ?, ?, ?, ?, ?)`
res, err := s.DB.ExecContext(ctx, q,
d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.Infohash, d.IdempotencyKey, d.State)
if err != nil {
return 0, fmt.Errorf("insert download: %w", err)
// CreateDownloadIfNoActive атомарно (одна write-транзакция, BEGIN IMMEDIATE
// через _txlock) проверяет инвариант «не более одной активной загрузки на
// infohash» и заводит загрузку: если активная задача с любым из hashes уже
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
// ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются.
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string) (*Download, error) {
norm := normalizeHashes(hashes)
if len(norm) == 0 {
return nil, fmt.Errorf("create download: no infohash")
}
id, err := res.LastInsertId()
now := FormatTime(Now())
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("download last insert id: %w", err)
return nil, fmt.Errorf("create download: begin tx: %w", err)
}
return id, nil
defer func() { _ = tx.Rollback() }()
existing, err := findActiveByInfohash(ctx, tx, norm, "")
if err != nil {
return nil, fmt.Errorf("create download: %w", err)
}
if existing != nil {
// Дедуп нашёл активного владельца по одному из хешей — остальные хеши
// norm принадлежат тому же торренту (гибридный magnet): дописываем
// недостающие, иначе второй хеш молча теряется и последующий приём по
// нему создал бы вторую активную задачу.
for _, h := range norm {
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
existing.ID, h, HashKind(h), now); err != nil {
return nil, fmt.Errorf("create download: top up infohash: %w", err)
}
}
if err := attachInfohashesOne(ctx, tx, existing); err != nil {
return nil, fmt.Errorf("create download: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("create download: commit dedup: %w", err)
}
return existing, nil
}
// GetDownload возвращает загрузку по id.
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
d.ID = ident.NewID()
const q = `
INSERT INTO download (id, source_type, source_ref, display_name, context, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
if _, err := tx.ExecContext(ctx, q,
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State, now, now); err != nil {
return nil, fmt.Errorf("insert download: %w", err)
}
d.Infohashes = d.Infohashes[:0]
for _, h := range norm {
if _, err := tx.ExecContext(ctx,
`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
d.ID, h, HashKind(h), now); err != nil {
return nil, fmt.Errorf("insert download infohash: %w", err)
}
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("create download: commit: %w", err)
}
return nil, nil
}
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
// переводит состояние. При владении возвращает ErrInfohashTaken (обёрнутый
// с id владельца).
func (s *Store) ActivateIfNoOtherActive(ctx context.Context, id string, state State, errCode, errMsg string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("activate %s: begin tx: %w", id, err)
}
defer func() { _ = tx.Rollback() }()
var hashes []string
if err := tx.SelectContext(ctx, &hashes,
`SELECT infohash FROM download_infohash WHERE download_id = ?`, id); err != nil {
return fmt.Errorf("activate %s: read hashes: %w", id, err)
}
if len(hashes) > 0 {
// Исключаем саму задачу: при retry из stuck она сама активна и без
// исключения LIMIT 1 мог бы вернуть её, замаскировав другого владельца.
other, err := findActiveByInfohash(ctx, tx, hashes, id)
if err != nil {
return fmt.Errorf("activate %s: %w", id, err)
}
if other != nil {
return fmt.Errorf("activate %s: infohash owned by download %s: %w",
id, other.ID, ErrInfohashTaken)
}
}
if err := setState(ctx, tx, id, state, errCode, errMsg, true); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("activate %s: commit: %w", id, err)
}
return nil
}
// AddInfohashes дописывает загрузке недостающие хеши (qBittorrent раскрыл
// оба хеша гибридного торрента, а приём знал один). Это тоже мутация
// владения хешем, поэтому она под тем же гардом, что и create/activate:
// в одной write-транзакции каждый хеш проверяется на владение ДРУГОЙ
// активной задачей; конфликтные хеши не дописываются, метод возвращает
// ErrInfohashTaken (неконфликтные при этом дописаны — частичный успех).
func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []string) error {
norm := normalizeHashes(hashes)
if len(norm) == 0 {
return nil
}
now := FormatTime(Now())
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("add infohashes to %s: begin tx: %w", downloadID, err)
}
defer func() { _ = tx.Rollback() }()
var taken []string
for _, h := range norm {
other, err := findActiveByInfohash(ctx, tx, []string{h}, downloadID)
if err != nil {
return fmt.Errorf("add infohashes to %s: %w", downloadID, err)
}
if other != nil {
taken = append(taken, h)
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
downloadID, h, HashKind(h), now); err != nil {
return fmt.Errorf("add infohash %s to %s: %w", h, downloadID, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("add infohashes to %s: commit: %w", downloadID, err)
}
if len(taken) > 0 {
return fmt.Errorf("add infohashes to %s: %v owned by another active download: %w",
downloadID, taken, ErrInfohashTaken)
}
return nil
}
// GetDownload возвращает загрузку по id (с хешами).
func (s *Store) GetDownload(ctx context.Context, id string) (*Download, error) {
var d Download
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get download %d: %w", id, ErrNotFound)
return nil, fmt.Errorf("get download %s: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("get download %d: %w", id, err)
return nil, fmt.Errorf("get download %s: %w", id, err)
}
if err := attachInfohashesOne(ctx, s.DB, &d); err != nil {
return nil, fmt.Errorf("get download %s: %w", id, err)
}
return &d, nil
}
@@ -153,20 +341,24 @@ func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
// (qBittorrent added_on). SQL-гард `source_added_at IS NULL` не даёт перезапи-
// сать значение на повторных наблюдениях: время добавления неизменно.
func (s *Store) SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error {
func (s *Store) SetSourceAddedAt(ctx context.Context, id string, t time.Time) error {
const q = `UPDATE download SET source_added_at = ? WHERE id = ? AND source_added_at IS NULL`
if _, err := s.DB.ExecContext(ctx, q, FormatTime(t), id); err != nil {
return fmt.Errorf("set source added at %d: %w", id, err)
return fmt.Errorf("set source added at %s: %w", id, err)
}
return nil
}
// ListDownloads возвращает все загрузки, новые сверху.
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
// по нему хронологична).
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
var out []Download
if err := s.DB.SelectContext(ctx, &out, `SELECT * FROM download ORDER BY id DESC`); err != nil {
return nil, fmt.Errorf("list downloads: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list downloads: %w", err)
}
return out, nil
}
@@ -186,6 +378,9 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list downloads by state: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list downloads by state: %w", err)
}
return out, nil
}
@@ -210,25 +405,52 @@ func (s *Store) ListRecoverable(ctx context.Context, codes ...string) ([]Downloa
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list recoverable: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list recoverable: %w", err)
}
return out, nil
}
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
// (nil, nil), если её нет. Основа идемпотентного приёма.
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
// (terminalStates), иначе семантика активности разъедется с idempotency_key.
ph := make([]string, len(terminalStates))
args := make([]any, 0, len(terminalStates)+1)
args = append(args, infohash)
for i, st := range terminalStates {
ph[i] = "?"
args = append(args, string(st))
// FindActiveByInfohash возвращает незавершённую задачу, владеющую любым из
// hashes, либо (nil, nil). Читающая основа дедупа; сам инвариант держат
// guarded-методы (CreateDownloadIfNoActive / ActivateIfNoOtherActive).
func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Download, error) {
d, err := findActiveByInfohash(ctx, s.DB, normalizeHashes(hashes), "")
if err != nil {
return nil, err
}
q := `SELECT * FROM download WHERE infohash = ? AND state NOT IN (` +
strings.Join(ph, ",") + `) ORDER BY id DESC LIMIT 1`
if d != nil {
if err := attachInfohashesOne(ctx, s.DB, d); err != nil {
return nil, err
}
}
return d, nil
}
// findActiveByInfohash — общая выборка «активная задача по любому из хешей»
// (для guarded-методов — внутри их транзакции). hashes уже нормализованы;
// excludeID исключает саму проверяемую задачу (она может быть активной,
// например stuck при retry, — и не должна маскировать другого владельца);
// пустой excludeID — без исключения. Хеши найденной загрузки НЕ подгружаются.
func findActiveByInfohash(ctx context.Context, q sqlx.QueryerContext, hashes []string, excludeID string) (*Download, error) {
if len(hashes) == 0 {
return nil, nil
}
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
// (terminalStates), иначе семантика активности разъедется.
var args []any
hashPh := placeholders(&args, hashes)
statePh := placeholders(&args, terminalStates)
query := `SELECT download.* FROM download
JOIN download_infohash dh ON dh.download_id = download.id
WHERE dh.infohash IN (` + hashPh + `) AND download.state NOT IN (` + statePh + `)`
if excludeID != "" {
query += ` AND download.id != ?`
args = append(args, excludeID)
}
query += ` ORDER BY download.id DESC LIMIT 1`
var d Download
err := s.DB.GetContext(ctx, &d, q, args...)
err := sqlx.GetContext(ctx, q, &d, query, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -238,63 +460,150 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Dow
return &d, nil
}
// placeholders дописывает значения in в args и возвращает строку "?,?,…"
// той же длины — сборка IN-списков без ручного жонглирования срезами.
func placeholders[T ~string](args *[]any, in []T) string {
ph := make([]string, len(in))
for i, v := range in {
ph[i] = "?"
*args = append(*args, string(v))
}
return strings.Join(ph, ",")
}
// ExistsByInfohash сообщает, есть ли хоть одна загрузка (в любом состоянии)
// с данным infohash. Discovery усыновляет раздачу только если её ещё не
// с любым из hashes. Discovery усыновляет раздачу только если её ещё не
// видели — так готовые задачи не переобрабатываются на каждом тике.
func (s *Store) ExistsByInfohash(ctx context.Context, infohash string) (bool, error) {
func (s *Store) ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error) {
norm := normalizeHashes(hashes)
if len(norm) == 0 {
return false, nil
}
ph := make([]string, len(norm))
args := make([]any, len(norm))
for i, h := range norm {
ph[i] = "?"
args[i] = h
}
var n int
if err := s.DB.GetContext(ctx, &n,
`SELECT COUNT(1) FROM download WHERE infohash = ?`, infohash); err != nil {
`SELECT COUNT(1) FROM download_infohash WHERE infohash IN (`+strings.Join(ph, ",")+`)`,
args...); err != nil {
return false, fmt.Errorf("exists by infohash: %w", err)
}
return n > 0, nil
}
// SetDownloadState переводит загрузку в новое состояние. Ключ
// идемпотентности пересчитывается из текущего infohash: для терминального
// состояния снимается (NULL), иначе равен infohash — так partial unique
// index гарантирует не более одной активной задачи на infohash.
func (s *Store) SetDownloadState(ctx context.Context, id int64, state State, errCode, errMsg string) error {
const q = `
// SetDownloadState переводит загрузку в новое состояние. Механический
// бэкстоп инварианта «одна активная загрузка на infohash» (заменяет
// удалённый unique-индекс): переход из терминального состояния в активное
// этим методом отклоняется — возврат в активное идёт ТОЛЬКО через
// ActivateIfNoOtherActive, который проверяет владение хешами.
func (s *Store) SetDownloadState(ctx context.Context, id string, state State, errCode, errMsg string) error {
return setState(ctx, s.DB, id, state, errCode, errMsg, false)
}
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
func setState(ctx context.Context, e sqlx.ExecerContext, id string, state State, errCode, errMsg string, reviveOK bool) error {
q := `
UPDATE download
SET state = ?,
error_code = ?,
error_msg = ?,
idempotency_key = CASE WHEN ? = 1 THEN NULL ELSE infohash END,
updated_at = datetime('now')
updated_at = ?
WHERE id = ?`
terminal := 0
if state.IsTerminal() {
terminal = 1
args := []any{string(state), nullArg(errCode), nullArg(errMsg), FormatTime(Now()), id}
if !reviveOK && !state.IsTerminal() {
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
}
res, err := s.DB.ExecContext(ctx, q, string(state), nullArg(errCode), nullArg(errMsg), terminal, id)
res, err := e.ExecContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("set download %d state %q: %w", id, state, err)
return fmt.Errorf("set download %s state %q: %w", id, state, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set download %d state %q: %w", id, state, err)
return fmt.Errorf("set download %s state %q: %w", id, state, err)
}
if n == 0 {
return fmt.Errorf("set download %d state %q: not found", id, state)
return fmt.Errorf("set download %s state %q: not found or terminal (revive requires ActivateIfNoOtherActive)", id, state)
}
return nil
}
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
// Состояние не трогает — это отдельная от перехода фоновая отметка.
func (s *Store) SetSourceMissCount(ctx context.Context, id int64, n int) error {
func (s *Store) SetSourceMissCount(ctx context.Context, id string, n int) error {
res, err := s.DB.ExecContext(ctx,
`UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id)
if err != nil {
return fmt.Errorf("set download %d source_miss_count: %w", id, err)
return fmt.Errorf("set download %s source_miss_count: %w", id, err)
}
if affected, _ := res.RowsAffected(); affected == 0 {
return fmt.Errorf("set download %d source_miss_count: not found", id)
return fmt.Errorf("set download %s source_miss_count: not found", id)
}
return nil
}
// attachInfohashes подгружает хеши для набора загрузок батч-запросами
// (порядок стабильный: v1 раньше v2). IN-список режется на чанки, чтобы
// безлимитные выборки (ListDownloads за всю историю) не упирались в
// SQLITE_MAX_VARIABLE_NUMBER.
func (s *Store) attachInfohashes(ctx context.Context, ds []Download) error {
if len(ds) == 0 {
return nil
}
const chunkSize = 500
byID := make(map[string][]Infohash, len(ds))
for start := 0; start < len(ds); start += chunkSize {
end := min(start+chunkSize, len(ds))
var args []any
ids := make([]string, 0, end-start)
for i := start; i < end; i++ {
ids = append(ids, ds[i].ID)
}
ph := placeholders(&args, ids)
var rows []Infohash
if err := s.DB.SelectContext(ctx, &rows,
`SELECT download_id, infohash, kind FROM download_infohash
WHERE download_id IN (`+ph+`) ORDER BY kind, infohash`,
args...); err != nil {
return fmt.Errorf("attach infohashes: %w", err)
}
for _, r := range rows {
byID[r.DownloadID] = append(byID[r.DownloadID], r)
}
}
for i := range ds {
ds[i].Infohashes = byID[ds[i].ID]
}
return nil
}
// attachInfohashesOne подгружает хеши одной загрузки (в т.ч. внутри tx).
func attachInfohashesOne(ctx context.Context, q sqlx.QueryerContext, d *Download) error {
if err := sqlx.SelectContext(ctx, q, &d.Infohashes,
`SELECT download_id, infohash, kind FROM download_infohash
WHERE download_id = ? ORDER BY kind, infohash`, d.ID); err != nil {
return fmt.Errorf("attach infohashes: %w", err)
}
return nil
}
// normalizeHashes нормализует и дедуплицирует хеши, отбрасывая пустые.
func normalizeHashes(hashes []string) []string {
out := make([]string, 0, len(hashes))
for _, h := range hashes {
h = NormalizeHash(h)
if h == "" || slices.Contains(out, h) {
continue
}
out = append(out, h)
}
return out
}
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
func nullArg(s string) any {
if s == "" {
+314 -38
View File
@@ -2,6 +2,9 @@ package store
import (
"context"
"errors"
"strings"
"sync"
"testing"
)
@@ -15,24 +18,39 @@ func newTestStore(t *testing.T) *Store {
return st
}
func newDownloading(infohash string) *Download {
func newDownloading() *Download {
return &Download{
SourceType: SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
SourceRef: "magnet:?xt=urn:btih:test",
Context: "ctx",
Infohash: NullString(infohash),
IdempotencyKey: NullString(infohash),
State: StateDownloading,
}
}
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
// существующую активную — ошибка теста.
func mustCreate(t *testing.T, st *Store, infohash string) string {
t.Helper()
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
if err != nil {
t.Fatalf("create: %v", err)
}
if existing != nil {
t.Fatalf("unexpected dedup to %s", existing.ID)
}
return d.ID
}
func TestCreateAndGetDownload(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id, err := st.CreateDownload(ctx, newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
if err != nil {
t.Fatalf("create: %v", err)
const ih = "AABBCCDDEEFF00112233445566778899aabbccdd" // смешанный регистр — нормализуется
id := mustCreate(t, st, ih)
if len(id) != 26 || id != strings.ToLower(id) {
t.Errorf("id = %q, want lowercase ULID (26 chars)", id)
}
got, err := st.GetDownload(ctx, id)
@@ -48,6 +66,12 @@ func TestCreateAndGetDownload(t *testing.T) {
if got.CreatedAt == "" {
t.Error("created_at пуст")
}
if len(got.Infohashes) != 1 {
t.Fatalf("infohashes = %v, want 1", got.Infohashes)
}
if h := got.Infohashes[0]; h.Infohash != strings.ToLower(ih) || h.Kind != HashV1 {
t.Errorf("infohash = %+v, want lowercase v1", h)
}
}
func TestFindActiveByInfohash(t *testing.T) {
@@ -59,10 +83,7 @@ func TestFindActiveByInfohash(t *testing.T) {
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
}
id, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, st, ih)
d, err := st.FindActiveByInfohash(ctx, ih)
if err != nil {
t.Fatal(err)
@@ -70,6 +91,9 @@ func TestFindActiveByInfohash(t *testing.T) {
if d == nil || d.ID != id {
t.Fatalf("активная задача не найдена: %v", d)
}
if len(d.Infohashes) != 1 {
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
}
}
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
@@ -81,12 +105,9 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
t.Run(string(st), func(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
ih := "33333333333333333333333333333333333333" + string(st[0:2])
const ih = "3333333333333333333333333333333333333333"
id, err := store.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, store, ih)
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
t.Fatal(err)
}
@@ -97,17 +118,15 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
}
}
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
// тот же infohash заново (повторная закачка спустя время).
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
// новой задачей (повторная закачка спустя время) — активность выводится
// только из state.
func TestTerminalReleasesInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "2222222222222222222222222222222222222222"
id, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
t.Fatal(err)
}
@@ -116,39 +135,291 @@ func TestTerminalReleasesInfohash(t *testing.T) {
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
}
// Ключ идемпотентности снят.
got, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatal(err)
}
if got.IdempotencyKey.Valid {
t.Errorf("idempotency_key должен быть NULL, получили %q", got.IdempotencyKey.String)
}
if got.ErrorCode.String != "qbit_add" {
t.Errorf("error_code = %q", got.ErrorCode.String)
}
// Тот же infohash заводится заново — unique index не мешает.
id2, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatalf("повторное добавление после терминального должно проходить: %v", err)
}
// Тот же infohash заводится заново новой задачей.
id2 := mustCreate(t, st, ih)
if id2 == id {
t.Error("ожидалась новая задача")
}
}
// Две активные задачи с одним ключом идемпотентности недопустимы.
func TestActiveDuplicateRejected(t *testing.T) {
// Повторный приём при активной задаче дедуплицируется: вторая вставка не
// создаёт строку, а возвращает существующую активную задачу.
func TestActiveDuplicateDeduplicated(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "3333333333333333333333333333333333333333"
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
id := mustCreate(t, st, ih)
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err == nil {
t.Error("ожидалось нарушение уникальности idempotency_key")
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
}
if len(existing.Infohashes) != 1 {
t.Fatalf("у существующей задачи не подгружены хеши: %v", existing.Infohashes)
}
}
// Дедуп ловит совпадение по ЛЮБОМУ из хешей: активная задача знает v1+v2,
// новый приём приходит только с v2.
func TestDedupByAnyHash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const v1 = "4444444444444444444444444444444444444444"
const v2 = "6666666666666666666666666666666666666666666666666666666666666666"
id := mustCreate(t, st, v1)
if err := st.AddInfohashes(ctx, id, []string{v2}); err != nil {
t.Fatal(err)
}
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
if err != nil {
t.Fatal(err)
}
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп по v2-хешу на %s, получили %v", id, existing)
}
// Хеши задачи: v1 раньше v2 (стабильный порядок), повторное добавление
// идемпотентно.
if err := st.AddInfohashes(ctx, id, []string{v2, v1}); err != nil {
t.Fatal(err)
}
got, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatal(err)
}
if len(got.Infohashes) != 2 || got.Infohashes[0].Kind != HashV1 || got.Infohashes[1].Kind != HashV2 {
t.Fatalf("infohashes = %+v, want [v1, v2]", got.Infohashes)
}
if got.PrimaryInfohash() != v1 {
t.Errorf("primary = %q, want v1", got.PrimaryInfohash())
}
}
// ActivateIfNoOtherActive отказывает, когда хешем владеет другая активная
// задача, и пропускает, когда владелец ушёл в терминал.
func TestActivateIfNoOtherActive(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "5555555555555555555555555555555555555555"
id1 := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id1, StateFailed, "magnet_timeout", ""); err != nil {
t.Fatal(err)
}
// Хеш перехватила новая активная задача.
id2 := mustCreate(t, st, ih)
err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", "")
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
}
if d, _ := st.GetDownload(ctx, id1); d.State != StateFailed {
t.Fatalf("задача не должна была активироваться: %s", d.State)
}
// Владелец завершился → активация проходит.
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil {
t.Fatal(err)
}
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
t.Fatalf("активация после ухода владельца: %v", err)
}
if d, _ := st.GetDownload(ctx, id1); d.State != StateDownloading {
t.Fatalf("state = %s, want downloading", d.State)
}
}
// Дедуп-ветка дописывает существующей задаче недостающие хеши гибридного
// вызова — второй хеш не теряется, и последующий приём по нему дедупится.
func TestCreateDedupTopsUpHashes(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const v1 = "aaaa111111111111111111111111111111111111"
const v2 = "bbbb222222222222222222222222222222222222222222222222222222222222"
id := mustCreate(t, st, v1)
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2})
if err != nil {
t.Fatal(err)
}
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
}
if len(existing.Infohashes) != 2 {
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
}
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
if err != nil {
t.Fatal(err)
}
if byV2 == nil || byV2.ID != id {
t.Fatalf("дедуп по донесённому v2 не сработал: %v", byV2)
}
}
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
// не дописывается — возвращается ErrInfohashTaken.
func TestAddInfohashesGuard(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const h1 = "cccc111111111111111111111111111111111111"
const h2 = "dddd222222222222222222222222222222222222"
a := mustCreate(t, st, h1)
_ = mustCreate(t, st, h2) // активный владелец h2
err := st.AddInfohashes(ctx, a, []string{h2})
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
}
got, _ := st.GetDownload(ctx, a)
if len(got.Infohashes) != 1 || got.Infohashes[0].Infohash != h1 {
t.Fatalf("чужой хеш не должен был дописаться: %+v", got.Infohashes)
}
// Хеш терминального владельца дописывается свободно.
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil {
t.Fatal(err)
}
b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
}
}
// Гард активации не маскирует конфликт самой активируемой задачей: stuck
// (нетерминальна) с более новым id не должна перекрыть старшего владельца.
func TestActivateExcludesSelf(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const h = "ffff111111111111111111111111111111111111"
older := mustCreate(t, st, h) // активный владелец, id старше
newer := mustCreate(t, st, "0000222222222222222222222222222222222222")
if err := st.SetDownloadState(ctx, newer, StateStuck, "stalled", ""); err != nil {
t.Fatal(err)
}
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
// такого не создаст, но обязан не маскировать).
if _, err := st.DB.ExecContext(ctx,
`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, 'v1', ?)`,
newer, h, FormatTime(Now())); err != nil {
t.Fatal(err)
}
err := st.ActivateIfNoOtherActive(ctx, newer, StateDownloading, "", "")
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("гард замаскирован self-строкой: ожидался ErrInfohashTaken, получили %v", err)
}
_ = older
}
// Механический бэкстоп: публичный SetDownloadState не оживляет терминальную
// задачу — возврат в активное только через ActivateIfNoOtherActive.
func TestSetDownloadStateRejectsRevive(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id := mustCreate(t, st, "1234511111111111111111111111111111111111")
if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
t.Fatal(err)
}
if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
t.Fatal("терминал→активное мимо гарда должно отклоняться")
}
if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
t.Fatalf("state = %s, want failed (без изменений)", d.State)
}
// Терминал→терминал разрешён (например, сверка double-terminal переходов).
if err := st.SetDownloadState(ctx, id, StateDeleted, "", ""); err != nil {
t.Fatalf("терминал→терминал должен проходить: %v", err)
}
// Штатный путь оживления работает.
if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil {
t.Fatalf("оживление через гард: %v", err)
}
}
// Конкурентные создания одного infohash сериализуются write-транзакциями
// (_txlock=immediate): ровно одна задача создаётся, остальные дедупятся.
func TestConcurrentCreateDedup(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "abcde11111111111111111111111111111111111"
const n = 8
ids := make(chan string, n)
errs := make(chan error, n)
var wg sync.WaitGroup
for range n {
wg.Add(1)
go func() {
defer wg.Done()
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
if err != nil {
errs <- err
return
}
if existing != nil {
ids <- existing.ID
} else {
ids <- d.ID
}
}()
}
wg.Wait()
close(ids)
close(errs)
for err := range errs {
t.Fatalf("конкурентное создание упало: %v", err)
}
uniq := map[string]bool{}
for id := range ids {
uniq[id] = true
}
if len(uniq) != 1 {
t.Fatalf("создано %d разных задач на один infohash, want 1: %v", len(uniq), uniq)
}
all, _ := st.ListDownloads(ctx)
if len(all) != 1 {
t.Fatalf("в БД %d строк, want 1", len(all))
}
}
func TestExistsByInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "7777777777777777777777777777777777777777"
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || ok {
t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
}
id := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id, StateDone, "", ""); err != nil {
t.Fatal(err)
}
// Exists видит и терминальные (в отличие от FindActive).
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
}
}
@@ -156,8 +427,8 @@ func TestListAndByState(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id1, _ := st.CreateDownload(ctx, newDownloading("4444444444444444444444444444444444444444"))
id2, _ := st.CreateDownload(ctx, newDownloading("5555555555555555555555555555555555555555"))
id1 := mustCreate(t, st, "4444444444444444444444444444444444444444")
id2 := mustCreate(t, st, "5555555555555555555555555555555555555555")
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
t.Fatal(err)
}
@@ -169,6 +440,11 @@ func TestListAndByState(t *testing.T) {
if len(all) != 2 {
t.Fatalf("ListDownloads = %d, want 2", len(all))
}
for _, d := range all {
if len(d.Infohashes) != 1 {
t.Fatalf("у %s не подгружены хеши", d.ID)
}
}
dl, err := st.ListDownloadsByState(ctx, StateDownloading)
if err != nil {
+6
View File
@@ -6,3 +6,9 @@ import "errors"
// в него sql.ErrNoRows у источника, чтобы выше по коду не торчал database/sql,
// а потребители матчили причину через errors.Is(err, store.ErrNotFound).
var ErrNotFound = errors.New("not found")
// ErrInfohashTaken — инвариант «не более одной активной загрузки на infohash»:
// возврат задачи в активное состояние отклонён, потому что хешем уже владеет
// другая активная задача (ActivateIfNoOtherActive). Вызывающие отличают этот
// штатный конфликт от сбоя через errors.Is.
var ErrInfohashTaken = errors.New("infohash owned by another active download")
+9 -4
View File
@@ -41,7 +41,7 @@ func statesInGroup(g StateGroup) []State {
// ListFilter — параметры страницы списка загрузок.
type ListFilter struct {
Group StateGroup // группа состояний ("" / all — без сужения)
Query string // поиск по названию/infohash/контексту (подстрока)
Query string // поиск по id/названию/infohash/контексту (подстрока)
IncludeDeleted bool // показывать состояние deleted (только под all)
Limit int // размер страницы
Offset int // смещение страницы
@@ -71,11 +71,13 @@ func listWhere(f ListFilter) (string, []any) {
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
// кириллические названия ищем как введены.
like := "%" + q + "%"
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
conds = append(conds, "(download.id LIKE ? COLLATE NOCASE "+
"OR source_ref LIKE ? COLLATE NOCASE "+
"OR display_name LIKE ? COLLATE NOCASE "+
"OR context LIKE ? COLLATE NOCASE "+
"OR IFNULL(infohash,'') LIKE ? COLLATE NOCASE)")
args = append(args, like, like, like, like)
"OR EXISTS (SELECT 1 FROM download_infohash dh "+
"WHERE dh.download_id = download.id AND dh.infohash LIKE ? COLLATE NOCASE))")
args = append(args, like, like, like, like, like)
}
if len(conds) == 0 {
@@ -109,5 +111,8 @@ LIMIT ? OFFSET ?`
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
return nil, 0, fmt.Errorf("list downloads page: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, 0, fmt.Errorf("list downloads page: %w", err)
}
return out, total, nil
}
+15 -12
View File
@@ -11,25 +11,28 @@ import (
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
// mkDownload заводит загрузку в заданном состоянии с display_name.
func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 {
func mkDownload(t *testing.T, st *Store, n int, state State, display string) string {
t.Helper()
ctx := context.Background()
d := newDownloading(hashN(n))
d := newDownloading()
d.DisplayName = display
id, err := st.CreateDownload(ctx, d)
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)})
if err != nil {
t.Fatalf("create #%d: %v", n, err)
}
if existing != nil {
t.Fatalf("create #%d: unexpected dedup", n)
}
if state != StateDownloading {
if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil {
if err := st.SetDownloadState(ctx, d.ID, state, "", ""); err != nil {
t.Fatalf("set state #%d: %v", n, err)
}
}
return id
return d.ID
}
func ids(ds []Download) []int64 {
out := make([]int64, len(ds))
func ids(ds []Download) []string {
out := make([]string, len(ds))
for i, d := range ds {
out[i] = d.ID
}
@@ -51,7 +54,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
t.Fatal(err)
}
if total != 1 || len(page) != 1 || page[0].ID != review {
t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review)
t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review)
}
// all: deleted скрыт по умолчанию.
@@ -110,7 +113,7 @@ func TestListDownloadsPageSearch(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(page) != 1 || page[0].Infohash.String != hashN(2) {
if len(page) != 1 || page[0].PrimaryInfohash() != hashN(2) {
t.Fatalf("search by infohash = %v", ids(page))
}
}
@@ -144,7 +147,7 @@ func TestListDownloadsPageOrderAndPagination(t *testing.T) {
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
got := ids(page)
want := []int64{id3, id2, id1}
want := []string{id3, id2, id1}
for i := range want {
if got[i] != want[i] {
t.Fatalf("порядок = %v, want %v", got, want)
@@ -178,7 +181,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
var idList []int64
var idList []string
for i := 1; i <= 3; i++ {
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
@@ -191,7 +194,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
t.Fatal(err)
}
got := ids(page)
want := []int64{idList[2], idList[1], idList[0]} // id DESC
want := []string{idList[2], idList[1], idList[0]} // id DESC
for i := range want {
if got[i] != want[i] {
t.Fatalf("tie-break порядок = %v, want %v", got, want)
+200
View File
@@ -0,0 +1,200 @@
package store
import (
"context"
"database/sql"
"testing"
"github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3"
)
// TestUlidMigration прогоняет миграцию 0006 на фикстурной БД со старой схемой
// (числовые id, download.infohash + idempotency_key) и проверяет: FK-связи
// сохранены, порядок по id соответствует created_at, хеши разнесены в
// download_infohash, старые столбцы удалены.
func TestUlidMigration(t *testing.T) {
dbPath := t.TempDir() + "/legacy.db"
// Legacy-БД: схема до 0006 + строки с числовыми id.
legacy, err := sqlx.Connect("sqlite",
"file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open legacy: %v", err)
}
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatal(err)
}
if err := goose.UpTo(legacy.DB, "migrations", 5); err != nil {
t.Fatalf("migrate to v5: %v", err)
}
const (
v1hash = "aabbccddeeff00112233445566778899aabbccdd"
v2hash = "6666666666666666666666666666666666666666666666666666666666666666"
)
seed := []string{
// #1 done с v1-хешем (ключ снят терминалом), #2 активная с v2-хешем,
// #3 без хеша; #2 и #3 в одну секунду — порядок должен сохраниться.
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
VALUES (1, 'magnet', 'magnet:?xt=urn:btih:` + v1hash + `', 'Old One', 'done', '` + v1hash + `', NULL,
'2026-01-01 10:00:00', '2026-01-01 11:00:00')`,
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
VALUES (2, 'magnet', 'magnet:?xt=urn:btmh:1220` + v2hash + `', 'Two', 'downloading', '` + v2hash + `', '` + v2hash + `',
'2026-02-01 10:00:00', '2026-02-01 10:00:00')`,
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
VALUES (3, 'magnet', 'magnet:?xt=urn:btih:cafe', 'Three', 'failed', NULL, NULL,
'2026-02-01 10:00:00', '2026-02-01 10:00:00')`,
`INSERT INTO recognition (id, download_id, attempt_no, is_current, title, provider, provider_id, plan)
VALUES (10, 1, 1, 1, 'Fargo', 'tvdb', '269613', '{"type":"series"}')`,
`INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, chosen)
VALUES (20, 10, 'tvdb', '269613', 'Fargo', 1)`,
`INSERT INTO hint (id, download_id, text) VALUES (30, 1, 'второй сезон')`,
`INSERT INTO override (id, download_id, field, value) VALUES (40, 1, 'media_type', 'series')`,
`INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status)
VALUES (50, 1, 'b-1', '/d/a.mkv', '/m/A.mkv', 'video', 'linked')`,
}
for _, q := range seed {
if _, err := legacy.Exec(q); err != nil {
t.Fatalf("seed: %v\n%s", err, q)
}
}
if err := legacy.Close(); err != nil {
t.Fatal(err)
}
// Open прогоняет оставшиеся миграции (0006).
st, err := Open(dbPath)
if err != nil {
t.Fatalf("open with migration: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx := context.Background()
all, err := st.ListDownloads(ctx) // ORDER BY id DESC
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("downloads = %d, want 3", len(all))
}
// Хронология сохранена: DESC по id = [Three, Two, Old One] (при равных
// секундах #2/#3 порядок старых id держит monotonic-энтропия).
if all[0].DisplayName != "Three" || all[1].DisplayName != "Two" || all[2].DisplayName != "Old One" {
t.Fatalf("порядок по id разъехался с хронологией: %s, %s, %s",
all[0].DisplayName, all[1].DisplayName, all[2].DisplayName)
}
one, two, three := all[2], all[1], all[0]
// Хеши разнесены с верным kind; created_at/updated_at сохранены.
if len(one.Infohashes) != 1 || one.Infohashes[0].Infohash != v1hash || one.Infohashes[0].Kind != HashV1 {
t.Fatalf("хеши #1 = %+v", one.Infohashes)
}
if len(two.Infohashes) != 1 || two.Infohashes[0].Infohash != v2hash || two.Infohashes[0].Kind != HashV2 {
t.Fatalf("хеши #2 = %+v", two.Infohashes)
}
if len(three.Infohashes) != 0 {
t.Fatalf("хеши #3 = %+v, want пусто", three.Infohashes)
}
// Метки переформатированы миграцией 0008 в RFC 3339 (UTC, суффикс Z) —
// момент сохранён; заодно канарейка, что backfill 0008 отработал на данных.
if one.CreatedAt != "2026-01-01T10:00:00Z" || one.UpdatedAt != "2026-01-01T11:00:00Z" {
t.Fatalf("метки #1 = %q / %q", one.CreatedAt, one.UpdatedAt)
}
// FK-связи: распознавание/кандидаты/подсказки/правки/ссылки указывают на #1.
rec, err := st.GetCurrentRecognition(ctx, one.ID)
if err != nil || rec == nil || rec.Title.String != "Fargo" {
t.Fatalf("recognition #1 = %+v, %v", rec, err)
}
cands, err := st.ListCandidatesByRecognition(ctx, rec.ID)
if err != nil || len(cands) != 1 || !cands[0].Chosen {
t.Fatalf("candidates = %+v, %v", cands, err)
}
hints, err := st.ListHints(ctx, one.ID)
if err != nil || len(hints) != 1 || hints[0] != "второй сезон" {
t.Fatalf("hints = %v, %v", hints, err)
}
ovr, err := st.ListOverrides(ctx, one.ID)
if err != nil || ovr["media_type"] != "series" {
t.Fatalf("overrides = %v, %v", ovr, err)
}
batch, err := st.LatestBatchID(ctx, one.ID)
if err != nil || batch != "b-1" {
t.Fatalf("batch = %q, %v", batch, err)
}
// Активность выводится из state: v2-хеш занят активной #2.
active, err := st.FindActiveByInfohash(ctx, v2hash)
if err != nil || active == nil || active.ID != two.ID {
t.Fatalf("active by v2 = %+v, %v", active, err)
}
// Старые столбцы удалены.
var cols []string
if err := st.DB.Select(&cols, `SELECT name FROM pragma_table_info('download')`); err != nil {
t.Fatal(err)
}
for _, c := range cols {
if c == "infohash" || c == "idempotency_key" {
t.Fatalf("столбец %q должен быть удалён", c)
}
}
}
// TestRFC3339TimeMigration проверяет миграцию 0008: при Up метки переформатиру-
// ются из datetime('now') (пробел) в RFC 3339 (UTC, суффикс Z), при Down — обра-
// тно; NULL source_added_at не затрагивается, момент времени сохраняется.
func TestRFC3339TimeMigration(t *testing.T) {
dbPath := t.TempDir() + "/time.db"
db, err := sqlx.Connect("sqlite",
"file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = db.Close() }()
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatal(err)
}
// Схема до 0008: DEFAULT (datetime('now')), формат меток с пробелом.
if err := goose.UpTo(db.DB, "migrations", 7); err != nil {
t.Fatalf("up to 7: %v", err)
}
// Строка со старой меткой; source_added_at = NULL (проверяем щажение).
if _, err := db.Exec(`INSERT INTO download (id, source_type, source_ref, state, created_at, updated_at)
VALUES ('d1', 'magnet', 'magnet:x', 'downloading', '2026-03-01 12:00:00', '2026-03-01 13:00:00')`); err != nil {
t.Fatalf("seed: %v", err)
}
read := func() (string, string, sql.NullString) {
var c, u string
var sa sql.NullString
if err := db.QueryRowx(
`SELECT created_at, updated_at, source_added_at FROM download WHERE id='d1'`).
Scan(&c, &u, &sa); err != nil {
t.Fatalf("read: %v", err)
}
return c, u, sa
}
// Up 0008 → RFC 3339.
if err := goose.UpTo(db.DB, "migrations", 8); err != nil {
t.Fatalf("up to 8: %v", err)
}
if c, u, sa := read(); c != "2026-03-01T12:00:00Z" || u != "2026-03-01T13:00:00Z" || sa.Valid {
t.Fatalf("после up: %q / %q / sa.Valid=%v, want RFC 3339 и NULL sa", c, u, sa.Valid)
}
// Down 0008 → формат с пробелом.
if err := goose.DownTo(db.DB, "migrations", 7); err != nil {
t.Fatalf("down to 7: %v", err)
}
if c, u, sa := read(); c != "2026-03-01 12:00:00" || u != "2026-03-01 13:00:00" || sa.Valid {
t.Fatalf("после down: %q / %q / sa.Valid=%v, want формат с пробелом и NULL sa", c, u, sa.Valid)
}
}
@@ -0,0 +1,439 @@
// Package migrations содержит Go-миграции goose (SQL-миграции лежат рядом
// *.sql-файлами и прогоняются из embed FS пакета store). Регистрация — в
// init(); чтобы она сработала, пакет blank-импортируется из store.
package migrations
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/pressly/goose/v3"
"git.vakhrushev.me/av/jellybit/internal/ident"
)
func init() {
goose.AddMigrationContext(upUlidIdentity, downUlidIdentity)
}
// upUlidIdentity переводит все таблицы на ULID-идентификаторы (TEXT PK),
// разносит download.infohash в download_infohash и убирает idempotency_key
// (см. openspec/changes/ulid-identity/design.md, D6).
//
// Работает при включённых foreign_keys (PRAGMA внутри транзакции — no-op),
// поэтому порядок жёсткий: новые таблицы и данные — родители первыми, DROP
// старых — дети первыми, затем RENAME (SQLite ≥ 3.25 переписывает REFERENCES
// в ссылающихся таблицах).
func upUlidIdentity(ctx context.Context, tx *sql.Tx) error {
if err := createNewTables(ctx, tx); err != nil {
return err
}
downloadIDs, err := migrateDownloads(ctx, tx)
if err != nil {
return err
}
recognitionIDs, err := migrateRecognitions(ctx, tx, downloadIDs)
if err != nil {
return err
}
if err := migrateHints(ctx, tx, downloadIDs); err != nil {
return err
}
if err := migrateOverrides(ctx, tx, downloadIDs); err != nil {
return err
}
if err := migrateCandidates(ctx, tx, recognitionIDs); err != nil {
return err
}
if err := migrateFileLinks(ctx, tx, downloadIDs); err != nil {
return err
}
// Старые таблицы: дети первыми, родитель последним (FK включены).
for _, stmt := range []string{
`DROP TABLE file_link`,
`DROP TABLE metadata_candidate`,
`DROP TABLE override`,
`DROP TABLE hint`,
`DROP TABLE recognition`,
`DROP TABLE download`,
`ALTER TABLE download_new RENAME TO download`,
`ALTER TABLE recognition_new RENAME TO recognition`,
`ALTER TABLE hint_new RENAME TO hint`,
`ALTER TABLE override_new RENAME TO override`,
`ALTER TABLE metadata_candidate_new RENAME TO metadata_candidate`,
`ALTER TABLE file_link_new RENAME TO file_link`,
// Индексы — после переименований, с каноническими именами (старые
// одноимённые ушли вместе со старыми таблицами).
`CREATE INDEX idx_download_state ON download (state)`,
`CREATE INDEX idx_download_infohash_download ON download_infohash (download_id)`,
`CREATE INDEX idx_recognition_download ON recognition (download_id)`,
`CREATE INDEX idx_hint_download ON hint (download_id)`,
`CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id)`,
`CREATE INDEX idx_file_link_download ON file_link (download_id)`,
`CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id)`,
} {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("ulid migration: %q: %w", stmt, err)
}
}
return checkForeignKeys(ctx, tx)
}
// downUlidIdentity: обратной миграции нет — ULID → числовые id невосстановимы.
// Откат — восстановление файла БД из копии (см. design.md, Migration Plan).
func downUlidIdentity(context.Context, *sql.Tx) error {
return fmt.Errorf("ulid identity migration is irreversible; restore the database file from a backup")
}
func createNewTables(ctx context.Context, tx *sql.Tx) error {
for _, stmt := range []string{
`CREATE TABLE download_new (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL,
source_ref TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
context TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL,
error_code TEXT,
error_msg TEXT,
source_miss_count INTEGER NOT NULL DEFAULT 0,
source_added_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE download_infohash (
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
infohash TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (infohash, download_id)
)`,
`CREATE TABLE recognition_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
attempt_no INTEGER NOT NULL DEFAULT 1,
is_current INTEGER NOT NULL DEFAULT 1,
media_type TEXT,
title TEXT,
original_title TEXT,
year INTEGER,
provider TEXT,
provider_id TEXT,
confidence REAL,
reasons TEXT NOT NULL DEFAULT '[]',
raw_llm TEXT,
plan TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE hint_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE override_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
field TEXT NOT NULL,
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (download_id, field)
)`,
`CREATE TABLE metadata_candidate_new (
id TEXT PRIMARY KEY,
recognition_id TEXT NOT NULL REFERENCES recognition_new (id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
title TEXT,
year INTEGER,
chosen INTEGER NOT NULL DEFAULT 0,
url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE file_link_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
apply_batch_id TEXT NOT NULL,
src_path TEXT NOT NULL,
dst_path TEXT NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
} {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("ulid migration: create tables: %w", err)
}
}
return nil
}
// idMap строит маппинг «старый int id → ULID» для таблицы: строки читаются в
// порядке старого id (хронология), timestamp-часть ULID — из created_at, так
// что лексикографический порядок новых id сохраняет исторический. Равные
// секунды created_at упорядочивает monotonic-энтропия по порядку обхода.
func idMap(ctx context.Context, tx *sql.Tx, table string) (map[int64]string, error) {
rows, err := tx.QueryContext(ctx,
`SELECT id, created_at FROM `+table+` ORDER BY id`) //nolint:gosec // имена таблиц — константы этого файла
if err != nil {
return nil, fmt.Errorf("ulid migration: read %s ids: %w", table, err)
}
defer func() { _ = rows.Close() }()
out := map[int64]string{}
for rows.Next() {
var id int64
var createdAt string
if err := rows.Scan(&id, &createdAt); err != nil {
return nil, fmt.Errorf("ulid migration: scan %s id: %w", table, err)
}
out[id] = ident.NewIDAt(parseCreatedAt(createdAt))
}
return out, rows.Err()
}
// parseCreatedAt разбирает метку datetime('now') (UTC); непарсибельная метка
// → текущее время (порядок в пределах таблицы всё равно монотонен).
func parseCreatedAt(s string) time.Time {
t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC)
if err != nil {
return time.Now()
}
return t
}
func migrateDownloads(ctx context.Context, tx *sql.Tx) (map[int64]string, error) {
ids, err := idMap(ctx, tx, "download")
if err != nil {
return nil, err
}
// Сначала вычитываем всё и закрываем курсор, потом вставляем: Tx держит
// одно соединение, Exec при открытых Rows на нём невозможен.
type downloadRow struct {
id int64
sourceType, sourceRef, displayName string
contextText, state string
infohash, errorCode, errorMsg sql.NullString
sourceMissCount int
sourceAddedAt sql.NullString
createdAt, updatedAt string
}
var all []downloadRow
rows, err := tx.QueryContext(ctx, `
SELECT id, source_type, source_ref, display_name, context, infohash, state,
error_code, error_msg, source_miss_count, source_added_at,
created_at, updated_at
FROM download ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("ulid migration: read downloads: %w", err)
}
for rows.Next() {
var r downloadRow
if err := rows.Scan(&r.id, &r.sourceType, &r.sourceRef, &r.displayName,
&r.contextText, &r.infohash, &r.state, &r.errorCode, &r.errorMsg,
&r.sourceMissCount, &r.sourceAddedAt, &r.createdAt, &r.updatedAt); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("ulid migration: scan download: %w", err)
}
all = append(all, r)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("ulid migration: iterate downloads: %w", err)
}
_ = rows.Close()
for _, r := range all {
if _, err := tx.ExecContext(ctx, `
INSERT INTO download_new (id, source_type, source_ref, display_name, context,
state, error_code, error_msg, source_miss_count,
source_added_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
ids[r.id], r.sourceType, r.sourceRef, r.displayName, r.contextText,
r.state, r.errorCode, r.errorMsg, r.sourceMissCount, r.sourceAddedAt,
r.createdAt, r.updatedAt); err != nil {
return nil, fmt.Errorf("ulid migration: insert download %d: %w", r.id, err)
}
if r.infohash.Valid && r.infohash.String != "" {
h := strings.ToLower(r.infohash.String)
if _, err := tx.ExecContext(ctx, `
INSERT INTO download_infohash (download_id, infohash, kind, created_at)
VALUES (?, ?, ?, ?)`,
ids[r.id], h, hashKind(h), r.createdAt); err != nil {
return nil, fmt.Errorf("ulid migration: insert infohash for %d: %w", r.id, err)
}
}
}
return ids, nil
}
// hashKind — вид инфохэша по длине hex: 40 — v1 (SHA-1), 64 — v2 (SHA-256).
func hashKind(h string) string {
if len(h) == 64 {
return "v2"
}
return "v1"
}
func migrateRecognitions(ctx context.Context, tx *sql.Tx, downloads map[int64]string) (map[int64]string, error) {
ids, err := idMap(ctx, tx, "recognition")
if err != nil {
return nil, err
}
if err := copyRows(ctx, tx, copySpec{
from: "recognition", to: "recognition_new",
cols: []string{"attempt_no", "is_current", "media_type", "title", "original_title", "year", "provider", "provider_id", "confidence", "reasons", "raw_llm", "plan", "created_at"},
ids: ids,
parent: parentRef{col: "download_id", ids: downloads},
}); err != nil {
return nil, err
}
return ids, nil
}
func migrateHints(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
ids, err := idMap(ctx, tx, "hint")
if err != nil {
return err
}
return copyRows(ctx, tx, copySpec{
from: "hint", to: "hint_new",
cols: []string{"text", "created_at"},
ids: ids,
parent: parentRef{col: "download_id", ids: downloads},
})
}
func migrateOverrides(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
ids, err := idMap(ctx, tx, "override")
if err != nil {
return err
}
return copyRows(ctx, tx, copySpec{
from: "override", to: "override_new",
cols: []string{"field", "value", "created_at"},
ids: ids,
parent: parentRef{col: "download_id", ids: downloads},
})
}
func migrateCandidates(ctx context.Context, tx *sql.Tx, recognitions map[int64]string) error {
ids, err := idMap(ctx, tx, "metadata_candidate")
if err != nil {
return err
}
return copyRows(ctx, tx, copySpec{
from: "metadata_candidate", to: "metadata_candidate_new",
cols: []string{"provider", "provider_id", "title", "year", "chosen", "url", "created_at"},
ids: ids,
parent: parentRef{col: "recognition_id", ids: recognitions},
})
}
func migrateFileLinks(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
ids, err := idMap(ctx, tx, "file_link")
if err != nil {
return err
}
return copyRows(ctx, tx, copySpec{
from: "file_link", to: "file_link_new",
cols: []string{"apply_batch_id", "src_path", "dst_path", "kind", "status", "created_at"},
ids: ids,
parent: parentRef{col: "download_id", ids: downloads},
})
}
// copySpec описывает перенос таблицы: собственный маппинг id, FK-родитель и
// прочие столбцы, копируемые как есть.
type copySpec struct {
from, to string
cols []string
ids map[int64]string
parent parentRef
}
type parentRef struct {
col string
ids map[int64]string
}
func copyRows(ctx context.Context, tx *sql.Tx, spec copySpec) error {
colList := strings.Join(spec.cols, ", ")
// Сначала вычитываем всё и закрываем курсор, потом вставляем (Exec при
// открытых Rows на соединении транзакции невозможен).
type rowData struct {
oldID, parentID int64
rest []any
}
var all []rowData
//nolint:gosec // имена таблиц/столбцов — константы этого файла
rows, err := tx.QueryContext(ctx, fmt.Sprintf(
`SELECT id, %s, %s FROM %s ORDER BY id`, spec.parent.col, colList, spec.from))
if err != nil {
return fmt.Errorf("ulid migration: read %s: %w", spec.from, err)
}
for rows.Next() {
r := rowData{rest: make([]any, len(spec.cols))}
dest := append([]any{&r.oldID, &r.parentID}, scanPtrs(r.rest)...)
if err := rows.Scan(dest...); err != nil {
_ = rows.Close()
return fmt.Errorf("ulid migration: scan %s: %w", spec.from, err)
}
all = append(all, r)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("ulid migration: iterate %s: %w", spec.from, err)
}
_ = rows.Close()
ph := strings.TrimSuffix(strings.Repeat("?, ", len(spec.cols)), ", ")
//nolint:gosec // имена таблиц/столбцов — константы этого файла
insert := fmt.Sprintf(`INSERT INTO %s (id, %s, %s) VALUES (?, ?, %s)`,
spec.to, spec.parent.col, colList, ph)
for _, r := range all {
newParent, ok := spec.parent.ids[r.parentID]
if !ok {
return fmt.Errorf("ulid migration: %s row %d references unknown %s %d",
spec.from, r.oldID, spec.parent.col, r.parentID)
}
args := append([]any{spec.ids[r.oldID], newParent}, r.rest...)
if _, err := tx.ExecContext(ctx, insert, args...); err != nil {
return fmt.Errorf("ulid migration: insert %s row %d: %w", spec.to, r.oldID, err)
}
}
return nil
}
// scanPtrs — указатели на элементы среза для rows.Scan (значения любого типа
// SQLite едут через any и вставляются обратно как есть).
func scanPtrs(vals []any) []any {
out := make([]any, len(vals))
for i := range vals {
out[i] = &vals[i]
}
return out
}
// checkForeignKeys — финальная самопроверка целостности после rebuild.
func checkForeignKeys(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
if err != nil {
return fmt.Errorf("ulid migration: foreign_key_check: %w", err)
}
defer func() { _ = rows.Close() }()
if rows.Next() {
var table string
var rowid, parent, fkid any
_ = rows.Scan(&table, &rowid, &parent, &fkid)
return fmt.Errorf("ulid migration: foreign key violation in %s after rebuild", table)
}
return rows.Err()
}
@@ -0,0 +1,10 @@
-- +goose Up
-- Размер разложенного файла (байт), фиксируется при линковке. Служит фолбэком
-- размера раздачи в веб-UI, когда торрента нет в qBittorrent (например,
-- состояние orphaned — файлы библиотеки последняя копия данных). Старые записи
-- получают 0 (бэкофилл не делаем: для done/сидирующих размер берётся из живого
-- снимка qBittorrent).
ALTER TABLE file_link ADD COLUMN size INTEGER NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE file_link DROP COLUMN size;
@@ -0,0 +1,202 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upRFC3339Time, downRFC3339Time)
}
// upRFC3339Time переводит хранение временных меток на RFC 3339 (UTC, суффикс Z,
// напр. «2006-01-02T15:04:05Z») и убирает `DEFAULT (datetime('now'))` со всех
// колонок: время отныне пишет только приложение (store.Now/FormatTime) — единая
// точка генерации, fail-loud при забытой вставке (NOT NULL без DEFAULT).
//
// SQLite не умеет ALTER COLUMN DROP DEFAULT, поэтому перестраиваем 7 таблиц по
// образцу миграции 0006 (FK включены): создаём *_new без DEFAULT на метках,
// копируем данные с переформатированием меток через strftime (id и связи НЕ
// меняются — момент времени сохраняется, ULID-корреляция цела), удаляем старые
// (дети первыми), переименовываем (SQLite ≥3.25 переписывает REFERENCES),
// пересоздаём индексы и проверяем целостность FK.
func upRFC3339Time(ctx context.Context, tx *sql.Tx) error {
return rebuildTimeColumns(ctx, tx, "", rfc3339Expr)
}
// downRFC3339Time возвращает прежний формат меток (`YYYY-MM-DD HH:MM:SS`) и
// `DEFAULT (datetime('now'))` на колонках. Обратима без потерь: strftime
// разбирает RFC 3339 (T/Z) и момент времени сохраняется.
func downRFC3339Time(ctx context.Context, tx *sql.Tx) error {
return rebuildTimeColumns(ctx, tx, " DEFAULT (datetime('now'))", sqliteExpr)
}
// rfc3339Expr / sqliteExpr — SQL-выражение переформатирования метки col в
// целевой формат (strftime корректно разбирает и пробел-, и T/Z-форму на входе).
func rfc3339Expr(col string) string {
return fmt.Sprintf("strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', %s)", col)
}
func sqliteExpr(col string) string {
return fmt.Sprintf("strftime('%%Y-%%m-%%d %%H:%%M:%%S', %s)", col)
}
// rebuildTimeColumns перестраивает все таблицы с временными метками: tsDefault —
// суффикс DEFAULT для колонок created_at/updated_at ("" — без дефолта); ts —
// выражение переформатирования метки при копировании.
func rebuildTimeColumns(ctx context.Context, tx *sql.Tx, tsDefault string, ts func(string) string) error {
stmts := make([]string, 0, 28)
stmts = append(stmts, createTimeTables(tsDefault)...)
stmts = append(stmts, copyTimeTables(ts)...)
stmts = append(stmts, dropRenameReindex...)
for _, stmt := range stmts {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("rfc3339 time migration: %q: %w", stmt, err)
}
}
return checkForeignKeys(ctx, tx)
}
// createTimeTables — CREATE *_new для всех таблиц; d подставляет DEFAULT на
// метки created_at/updated_at (прочие DEFAULT — ”, 0, '[]', 1 — неизменны).
func createTimeTables(d string) []string {
return []string{
fmt.Sprintf(`CREATE TABLE download_new (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL,
source_ref TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
context TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL,
error_code TEXT,
error_msg TEXT,
source_miss_count INTEGER NOT NULL DEFAULT 0,
source_added_at TEXT,
created_at TEXT NOT NULL%[1]s,
updated_at TEXT NOT NULL%[1]s
)`, d),
fmt.Sprintf(`CREATE TABLE download_infohash_new (
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
infohash TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL%[1]s,
PRIMARY KEY (infohash, download_id)
)`, d),
fmt.Sprintf(`CREATE TABLE recognition_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
attempt_no INTEGER NOT NULL DEFAULT 1,
is_current INTEGER NOT NULL DEFAULT 1,
media_type TEXT,
title TEXT,
original_title TEXT,
year INTEGER,
provider TEXT,
provider_id TEXT,
confidence REAL,
reasons TEXT NOT NULL DEFAULT '[]',
raw_llm TEXT,
plan TEXT,
created_at TEXT NOT NULL%[1]s
)`, d),
fmt.Sprintf(`CREATE TABLE hint_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL%[1]s
)`, d),
fmt.Sprintf(`CREATE TABLE override_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
field TEXT NOT NULL,
value TEXT NOT NULL,
created_at TEXT NOT NULL%[1]s,
UNIQUE (download_id, field)
)`, d),
fmt.Sprintf(`CREATE TABLE metadata_candidate_new (
id TEXT PRIMARY KEY,
recognition_id TEXT NOT NULL REFERENCES recognition_new (id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
title TEXT,
year INTEGER,
chosen INTEGER NOT NULL DEFAULT 0,
url TEXT,
created_at TEXT NOT NULL%[1]s
)`, d),
fmt.Sprintf(`CREATE TABLE file_link_new (
id TEXT PRIMARY KEY,
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
apply_batch_id TEXT NOT NULL,
src_path TEXT NOT NULL,
dst_path TEXT NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL%[1]s
)`, d),
}
}
// copyTimeTables — INSERT ... SELECT в *_new с переформатированием меток через
// ts(col). Списки колонок явные; strftime(NULL) = NULL — nullable source_added_at
// корректно щадится.
func copyTimeTables(ts func(string) string) []string {
return []string{
fmt.Sprintf(`INSERT INTO download_new
(id, source_type, source_ref, display_name, context, state, error_code,
error_msg, source_miss_count, source_added_at, created_at, updated_at)
SELECT id, source_type, source_ref, display_name, context, state, error_code,
error_msg, source_miss_count, %s, %s, %s
FROM download`, ts("source_added_at"), ts("created_at"), ts("updated_at")),
fmt.Sprintf(`INSERT INTO download_infohash_new (download_id, infohash, kind, created_at)
SELECT download_id, infohash, kind, %s FROM download_infohash`, ts("created_at")),
fmt.Sprintf(`INSERT INTO recognition_new
(id, download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
SELECT id, download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan, %s
FROM recognition`, ts("created_at")),
fmt.Sprintf(`INSERT INTO hint_new (id, download_id, text, created_at)
SELECT id, download_id, text, %s FROM hint`, ts("created_at")),
fmt.Sprintf(`INSERT INTO override_new (id, download_id, field, value, created_at)
SELECT id, download_id, field, value, %s FROM override`, ts("created_at")),
fmt.Sprintf(`INSERT INTO metadata_candidate_new
(id, recognition_id, provider, provider_id, title, year, chosen, url, created_at)
SELECT id, recognition_id, provider, provider_id, title, year, chosen, url, %s
FROM metadata_candidate`, ts("created_at")),
fmt.Sprintf(`INSERT INTO file_link_new
(id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
SELECT id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, %s
FROM file_link`, ts("created_at")),
}
}
// dropRenameReindex — удаление старых таблиц (дети первыми, FK включены),
// переименование *_new и пересоздание индексов (канонический набор из 0006).
var dropRenameReindex = []string{
`DROP TABLE file_link`,
`DROP TABLE metadata_candidate`,
`DROP TABLE override`,
`DROP TABLE hint`,
`DROP TABLE recognition`,
`DROP TABLE download_infohash`,
`DROP TABLE download`,
`ALTER TABLE download_new RENAME TO download`,
`ALTER TABLE download_infohash_new RENAME TO download_infohash`,
`ALTER TABLE recognition_new RENAME TO recognition`,
`ALTER TABLE hint_new RENAME TO hint`,
`ALTER TABLE override_new RENAME TO override`,
`ALTER TABLE metadata_candidate_new RENAME TO metadata_candidate`,
`ALTER TABLE file_link_new RENAME TO file_link`,
`CREATE INDEX idx_download_state ON download (state)`,
`CREATE INDEX idx_download_infohash_download ON download_infohash (download_id)`,
`CREATE INDEX idx_recognition_download ON recognition (download_id)`,
`CREATE INDEX idx_hint_download ON hint (download_id)`,
`CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id)`,
`CREATE INDEX idx_file_link_download ON file_link (download_id)`,
`CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id)`,
}
+89 -45
View File
@@ -7,12 +7,14 @@ import (
"errors"
"fmt"
"strings"
"git.vakhrushev.me/av/jellybit/internal/ident"
)
// Recognition — строка таблицы recognition (попытка распознавания).
type Recognition struct {
ID int64 `db:"id"`
DownloadID int64 `db:"download_id"`
ID string `db:"id"`
DownloadID string `db:"download_id"`
AttemptNo int `db:"attempt_no"`
IsCurrent bool `db:"is_current"`
MediaType sql.NullString `db:"media_type"`
@@ -41,54 +43,51 @@ func (r Recognition) ReasonList() []string {
// CreateRecognition вставляет новую попытку распознавания, помечая прежние
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
// Возвращает id новой записи. reasons сериализуется в JSON.
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (int64, error) {
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (string, error) {
reasonsJSON, err := json.Marshal(reasons)
if err != nil {
return 0, fmt.Errorf("marshal reasons: %w", err)
return "", fmt.Errorf("marshal reasons: %w", err)
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
return "", fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil {
return 0, fmt.Errorf("clear current recognitions: %w", err)
return "", fmt.Errorf("clear current recognitions: %w", err)
}
var nextAttempt int
if err := tx.GetContext(ctx, &nextAttempt,
`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
r.DownloadID); err != nil {
return 0, fmt.Errorf("next attempt_no: %w", err)
return "", fmt.Errorf("next attempt_no: %w", err)
}
r.ID = ident.NewID()
const q = `
INSERT INTO recognition
(download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
res, err := tx.ExecContext(ctx, q,
r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan)
if err != nil {
return 0, fmt.Errorf("insert recognition: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("recognition last insert id: %w", err)
(id, download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if _, err := tx.ExecContext(ctx, q,
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
FormatTime(Now())); err != nil {
return "", fmt.Errorf("insert recognition: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit recognition: %w", err)
return "", fmt.Errorf("commit recognition: %w", err)
}
return id, nil
return r.ID, nil
}
// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
// либо (nil, nil), если её ещё нет.
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*Recognition, error) {
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*Recognition, error) {
var r Recognition
err := s.DB.GetContext(ctx, &r,
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
@@ -105,16 +104,17 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*R
// --- Подсказки (hint) ---
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
func (s *Store) AddHint(ctx context.Context, downloadID int64, text string) error {
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
if _, err := s.DB.ExecContext(ctx,
`INSERT INTO hint (download_id, text) VALUES (?, ?)`, downloadID, text); err != nil {
`INSERT INTO hint (id, download_id, text, created_at) VALUES (?, ?, ?, ?)`,
ident.NewID(), downloadID, text, FormatTime(Now())); err != nil {
return fmt.Errorf("add hint: %w", err)
}
return nil
}
// ListHints возвращает подсказки загрузки в хронологическом порядке.
func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, error) {
func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, error) {
var out []string
if err := s.DB.SelectContext(ctx, &out,
`SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil {
@@ -126,18 +126,18 @@ func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, erro
// --- Ручные правки (override) ---
// SetOverride пиннит значение поля (upsert по (download_id, field)).
func (s *Store) SetOverride(ctx context.Context, downloadID int64, field, value string) error {
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
const q = `
INSERT INTO override (download_id, field, value) VALUES (?, ?, ?)
INSERT INTO override (id, download_id, field, value, created_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
if _, err := s.DB.ExecContext(ctx, q, downloadID, field, value); err != nil {
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value, FormatTime(Now())); err != nil {
return fmt.Errorf("set override %q: %w", field, err)
}
return nil
}
// ListOverrides возвращает запиненные правки загрузки как map[field]value.
func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error) {
func (s *Store) ListOverrides(ctx context.Context, downloadID string) (map[string]string, error) {
rows, err := s.DB.QueryxContext(ctx,
`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
if err != nil {
@@ -160,13 +160,14 @@ func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string
// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
type FileLink struct {
ID int64 `db:"id"`
DownloadID int64 `db:"download_id"`
ID string `db:"id"`
DownloadID string `db:"download_id"`
ApplyBatchID string `db:"apply_batch_id"`
SrcPath string `db:"src_path"`
DstPath string `db:"dst_path"`
Kind string `db:"kind"`
Status string `db:"status"`
Size int64 `db:"size"` // размер файла (байт), зафиксирован при линковке
CreatedAt string `db:"created_at"`
}
@@ -181,12 +182,13 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
}
defer func() { _ = tx.Rollback() }()
now := FormatTime(Now())
const q = `
INSERT INTO file_link (download_id, apply_batch_id, src_path, dst_path, kind, status)
VALUES (?, ?, ?, ?, ?, ?)`
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
for _, l := range links {
if _, err := tx.ExecContext(ctx, q,
l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size, now); err != nil {
return fmt.Errorf("insert file_link: %w", err)
}
}
@@ -202,7 +204,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
// раскладка забирает владение освободившимся путём, прежние записи перестают
// считаться целью при сверке. Затрагивает только активные статусы раскладки
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error {
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error {
if len(dstPaths) == 0 {
return nil
}
@@ -226,7 +228,7 @@ WHERE download_id != ?
// LatestBatchID возвращает apply_batch_id последнего применённого батча
// загрузки (для undo) либо пустую строку, если ссылок нет.
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
func (s *Store) LatestBatchID(ctx context.Context, downloadID string) (string, error) {
var batch string
err := s.DB.GetContext(ctx, &batch,
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
@@ -250,6 +252,47 @@ func (s *Store) ListFileLinksByBatch(ctx context.Context, batchID string) ([]Fil
return out, nil
}
// LayoutSizeByDownload возвращает суммарный размер разложенных файлов по каждой
// из загрузок ids: карта download_id → сумма байт. Учитываются только активные
// статусы раскладки (linked/copied/exists); superseded-пути принадлежат уже
// другой загрузке. Каждый dst_path считается один раз (повторный apply даёт
// новую строку 'exists' с тем же путём) — сначала схлопываем строки по пути,
// потом суммируем, иначе размер задвоился бы. Служит фолбэком размера раздачи в
// карточке, когда торрента нет в живом снимке. Загрузки без разложенных файлов
// в карте отсутствуют (читатель берёт 0).
func (s *Store) LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) {
if len(ids) == 0 {
return map[string]int64{}, nil
}
var args []any
ph := placeholders(&args, ids)
q := `SELECT download_id, SUM(size) AS total FROM (
SELECT download_id, dst_path, MAX(size) AS size FROM file_link
WHERE download_id IN (` + ph + `)
AND status IN ('linked', 'copied', 'exists')
GROUP BY download_id, dst_path
) GROUP BY download_id`
rows, err := s.DB.QueryxContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("layout size by download: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]int64, len(ids))
for rows.Next() {
var id string
var total int64
if err := rows.Scan(&id, &total); err != nil {
return nil, fmt.Errorf("layout size by download: scan: %w", err)
}
out[id] = total
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("layout size by download: %w", err)
}
return out, nil
}
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
if _, err := s.DB.ExecContext(ctx,
@@ -265,8 +308,8 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
// см. recognize), а не обязательно нативный id провайдера поиска.
type MetadataCandidate struct {
ID int64 `db:"id"`
RecognitionID int64 `db:"recognition_id"`
ID string `db:"id"`
RecognitionID string `db:"recognition_id"`
Provider string `db:"provider"`
ProviderID string `db:"provider_id"`
Title sql.NullString `db:"title"`
@@ -287,12 +330,13 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
}
defer func() { _ = tx.Rollback() }()
now := FormatTime(Now())
const q = `
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year, url)
VALUES (?, ?, ?, ?, ?, ?)`
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
for _, c := range cands {
if _, err := tx.ExecContext(ctx, q,
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL, now); err != nil {
return fmt.Errorf("insert candidate: %w", err)
}
}
@@ -303,7 +347,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
}
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]MetadataCandidate, error) {
var out []MetadataCandidate
if err := s.DB.SelectContext(ctx, &out,
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
@@ -313,21 +357,21 @@ func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID i
}
// GetCandidate возвращает кандидата по id либо (nil, nil).
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
func (s *Store) GetCandidate(ctx context.Context, id string) (*MetadataCandidate, error) {
var c MetadataCandidate
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get candidate %d: %w", id, err)
return nil, fmt.Errorf("get candidate %s: %w", id, err)
}
return &c, nil
}
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
// той же попытке распознавания.
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
+42 -39
View File
@@ -6,14 +6,9 @@ import (
"testing"
)
func seedDownload(t *testing.T, st *Store) int64 {
func seedDownload(t *testing.T, st *Store) string {
t.Helper()
id, err := st.CreateDownload(context.Background(),
newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
if err != nil {
t.Fatalf("seed download: %v", err)
}
return id
return mustCreate(t, st, "aabbccddeeff00112233445566778899aabbccdd")
}
func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
@@ -49,7 +44,7 @@ func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
t.Fatalf("get current: %v", err)
}
if cur.ID != id2 {
t.Errorf("current id = %d, want %d", cur.ID, id2)
t.Errorf("current id = %s, want %s", cur.ID, id2)
}
if cur.AttemptNo != 2 {
t.Errorf("attempt_no = %d, want 2", cur.AttemptNo)
@@ -159,15 +154,47 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
}
}
func TestLayoutSizeByDownload(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
other := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
// Активные статусы суммируются; superseded исключается; повторный apply даёт
// вторую строку с тем же dst_path — DISTINCT не даёт задвоить размер.
if err := st.CreateFileLinks(ctx, []FileLink{
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "linked", Size: 100},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/a.srt", DstPath: "/m/A.ru.srt", Kind: "subtitle", Status: "copied", Size: 50},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/b.mkv", DstPath: "/m/B.mkv", Kind: "video", Status: "exists", Size: 25},
{DownloadID: dl, ApplyBatchID: "b2", SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "exists", Size: 100},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/s.mkv", DstPath: "/m/S.mkv", Kind: "video", Status: "superseded", Size: 999},
}); err != nil {
t.Fatalf("create links: %v", err)
}
got, err := st.LayoutSizeByDownload(ctx, []string{dl, other})
if err != nil {
t.Fatalf("layout size: %v", err)
}
if got[dl] != 175 { // 100 + 50 + 25, повтор A.mkv и superseded не в счёт
t.Errorf("размер %s = %d, want 175", dl, got[dl])
}
if _, ok := got[other]; ok {
t.Errorf("загрузка без разложенных файлов не должна быть в карте: %v", got[other])
}
// Пустой список id — пустая карта, без ошибки.
empty, err := st.LayoutSizeByDownload(ctx, nil)
if err != nil || len(empty) != 0 {
t.Errorf("пустой ids: got %v, err %v", empty, err)
}
}
func TestSupersedeForeignLinks(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
owner := seedDownload(t, st)
foreign, err := st.CreateDownload(ctx,
newDownloading("bbccddeeff00112233445566778899aabbccddee"))
if err != nil {
t.Fatalf("seed foreign: %v", err)
}
foreign := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
shared := "/m/Movie (2024).mkv"
// foreign разложена по shared (linked) и по своему пути (exists);
@@ -244,7 +271,7 @@ func TestCandidates_Lifecycle(t *testing.T) {
for _, c := range got {
want := c.ID == chosenID
if c.Chosen != want {
t.Errorf("candidate %d chosen = %v, want %v", c.ID, c.Chosen, want)
t.Errorf("candidate %s chosen = %v, want %v", c.ID, c.Chosen, want)
}
}
@@ -262,33 +289,9 @@ func TestCandidates_Lifecycle(t *testing.T) {
}
}
func TestExistsByInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "aabbccddeeff00112233445566778899aabbccdd"
exists, err := st.ExistsByInfohash(ctx, ih)
if err != nil || exists {
t.Fatalf("пусто: exists=%v err=%v", exists, err)
}
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
t.Fatal(err)
}
exists, err = st.ExistsByInfohash(ctx, ih)
if err != nil || !exists {
t.Fatalf("после вставки: exists=%v err=%v", exists, err)
}
// Терминальное состояние тоже считается «видели» (не реусыновляем).
id, _ := st.CreateDownload(ctx, newDownloading("ffffffffffffffffffffffffffffffffffffffff"))
_ = st.SetDownloadState(ctx, id, StateDone, "", "")
if ex, _ := st.ExistsByInfohash(ctx, "ffffffffffffffffffffffffffffffffffffffff"); !ex {
t.Error("done-задача должна считаться существующей")
}
}
func TestGetCandidate_None(t *testing.T) {
st := newTestStore(t)
c, err := st.GetCandidate(context.Background(), 999)
c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz")
if err != nil || c != nil {
t.Errorf("want nil,nil; got %+v, %v", c, err)
}
+8 -1
View File
@@ -10,6 +10,9 @@ import (
"github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
// Go-миграции goose (регистрируются в init(); SQL-миграции — embed ниже).
_ "git.vakhrushev.me/av/jellybit/internal/store/migrations"
)
//go:embed migrations/*.sql
@@ -29,8 +32,12 @@ func Open(dbPath string) (*Store, error) {
}
}
// _txlock=immediate: каждая явная транзакция открывается как BEGIN
// IMMEDIATE — write-транзакция с самого начала. На этом держатся
// guarded-методы инварианта «одна активная загрузка на infohash»
// (check-then-write без гонок: SQLite сериализует писателей).
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
"file:%s?_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
dbPath,
)
db, err := sqlx.Connect("sqlite", dsn)
+30 -27
View File
@@ -4,12 +4,12 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -30,13 +30,13 @@ type Ingestor interface {
// Reviewer — операции ревью (worker.Worker).
type Reviewer interface {
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
Apply(ctx context.Context, id int64) error
Refine(ctx context.Context, id int64, hint string) error
SetType(ctx context.Context, id int64, mediaType string) error
Defer(ctx context.Context, id int64) error
Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
Apply(ctx context.Context, id string) error
Refine(ctx context.Context, id string, hint string) error
SetType(ctx context.Context, id string, mediaType string) error
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
}
// Config — параметры бота.
@@ -55,7 +55,7 @@ type Bot struct {
log *slog.Logger
mu sync.Mutex // защищает pending
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
pending map[int64]string // chatID → downloadID, ждущий подсказку
}
// New собирает бота поверх клиента Telegram.
@@ -71,7 +71,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
allowed: allowed,
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
log: log,
pending: map[int64]int64{},
pending: map[int64]string{},
}
}
@@ -129,7 +129,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
return
}
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
return
}
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
return
}
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
if res.Deduplicated {
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State)
}
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
}
@@ -173,8 +173,10 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
}
action, id, val := parseCallback(cq.Data)
if id == 0 {
b.answer(cq.ID, "")
if id == "" {
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
return
}
chatID := cq.Message.Chat.ID
@@ -201,7 +203,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "refine":
b.setPending(chatID, id)
b.answer(cq.ID, "Жду подсказку")
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
return
default:
b.answer(cq.ID, "")
@@ -218,7 +220,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
}
// refreshCard перечитывает задачу и обновляет карточку на месте.
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id string) {
rd, err := b.reviewer.ReviewData(ctx, id)
if err != nil {
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
@@ -239,7 +241,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
// --- Notifier (worker.Notifier) ---
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.NotifyEvent) {
rd, err := b.reviewer.ReviewData(ctx, downloadID)
if err != nil {
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
@@ -281,13 +283,13 @@ func (b *Bot) answer(callbackID, text string) {
}
}
func (b *Bot) setPending(chatID, id int64) {
func (b *Bot) setPending(chatID int64, id string) {
b.mu.Lock()
b.pending[chatID] = id
b.mu.Unlock()
}
func (b *Bot) takePending(chatID int64) (int64, bool) {
func (b *Bot) takePending(chatID int64) (string, bool) {
b.mu.Lock()
defer b.mu.Unlock()
id, ok := b.pending[chatID]
@@ -300,20 +302,21 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
// текст + download_id для корреляции с логами (полная ошибка уже там, на
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
func opErr(msg string, downloadID int64) string {
if downloadID > 0 {
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
func opErr(msg string, downloadID string) string {
if downloadID != "" {
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
}
return msg + "."
}
// parseCallback разбирает "action[:id[:value]]".
func parseCallback(data string) (action string, id int64, value string) {
// parseCallback разбирает "action[:id[:value]]". id валидируется как ULID
// (входная граница); невалидный/устаревший (числовой) → пустая строка.
func parseCallback(data string) (action string, id string, value string) {
parts := strings.Split(data, ":")
action = parts[0]
if len(parts) > 1 {
id, _ = strconv.ParseInt(parts[1], 10, 64)
id, _ = ident.Parse(parts[1])
}
if len(parts) > 2 {
value = parts[2]
+66 -42
View File
@@ -60,52 +60,55 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
type fakeReviewer struct {
data *worker.ReviewData
applied []int64
refined map[int64]string
typed map[int64]string
deferred []int64
canceled []int64
retried []int64
applied []string
refined map[string]string
typed map[string]string
deferred []string
canceled []string
retried []string
}
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
return f.data, nil
}
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
f.applied = append(f.applied, id)
return nil
}
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
if f.refined == nil {
f.refined = map[int64]string{}
f.refined = map[string]string{}
}
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
if f.typed == nil {
f.typed = map[int64]string{}
f.typed = map[string]string{}
}
f.typed[id] = t
return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
f.deferred = append(f.deferred, id)
return nil
}
func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
func (f *fakeReviewer) Cancel(_ context.Context, id string) error {
f.canceled = append(f.canceled, id)
return nil
}
func (f *fakeReviewer) Retry(_ context.Context, id int64) error {
func (f *fakeReviewer) Retry(_ context.Context, id string) error {
f.retried = append(f.retried, id)
return nil
}
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
const tid = "01arz3ndektsv4rrffq69g5fav"
func reviewData(state store.State) *worker.ReviewData {
s, e := 2, 1
return &worker.ReviewData{
Download: store.Download{ID: 5, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
Download: store.Download{ID: tid, State: state, DisplayName: "Фарго (2015). Сезон 2", Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
Recognition: &store.Recognition{
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
Reasons: `["неполный пак"]`,
@@ -123,7 +126,7 @@ func reviewData(state store.State) *worker.ReviewData {
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
t.Helper()
api := &fakeAPI{}
ing := &fakeIngestor{res: ingest.Result{DownloadID: 5, State: store.StateDownloading}}
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
rev := &fakeReviewer{data: reviewData(store.StateReview)}
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
@@ -146,7 +149,7 @@ func TestBot_IngestFromMagnet(t *testing.T) {
if ing.lastReq.Context != "крутой сериал" {
t.Errorf("context = %q", ing.lastReq.Context)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #5") {
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
t.Errorf("sent = %+v", api.sent)
}
}
@@ -174,10 +177,10 @@ func TestBot_NoMagnet(t *testing.T) {
func TestBot_RefineViaReply(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
b.setPending(7, 5)
b.setPending(7, tid)
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
if rev.refined[5] != "это второй сезон" {
if rev.refined[tid] != "это второй сезон" {
t.Errorf("refine = %v", rev.refined)
}
}
@@ -191,9 +194,9 @@ func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
func TestBot_CallbackApply(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
if len(rev.applied) != 1 || rev.applied[0] != 5 {
if len(rev.applied) != 1 || rev.applied[0] != tid {
t.Errorf("applied = %v", rev.applied)
}
if len(api.answers) != 1 {
@@ -206,18 +209,18 @@ func TestBot_CallbackApply(t *testing.T) {
func TestBot_CallbackType(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "type:5:movie"))
if rev.typed[5] != "movie" {
b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
if rev.typed[tid] != "movie" {
t.Errorf("typed = %v", rev.typed)
}
}
func TestBot_CallbackRefineSetsPending(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "refine:5"))
b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid))
if id, ok := b.takePending(7); !ok || id != 5 {
t.Errorf("pending = %d,%v", id, ok)
if id, ok := b.takePending(7); !ok || id != tid {
t.Errorf("pending = %s,%v", id, ok)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
t.Errorf("sent = %+v", api.sent)
@@ -226,7 +229,7 @@ func TestBot_CallbackRefineSetsPending(t *testing.T) {
func TestBot_CallbackDeniesUnknown(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(999, "apply:5"))
b.handleCallback(context.Background(), cbFrom(999, "apply:"+tid))
if len(rev.applied) != 0 {
t.Error("чужой колбэк не должен исполняться")
}
@@ -234,12 +237,12 @@ func TestBot_CallbackDeniesUnknown(t *testing.T) {
func TestBot_NotifyReview(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7, 8})
b.Notify(context.Background(), 5, worker.EventReview)
b.Notify(context.Background(), tid, worker.EventReview)
if len(api.sent) != 2 { // обоим доверенным
t.Fatalf("sent to %d chats, want 2", len(api.sent))
}
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #5") {
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+tid) {
t.Errorf("card text = %q", api.sent[0].text)
}
if !api.sent[0].hasKB {
@@ -250,9 +253,11 @@ func TestBot_NotifyReview(t *testing.T) {
func TestBot_NotifyDone(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateDone)
b.Notify(context.Background(), 5, worker.EventDone)
b.Notify(context.Background(), tid, worker.EventDone)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") {
// Заголовок берётся из display_name (консистентно с веб-UI), а не из Plan.Title.
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") ||
!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") {
t.Errorf("sent = %+v", api.sent)
}
}
@@ -260,9 +265,12 @@ func TestBot_NotifyDone(t *testing.T) {
func TestBot_NotifyFailed(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.Notify(context.Background(), 5, worker.EventFailed)
b.Notify(context.Background(), tid, worker.EventFailed)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") {
// В ошибке — и заголовок (display_name), и #id для поиска по логам.
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") ||
!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") ||
!strings.Contains(api.sent[0].text, "#"+tid) {
t.Errorf("sent = %+v", api.sent)
}
if !api.sent[0].hasKB { // кнопка повтора
@@ -273,20 +281,36 @@ func TestBot_NotifyFailed(t *testing.T) {
func TestBot_CallbackRetry(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.handleCallback(context.Background(), cbFrom(7, "retry:5"))
b.handleCallback(context.Background(), cbFrom(7, "retry:"+tid))
if len(rev.retried) != 1 || rev.retried[0] != 5 {
if len(rev.retried) != 1 || rev.retried[0] != tid {
t.Errorf("retried = %v", rev.retried)
}
}
func TestParseCallback(t *testing.T) {
a, id, v := parseCallback("type:5:series")
if a != "type" || id != 5 || v != "series" {
t.Errorf("got %q %d %q", a, id, v)
a, id, v := parseCallback("type:" + tid + ":series")
if a != "type" || id != tid || v != "series" {
t.Errorf("got %q %q %q", a, id, v)
}
a, id, v = parseCallback("apply:9")
if a != "apply" || id != 9 || v != "" {
t.Errorf("got %q %d %q", a, id, v)
a, id, v = parseCallback("apply:" + tid)
if a != "apply" || id != tid || v != "" {
t.Errorf("got %q %q %q", a, id, v)
}
// Устаревшая числовая кнопка (до перехода на ULID) → id пуст.
if _, id, _ := parseCallback("apply:5"); id != "" {
t.Errorf("legacy numeric id must be rejected, got %q", id)
}
}
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
func TestBot_CallbackStaleButton(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
if len(rev.applied) != 0 {
t.Error("устаревшая кнопка не должна исполняться")
}
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
t.Errorf("answers = %v, want понятный ответ", api.answers)
}
}
+30 -18
View File
@@ -3,7 +3,6 @@ package tgbot
import (
"fmt"
"path/filepath"
"strconv"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -21,13 +20,13 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
case store.StateReview, store.StateDeferred:
return b.reviewCard(rd)
case store.StateRecognizing:
return "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id)
return "⏳ Распознаю #" + id + "…", b.webOnly(id)
case store.StateLinking:
return "⏳ Раскладываю #" + itoa(id) + "…", nil
return "⏳ Раскладываю #" + id + "…", nil
case store.StateDone:
return b.renderDone(rd), b.webOnly(id)
default:
text := fmt.Sprintf("Задача #%d — %s.", id, state)
text := fmt.Sprintf("Задача #%s — %s.", id, state)
if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + msg
}
@@ -43,7 +42,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
id := rd.Download.ID
var sb strings.Builder
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%d\n", id)
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", id)
if src := contextOrSource(rd); src != "" {
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
}
@@ -63,7 +62,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
id := rd.Download.ID
sid := itoa(id)
sid := id
var row1 []tgbotapi.InlineKeyboardButton
if len(rd.Preview) > 0 {
@@ -86,11 +85,21 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
return &kb
}
// displayTitle — имя раздачи для уведомлений: имя раздачи (display_name, то,
// что ушло в qBittorrent и показывается в веб-UI) → распознанное название.
// Пусто, если ни того ни другого нет. Держим заголовки консистентными с UI.
func displayTitle(rd *worker.ReviewData) string {
if t := strings.TrimSpace(rd.Download.DisplayName); t != "" {
return t
}
return rd.Plan.Title
}
// renderDone — короткое сообщение о готовности.
func (b *Bot) renderDone(rd *worker.ReviewData) string {
title := rd.Plan.Title
title := displayTitle(rd)
if title == "" {
title = "#" + itoa(rd.Download.ID)
title = "#" + rd.Download.ID
}
n := len(rd.Preview)
if n == 0 {
@@ -101,9 +110,9 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string {
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
title := rd.Plan.Title
title := displayTitle(rd)
if title == "" {
title = "#" + itoa(rd.Download.ID)
title = "#" + rd.Download.ID
}
switch event {
case worker.EventTargetMissing:
@@ -123,7 +132,12 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
if rd.Download.State == store.StateStuck {
verb = "зависла"
}
fmt.Fprintf(&sb, "❌ Задача #%d %s", id, verb)
// Заголовок (display_name) для читаемости + #id для поиска по логам.
if title := displayTitle(rd); title != "" {
fmt.Fprintf(&sb, "❌ «%s» — задача #%s %s", title, id, verb)
} else {
fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb)
}
if code := rd.Download.ErrorCode.String; code != "" {
fmt.Fprintf(&sb, " (%s)", code)
}
@@ -139,9 +153,9 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
}
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
row := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(id)),
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
}
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
@@ -150,7 +164,7 @@ func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {
return nil
@@ -161,11 +175,11 @@ func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
func (b *Bot) reviewURL(id int64) string {
func (b *Bot) reviewURL(id string) string {
if b.webBase == "" {
return ""
}
return b.webBase + "/review/" + itoa(id)
return b.webBase + "/review/" + id
}
// --- мелкие хелперы ---
@@ -233,5 +247,3 @@ func shorten(s string, n int) string {
}
return string(r[:n]) + "…"
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
+45 -27
View File
@@ -2,6 +2,7 @@ package worker
import (
"context"
"slices"
"strings"
"time"
@@ -10,13 +11,13 @@ import (
)
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
// ИЛИ тегом, чьих хешей ещё нет в БД, заводит задачу downloading. Дальше
// её ведёт обычный reconcile. Вызывается под w.mu.
//
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
// Корректность при гонке с Ingest (другая горутина): и adopt, и приём идут
// через store.CreateDownloadIfNoActive — атомарный check-then-insert в одной
// write-транзакции; при столкновении второй участник получает существующую
// активную задачу и просто пропускает.
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
for _, t := range torrents {
if w.tracked(t) {
@@ -35,13 +36,13 @@ func (w *Worker) tracked(t qbt.Torrent) bool {
// adopt заводит задачу под торрент, если его ещё не видели.
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
infohash := firstInfohash(t)
if infohash == "" {
hashes := torrentHashes(t)
if len(hashes) == 0 {
return // нечем идентифицировать (напр. ещё metaDL без хэша)
}
exists, err := w.store.ExistsByInfohash(ctx, infohash)
exists, err := w.store.ExistsByInfohash(ctx, hashes...)
if err != nil {
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", infohash, "error", err)
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", hashes[0], "error", err)
return
}
if exists {
@@ -50,32 +51,28 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
d := &store.Download{
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
SourceRef: magnetURN(hashes[0]),
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
Infohash: store.NullString(infohash),
IdempotencyKey: store.NullString(infohash),
State: store.StateDownloading,
}
id, err := w.store.CreateDownload(ctx, d)
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes)
if err != nil {
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
return
}
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
return
if existing != nil {
return // гонка с Ingest/другим тиком: задача уже заведена — всё ок
}
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
// известно (created_at задачи было бы моментом усыновления, не добавления).
if t.AddedOn > 0 {
if err := w.store.SetSourceAddedAt(ctx, id, time.Unix(t.AddedOn, 0)); err != nil {
if err := w.store.SetSourceAddedAt(ctx, d.ID, time.Unix(t.AddedOn, 0)); err != nil {
w.log.Warn("adopt set source_added_at failed",
"capability", capIngest, "download_id", id, "error", err)
"capability", capIngest, "download_id", d.ID, "error", err)
}
}
w.log.Info("discover adopted torrent",
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
"capability", capIngest, "download_id", d.ID, "infohash", hashes[0], "name", t.Name,
"category", t.Category, "tags", t.Tags)
}
@@ -92,12 +89,33 @@ func hasTag(tags, tag string) bool {
return false
}
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
func firstInfohash(t qbt.Torrent) string {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" {
return strings.ToLower(h)
// torrentHashes — все непустые хеши торрента (нижний регистр, без дублей,
// v1-приоритетный порядок: v1 раньше v2). Единственный сборщик хешей
// торрента для записи в БД: t.Hash берётся только когда qBittorrent не
// отдал infohash_v1/v2 (старые версии API); у v2-only раздачи t.Hash — это
// УСЕЧЁННЫЙ до 40 hex v2-хеш, хранить его нельзя (по длине он неотличим от
// v1 и порождает битые btih-magnet при retry).
func torrentHashes(t qbt.Torrent) []string {
cands := []string{t.InfohashV1, t.InfohashV2}
if t.InfohashV1 == "" && t.InfohashV2 == "" {
cands = append(cands, t.Hash)
}
var out []string
for _, h := range cands {
h = store.NormalizeHash(h)
if h != "" && !slices.Contains(out, h) {
out = append(out, h)
}
}
return ""
return out
}
// magnetURN — синтетический источник усыновлённой раздачи по её хешу:
// btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан
// быть полноразмерным (torrentHashes усечённые не отдаёт).
func magnetURN(h string) string {
if store.HashKind(h) == store.HashV2 {
return "magnet:?xt=urn:btmh:1220" + h
}
return "magnet:?xt=urn:btih:" + h
}
+63 -17
View File
@@ -11,13 +11,13 @@ import (
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
func emptyStore() *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{}}
return &fakeStore{downloads: map[string]*store.Download{}}
}
// findByInfohash возвращает усыновлённую задачу по infohash.
func findByInfohash(st *fakeStore, infohash string) *store.Download {
for _, d := range st.downloads {
if d.Infohash.String == infohash {
if hasAnyHash(d, []string{infohash}) {
return d
}
}
@@ -38,8 +38,8 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
t.Errorf("adopted = %+v", d)
}
if d.IdempotencyKey.String != ihDisc {
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV1 {
t.Errorf("infohashes = %+v", d.Infohashes)
}
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
// время добавления (added_on) как базис сортировки.
@@ -79,8 +79,8 @@ func TestDiscover_SkipsUntracked(t *testing.T) {
func TestDiscover_SkipsExisting(t *testing.T) {
st := emptyStore()
// Уже есть задача (напр. терминальная done) — не переусыновляем.
st.downloads[1] = &store.Download{
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
st.downloads["1"] = &store.Download{
ID: "1", State: store.StateDone, Infohashes: hashesOf("1", ihDisc),
}
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{
@@ -104,9 +104,9 @@ func TestDiscover_SkipsNoInfohash(t *testing.T) {
// уже скачанная раздача за один тик усыновляется и доходит до completed.
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
st := &fakeStore{downloads: map[int64]*store.Download{
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
st := &fakeStore{downloads: map[string]*store.Download{
"1": {ID: "1", State: store.StateDownloading, SourceType: store.SourceMagnet,
Infohashes: hashesOf("1", ih)},
}}
qb := &fakeQbt{torrents: []qbt.Torrent{
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
@@ -116,7 +116,7 @@ func TestPoll_CapturesSourceAddedAt(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if d := st.downloads[1]; !d.SourceAddedAt.Valid {
if d := st.downloads["1"]; !d.SourceAddedAt.Valid {
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
}
}
@@ -160,14 +160,60 @@ func TestHasTag(t *testing.T) {
}
}
func TestFirstInfohash(t *testing.T) {
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
t.Errorf("got %q", got)
func TestTorrentHashes(t *testing.T) {
got := torrentHashes(qbt.Torrent{Hash: "ABC", InfohashV1: "abc", InfohashV2: "DEF"})
if len(got) != 2 || got[0] != "abc" || got[1] != "def" {
t.Errorf("got %v, want [abc def] (lowercase, без дублей, v1 первым)", got)
}
if got := firstInfohash(qbt.Torrent{InfohashV2: "DEF"}); got != "def" {
t.Errorf("got %q", got)
if got := torrentHashes(qbt.Torrent{}); len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
if got := firstInfohash(qbt.Torrent{}); got != "" {
t.Errorf("got %q, want empty", got)
// Старый qBittorrent без infohash_v1/v2 — берём hash.
if got := torrentHashes(qbt.Torrent{Hash: "ABC"}); len(got) != 1 || got[0] != "abc" {
t.Errorf("legacy hash: got %v, want [abc]", got)
}
// v2-only: t.Hash — УСЕЧЁННЫЙ v2 (40 hex), хранить его нельзя.
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
got = torrentHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2})
if len(got) != 1 || got[0] != v2 {
t.Errorf("v2-only: got %v, want только полный v2", got)
}
}
// Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного
// v2-хеша (не битый btih из усечённого), kind в БД — v2.
func TestDiscover_AdoptsV2Only(t *testing.T) {
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{
{Hash: v2[:40], InfohashV2: v2, Name: "V2Only", Category: "jellybit", State: "downloading"},
})
d := findByInfohash(st, v2)
if d == nil {
t.Fatal("v2-only раздача не усыновлена")
}
if d.SourceRef != "magnet:?xt=urn:btmh:1220"+v2 {
t.Errorf("SourceRef = %q, want btmh с полным v2", d.SourceRef)
}
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV2 {
t.Errorf("infohashes = %+v, want один v2 (усечённый не хранится)", d.Infohashes)
}
}
// Усыновление гибридного торрента записывает оба хеша.
func TestDiscover_AdoptsBothHashes(t *testing.T) {
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, InfohashV1: ihDisc, InfohashV2: v2, Name: "Hybrid", Category: "jellybit", State: "downloading"},
})
d := findByInfohash(st, v2)
if d == nil {
t.Fatal("гибридная раздача не находится по v2-хешу")
}
if len(d.Infohashes) != 2 {
t.Errorf("infohashes = %+v, want v1+v2", d.Infohashes)
}
}
+9 -3
View File
@@ -14,15 +14,15 @@ import (
func TestPollBuildsLiveSnapshot(t *testing.T) {
qb := &fakeQbt{torrents: []qbt.Torrent{
// Качается (Category пуст → discover не усыновляет, store не мешает).
{Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120},
{Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120, TotalSize: 4096},
// Сидирует, торрент v2 (три ключа).
{
Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2",
State: "uploading", Progress: 1.0,
State: "uploading", Progress: 1.0, TotalSize: 8192,
Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50,
},
}}
w := newTestWorker(&fakeStore{downloads: map[int64]*store.Download{}}, qb)
w := newTestWorker(&fakeStore{downloads: map[string]*store.Download{}}, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
@@ -38,6 +38,9 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
if dl.Progress != 0.5 || dl.DlSpeed != 1000 || dl.ETA != 120 {
t.Errorf("телеметрия качания неверна: %+v", dl)
}
if dl.TotalSize != 4096 {
t.Errorf("полный размер качающейся задачи: got %d, want 4096", dl.TotalSize)
}
// Сидирующая задача находится по любому из трёх хэшей (lowercase).
for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} {
@@ -51,6 +54,9 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
if sd.Ratio != 2.5 || sd.Seeds != 3 || sd.Peers != 1 || sd.Uploaded != 999 || sd.UpSpeed != 50 {
t.Errorf("ключ %q: статистика раздачи неверна: %+v", h, sd)
}
if sd.TotalSize != 8192 {
t.Errorf("ключ %q: полный размер раздачи: got %d, want 8192", h, sd.TotalSize)
}
}
if _, ok := w.Live("unknownhash"); ok {
+22 -29
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"os"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
@@ -57,11 +56,11 @@ func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torr
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
// дебаунсом) и цели, выводит состояние и переходит при изменении.
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
return // нечем сопоставить источник
}
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
_, sourceSeen := torrentFor(d, byHash)
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
// идущих промахов; любое появление сбрасывает счётчик.
@@ -104,7 +103,7 @@ func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSee
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
// считается присутствующей, только если существуют ВСЕ ссылки последнего
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) {
func (w *Worker) targetPresent(ctx context.Context, id string) (bool, error) {
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return false, fmt.Errorf("latest batch: %w", err)
@@ -180,10 +179,10 @@ func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.To
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
// присутствует и продвинулся за условие падения.
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
return
}
t, ok := byHash[strings.ToLower(d.Infohash.String)]
t, ok := torrentFor(d, byHash)
if !ok {
return // источника нет — оставляем как есть (вернёт ручной retry)
}
@@ -194,28 +193,22 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
if want == "" {
return // переходное состояние qBit (moving/checking) — ждём
}
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
// Конфликт идемпотентности: пока задача лежала в failed, тот же infohash мог
// взять другая активная задача (idempotency_key снят при падении). Оба
// целевых состояния (downloading/completed) нетерминальны → SetDownloadState
// восстановит idempotency_key = infohash; при занятом ключе упёрлись бы в
// unique-индекс. Поэтому проверяем владельца независимо от целевого состояния
// и оставляем старую задачу в failed.
other, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
if err != nil {
logctx.From(ctx).Warn("recovery active lookup failed", "error", err)
return
}
if other != nil && other.ID != d.ID {
logctx.From(ctx).Info("recovery skipped, infohash taken by active download",
"conflict_download_id", other.ID)
return
}
// Возврат в активное состояние — только через атомарный гард инварианта:
// пока задача лежала в failed, тем же infohash могла завладеть другая
// активная задача (новый приём). Тогда старую оставляем в failed.
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State)
w.transition(ctx, d, want, "", "")
if err := w.store.ActivateIfNoOtherActive(ctx, d.ID, want, "", ""); err != nil {
if errors.Is(err, store.ErrInfohashTaken) {
logctx.From(ctx).Info("recovery skipped, infohash taken by active download", "error", err)
return
}
logctx.From(ctx).Warn("recovery activate failed", "error", err)
return
}
logctx.From(ctx).Info("recovery from failure", "from", d.State, "to", want, "qbit_state", t.State)
}
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
@@ -256,10 +249,10 @@ func recoveredState(state string) store.State {
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
if !d.Infohash.Valid {
return fmt.Errorf("%s: download %d has no infohash", op, d.ID)
if len(d.Infohashes) == 0 {
return fmt.Errorf("%s: download %s has no infohash", op, d.ID)
}
_, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
_, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
+21 -21
View File
@@ -31,11 +31,11 @@ func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTar
}
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = state
st.put(d)
st.links = append(st.links, store.FileLink{
DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
DownloadID: "1", ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
Kind: "video", Status: "linked",
})
@@ -65,7 +65,7 @@ func TestReconcileMatrix(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != tc.want {
if got := f.st.downloads["1"].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
@@ -78,7 +78,7 @@ func TestReconcileHealing(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done (healing)", got)
}
}
@@ -88,13 +88,13 @@ func TestReconcilePartialTargetLoss(t *testing.T) {
f := newReconcileFixture(t, store.StateDone, true, true)
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
f.st.links = append(f.st.links, store.FileLink{
DownloadID: 1, ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
DownloadID: "1", ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
Kind: "subtitle", Status: "linked",
})
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateTargetMissing {
if got := f.st.downloads["1"].State; got != store.StateTargetMissing {
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
}
}
@@ -106,7 +106,7 @@ func TestReconcileSkipsDeleted(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDeleted {
if got := f.st.downloads["1"].State; got != store.StateDeleted {
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
}
}
@@ -121,17 +121,17 @@ func TestReconcileDebounce(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll %d: %v", i, err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
}
if got := f.st.downloads[1].SourceMissCount; got != i {
if got := f.st.downloads["1"].SourceMissCount; got != i {
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
}
}
if err := f.w.Poll(context.Background()); err != nil { // третий промах
t.Fatalf("Poll 3: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
}
@@ -140,10 +140,10 @@ func TestReconcileDebounce(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll heal: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done (источник вернулся)", got)
}
if got := f.st.downloads[1].SourceMissCount; got != 0 {
if got := f.st.downloads["1"].SourceMissCount; got != 0 {
t.Errorf("miss = %d, want 0 (сброс)", got)
}
}
@@ -154,18 +154,18 @@ func TestReconcileSkipsActiveStates(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDownloading {
if got := f.st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
}
}
func TestUndoRejectedForOrphaned(t *testing.T) {
f := newReconcileFixture(t, store.StateOrphaned, false, true)
err := f.w.Undo(context.Background(), 1)
err := f.w.Undo(context.Background(), "1")
if err == nil {
t.Fatal("ожидали отказ Undo для orphaned")
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (без изменений)", got)
}
}
@@ -173,14 +173,14 @@ func TestUndoRejectedForOrphaned(t *testing.T) {
func TestRelinkFromTargetMissing(t *testing.T) {
// target_missing + источник на месте → relink ведёт в recognizing.
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
if err := f.w.Relink(context.Background(), 1); err != nil {
if err := f.w.Relink(context.Background(), "1"); err != nil {
t.Fatalf("Relink: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateRecognizing {
if got := f.st.downloads["1"].State; got != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", got)
}
if f.st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review = %q, want 1", f.st.overrides[1][ovrForceReview])
if f.st.overrides["1"][ovrForceReview] != "1" {
t.Errorf("force_review = %q, want 1", f.st.overrides["1"][ovrForceReview])
}
}
@@ -189,10 +189,10 @@ func TestPreflightFixesStaleState(t *testing.T) {
// а цель на месте: relink немедленно приводит состояние к orphaned, не
// дожидаясь фоновой сверки.
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
if err := f.w.Relink(context.Background(), 1); err == nil {
if err := f.w.Relink(context.Background(), "1"); err == nil {
t.Fatal("ожидали отказ relink при пропавшем источнике")
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
}
}
+23 -23
View File
@@ -14,13 +14,13 @@ import (
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{
1: {
ID: 1,
return &fakeStore{downloads: map[string]*store.Download{
"1": {
ID: "1",
State: state,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
Infohashes: hashesOf("1", infohash),
ErrorCode: store.NullString(code),
CreatedAt: createdAt,
},
@@ -52,7 +52,7 @@ func TestRecovery(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := st.downloads[1].State; got != tc.want {
if got := st.downloads["1"].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
@@ -68,8 +68,8 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
}
}
@@ -78,11 +78,11 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
st.downloads["2"] = &store.Download{
ID: "2",
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
Infohashes: hashesOf("2", ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
@@ -90,8 +90,8 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
}
}
@@ -101,11 +101,11 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
st.downloads["2"] = &store.Download{
ID: "2",
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
Infohashes: hashesOf("2", ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
@@ -113,8 +113,8 @@ func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
}
}
@@ -126,7 +126,7 @@ func TestFailNotifyDebounce(t *testing.T) {
w := newTestWorker(st, &fakeQbt{})
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n)
d := *st.downloads[1]
d := *st.downloads["1"]
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
if e := waitNotify(t, n); e.ev != EventFailed {
@@ -151,11 +151,11 @@ func TestRetryReattachesNoReadd(t *testing.T) {
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), 1); err != nil {
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads[1].State != store.StateDownloading {
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
}
if len(qb.added) != 0 {
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
@@ -164,7 +164,7 @@ func TestRetryReattachesNoReadd(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
}
}
+281 -81
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"path/filepath"
"strconv"
"strings"
@@ -50,7 +51,7 @@ func (w *Worker) recognizePending(ctx context.Context) {
// под блокировкой переводим в recognizing, LLM зовём без блокировки, затем
// под блокировкой фиксируем результат — но только если задачу за это время
// не увели в другое состояние (cancel/defer).
func (w *Worker) recognizeOne(ctx context.Context, id int64) {
func (w *Worker) recognizeOne(ctx context.Context, id string) {
w.mu.Lock()
d, err := w.store.GetDownload(ctx, id)
if err != nil {
@@ -62,7 +63,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
w.mu.Unlock()
return
}
ctx = w.scoped(ctx, capRecognize, id, d.Infohash.String)
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
if d.State == store.StateCompleted {
w.transition(ctx, *d, store.StateRecognizing, "", "")
}
@@ -84,10 +85,10 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
// затем зовёт распознаватель. Возвращает также savePath для маппинга
// относительных путей файлов в абсолютные при раскладке.
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
return recognize.Result{}, "", fmt.Errorf("no infohash")
}
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil {
return recognize.Result{}, "", err
}
@@ -123,7 +124,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) {
log := logctx.From(ctx)
planJSON, err := json.Marshal(res.Plan)
if err != nil {
@@ -174,6 +175,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
log.Error("recognition persist failed", "error", err)
return
}
// recognition_id — ключ корреляции попытки (грепается и голым ULID).
log.Info("recognition persisted", "recognition_id", recID,
"provider", provider, "provider_id", providerID)
// Кандидаты базы — для ручного выбора в review.
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
if err := w.store.CreateCandidates(ctx, cands); err != nil {
@@ -189,7 +193,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
forceReview := overrides[ovrForceReview] == "1"
if res.Decision.Auto && !forceReview && w.layouter != nil {
plan := applyOverrides(res.Plan, overrides)
lctx := w.scoped(ctx, capFileLayout, id, d.Infohash.String)
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
w.transition(lctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
@@ -200,7 +204,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
}
// overridesOrNil читает правки, проглатывая ошибку (для авто-пути).
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]string {
o, err := w.store.ListOverrides(ctx, id)
if err != nil {
logctx.From(ctx).Warn("recognition list overrides failed", "error", err)
@@ -213,7 +217,7 @@ func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
func (w *Worker) Apply(ctx context.Context, id int64) error {
func (w *Worker) Apply(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -225,15 +229,15 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: %w", err)
}
if d.State != store.StateReview && d.State != store.StateDeferred {
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
return fmt.Errorf("apply: download %s is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
plan, tag, err := w.effectivePlan(ctx, id)
if err != nil {
return fmt.Errorf("apply: %w", err)
}
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil {
return fmt.Errorf("apply: lookup torrent: %w", err)
}
@@ -274,6 +278,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
DstPath: r.Link.Dst,
Kind: string(r.Link.Kind),
Status: string(r.Status),
Size: r.Size,
})
}
if len(fl) > 0 {
@@ -308,7 +313,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
}
w.transition(ctx, *d, store.StateDone, "", "")
logctx.From(ctx).Info("layout linked", "batch", batch, "links", len(fl))
logctx.From(ctx).Info("layout linked", "batch_id", batch, "links", len(fl))
return nil
}
@@ -317,7 +322,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте.
func (w *Worker) Relink(ctx context.Context, id int64) error {
func (w *Worker) Relink(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -326,28 +331,26 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
return fmt.Errorf("relink: %w", err)
}
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
}
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при
// его отсутствии приводим состояние к реальности (orphaned/deleted).
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
return err
}
// Вернуть задачу в активную обработку можно, только если другой активной
// задачи на этот infohash нет (partial unique index по idempotency_key).
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("relink: %w", err)
}
if active != nil {
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
}
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
return fmt.Errorf("relink: %w", err)
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
w.transition(ctx, *d, store.StateRecognizing, "", "")
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Возврат в активную обработку — только через атомарный гард инварианта
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
if errors.Is(err, store.ErrInfohashTaken) {
return fmt.Errorf("relink: для этого торрента уже есть активная задача: %w", ErrConflict)
}
return fmt.Errorf("relink: %w", err)
}
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
return nil
}
@@ -355,7 +358,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -366,14 +369,14 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
logctx.From(ctx).Info("review re-recognizing without hint")
w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil
}
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
hint = strings.TrimSpace(hint)
if hint == "" {
return fmt.Errorf("refine: empty hint")
@@ -388,7 +391,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.store.AddHint(ctx, id, hint); err != nil {
return fmt.Errorf("refine: %w", err)
}
@@ -399,7 +402,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
// — чтобы LLM пересобрал роли файлов под новый тип.
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
return fmt.Errorf("set type: invalid type %q", mediaType)
}
@@ -413,7 +416,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
return fmt.Errorf("set type: %w", err)
}
@@ -430,7 +433,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
// превью пересчитается с учётом правки.
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
src = strings.TrimSpace(src)
if src == "" {
return fmt.Errorf("ignore: empty path")
@@ -454,12 +457,12 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
return fmt.Errorf("ignore: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review file ignored", "src", src)
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review file ignored", "src", src)
return nil
}
// Defer паркует задачу в deferred (вернётся в ревью по действию).
func (w *Worker) Defer(ctx context.Context, id int64) error {
func (w *Worker) Defer(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -468,9 +471,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
return fmt.Errorf("defer: %w", err)
}
if d.State.IsTerminal() {
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
w.transition(ctx, *d, store.StateDeferred, "", "")
return nil
}
@@ -479,7 +482,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
func (w *Worker) Undo(ctx context.Context, id int64) error {
func (w *Worker) Undo(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -496,9 +499,9 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
}
if d.State != store.StateDone {
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
return fmt.Errorf("undo: download %s is in state %s (expected done): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return fmt.Errorf("undo: %w", err)
@@ -528,18 +531,18 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
return fmt.Errorf("undo: %w", err)
}
w.transition(ctx, *d, store.StateReverted, "", "")
logctx.From(ctx).Info("layout reverted", "batch", batch, "removed", n)
logctx.From(ctx).Info("layout reverted", "batch_id", batch, "removed", n)
return nil
}
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*store.Download, error) {
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
if d.State != store.StateReview && d.State != store.StateDeferred {
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
return nil, fmt.Errorf("%s: download %s is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
}
return d, nil
}
@@ -549,7 +552,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -566,31 +569,110 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
return fmt.Errorf("choose candidate: %w", err)
}
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID)
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
}
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
}
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
if cand.Title.Valid && cand.Title.String != "" {
pins[ovrTitle] = cand.Title.String
// AddManualSource добавляет источник вручную по (provider, id) и выбирает его.
// Когда автопоиск промахнулся: сохраняем кандидата (дедуп по provider:id) и
// пиннит как выбранный. provider — из набора tmdb/tvdb/imdb.
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) error {
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
case "tmdb", "tvdb", "imdb":
default:
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb)", provider)
}
if providerID == "" {
return fmt.Errorf("add source: empty id")
}
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.requireReviewable(ctx, id, "add source")
if err != nil {
return err
}
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return fmt.Errorf("add source: %w", err)
}
if rec == nil {
return fmt.Errorf("add source: no recognition")
}
cand, err := w.findOrCreateCandidate(ctx, rec.ID, provider, providerID)
if err != nil {
return fmt.Errorf("add source: %w", err)
}
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
}
// findOrCreateCandidate возвращает кандидата рекогниции по (provider, id),
// создавая его при отсутствии (дедуп по provider:id). Под mu.
func (w *Worker) findOrCreateCandidate(ctx context.Context, recognitionID, provider, providerID string) (*store.MetadataCandidate, error) {
cands, err := w.store.ListCandidatesByRecognition(ctx, recognitionID)
if err != nil {
return nil, err
}
if c := findCandidate(cands, provider, providerID); c != nil {
return c, nil
}
if err := w.store.CreateCandidates(ctx, []store.MetadataCandidate{{
RecognitionID: recognitionID,
Provider: provider,
ProviderID: providerID,
}}); err != nil {
return nil, err
}
cands, err = w.store.ListCandidatesByRecognition(ctx, recognitionID)
if err != nil {
return nil, err
}
if c := findCandidate(cands, provider, providerID); c != nil {
return c, nil
}
return nil, fmt.Errorf("candidate %s:%s not found after create", provider, providerID)
}
func findCandidate(cands []store.MetadataCandidate, provider, providerID string) *store.MetadataCandidate {
for i := range cands {
if cands[i].Provider == provider && cands[i].ProviderID == providerID {
return &cands[i]
}
}
return nil
}
// chooseCandidateLocked пиннит кандидата как выбранный источник. Пишет ПОЛНЫЙ
// самосогласованный набор пинов (provider/id/title/year): пустые title/year у
// кандидата очищают возможный унаследованный пин прежнего источника — иначе
// превью разошлось бы с применением (решение 1a). Под mu.
func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.Download, rec *store.Recognition, cand store.MetadataCandidate) error {
title := ""
if cand.Title.Valid {
title = cand.Title.String
}
year := 0
if cand.Year.Valid {
pins[ovrYear] = strconv.FormatInt(cand.Year.Int64, 10)
year = int(cand.Year.Int64)
}
for field, value := range pins {
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
}
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review candidate chosen",
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
"provider", cand.Provider, "provider_id", cand.ProviderID)
return nil
}
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
@@ -608,19 +690,21 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
if err != nil {
return err
}
if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil {
// Полный набор пинов: id задан вручную, название/год берём из плана
// (очищаем возможный унаследованный пин прежнего источника).
for field, value := range sourcePins(provider, providerID, "", 0) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("set provider: %w", err)
}
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
return fmt.Errorf("set provider: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set",
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
"provider", provider, "provider_id", providerID)
return nil
}
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
// ClearProvider — «без базы»: снимает матч (тег папки не ставится) и очищает
// пины названия/года (источник — распознавание нейронкой).
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -628,16 +712,33 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
if err != nil {
return err
}
if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil {
for field, value := range sourcePins("none", "", "", 0) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider cleared")
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
return nil
}
// sourcePins — полный самосогласованный набор пинов источника (решение 1a):
// title/year пишутся пустой строкой, если у источника их нет; в applyOverrides
// пустая строка трактуется как «нет override» → берётся значение плана. Так
// выбор любого источника даёт детерминированный эффективный план, а превью
// совпадает с применением. Используется и в коммите (SetOverride), и в превью.
func sourcePins(provider, providerID, title string, year int) map[string]string {
yr := ""
if year > 0 {
yr = strconv.Itoa(year)
}
return map[string]string{
ovrProvider: provider,
ovrProviderID: providerID,
ovrTitle: title,
ovrYear: yr,
}
}
// --- Данные для экрана ревью ---
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
@@ -645,21 +746,46 @@ type ReviewData struct {
Download store.Download
Recognition *store.Recognition
Plan recognize.Plan // эффективный (с применёнными правками)
Preview []layout.Link // целевые пути (Src — относительный, для показа)
Preview []layout.Link // целевые пути активного источника (Src — относительный)
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string
Overrides map[string]string
}
// SourceKind — вид источника в едином списке ревью.
type SourceKind string
const (
SourceNeural SourceKind = "neural" // распознавание нейронкой (без базы)
SourceCandidate SourceKind = "candidate" // кандидат метабазы (в т.ч. добавленный вручную)
)
// SourceOption — источник совпадения в списке ревью: эффективные поля и
// предпросмотр целевых путей, посчитанные эфемерно (без записи overrides).
type SourceOption struct {
Kind SourceKind
CandidateID string // ULID кандидата (пусто для нейронки)
Provider string // "none" для нейронки
ProviderID string
URL string // ссылка кандидата на запись (если есть)
Title string // эффективное название для этого источника
Year int // эффективный год
Type string // "movie" | "series"
Active bool // текущий эффективный источник
Plan recognize.Plan // эффективный план (для показа файлов → раскладка)
Preview []layout.Link // целевые пути этого источника
}
// ReviewData собирает данные ревью по загрузке.
func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) {
func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) {
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return nil, fmt.Errorf("review data: %w", err)
}
log := logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String))
log := logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash()))
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return nil, fmt.Errorf("review data: %w", err)
@@ -686,30 +812,101 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
}
}
if rec != nil && rec.Plan.Valid {
var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
var rawPlan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &rawPlan); err != nil {
log.Warn("review data unmarshal plan failed", "error", err)
} else {
plan = applyOverrides(plan, overrides)
rd.Plan = plan
// Превью строим по относительным путям с provider-тегом; ошибку
// логируем на Debug — просто покажем причины без превью.
rd.Plan = applyOverrides(rawPlan, overrides)
// Превью активного источника строим по относительным путям с
// provider-тегом; ошибку логируем на Debug — покажем без превью.
if w.layouter != nil {
tag := providerTag(prov, pid)
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag)); lerr == nil {
rd.Preview = links
} else {
log.Debug("review data build preview failed", "error", lerr)
}
}
// Единый список источников: нейронка + кандидаты, каждый с
// эфемерным превью из сырого плана (без записи overrides).
rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates)
}
}
return rd, nil
}
// buildSources собирает единый список источников: нейронка (первой) +
// кандидаты (дедуп по provider:id). Активным помечается текущий эффективный
// источник.
func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
neutral := prov == "" || prov == "none"
out := make([]SourceOption, 0, len(cands)+1)
out = append(out, w.sourceOption(SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
seen := map[string]bool{}
for _, c := range cands {
key := c.Provider + ":" + c.ProviderID
if seen[key] {
continue
}
seen[key] = true
title := ""
if c.Title.Valid {
title = c.Title.String
}
year := 0
if c.Year.Valid {
year = int(c.Year.Int64)
}
active := !neutral && c.Provider == prov && c.ProviderID == pid
out = append(out, w.sourceOption(SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
}
return out
}
// sourceOption строит один источник: накладывает его пины на неисточниковые
// overrides, считает эффективный план и предпросмотр путей — эфемерно, без
// записи. Гарантия preview == apply: тот же набор пинов запишет выбор.
func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year)))
opt := SourceOption{
Kind: kind,
CandidateID: candID,
Provider: provider,
ProviderID: providerID,
URL: url,
Title: eff.Title,
Year: eff.Year,
Type: string(eff.Type),
Active: active,
Plan: eff,
}
if w.layouter != nil {
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID))); err == nil {
opt.Preview = links
}
}
return opt
}
// mergeSourceOverrides накладывает пины источника (provider/id/title/year) на
// неисточниковые overrides (media_type, ignored_files, force_review, ...).
func mergeSourceOverrides(base, pins map[string]string) map[string]string {
m := make(map[string]string, len(base)+len(pins))
for k, v := range base {
switch k {
case ovrProvider, ovrProviderID, ovrTitle, ovrYear:
continue
default:
m[k] = v
}
}
maps.Copy(m, pins)
return m
}
// effectivePlan загружает текущий план, применяет правки и возвращает
// provider-тег для имени папки (под mu).
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) {
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return recognize.Plan{}, "", err
@@ -772,7 +969,7 @@ func effectiveProvider(rec *store.Recognition, overrides map[string]string) (pro
// toStoreCandidates переводит кандидатов распознавания в строки БД,
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
func toStoreCandidates(recognitionID string, cands []metadata.Candidate) []store.MetadataCandidate {
out := make([]store.MetadataCandidate, 0, len(cands))
for _, c := range cands {
prov, id := recognize.CandidateTag(c)
@@ -863,19 +1060,22 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
}
}
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
// торренты (а не только свою категорию): раздача могла быть усыновлена по
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
// (как и в Poll, см. там же).
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
// torrentByInfohash ищет торрент по любому из хешей загрузки (v1/v2/hash).
// Листаем ВСЕ торренты (а не только свою категорию): раздача могла быть
// усыновлена по тегу и иметь чужую/пустую категорию — фильтр по категории её
// бы потерял (как и в Poll, см. там же).
func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.Torrent, bool, error) {
torrents, err := w.qbt.Torrents(ctx, "")
if err != nil {
return qbt.Torrent{}, false, err
}
want := strings.ToLower(infohash)
want := make(map[string]bool, len(hashes))
for _, h := range hashes {
want[store.NormalizeHash(h)] = true
}
for _, t := range torrents {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" && strings.ToLower(h) == want {
if h != "" && want[strings.ToLower(h)] {
return t, true, nil
}
}
File diff suppressed because it is too large Load Diff
+95 -42
View File
@@ -13,12 +13,15 @@ package worker
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"time"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/qbt"
@@ -39,34 +42,35 @@ const (
type Store interface {
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
GetDownload(ctx context.Context, id int64) (*store.Download, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id int64, n int) error
SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error
GetDownload(ctx context.Context, id string) (*store.Download, error)
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id string, n int) error
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
// Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
// Идентичность/инвариант «одна активная загрузка на infohash».
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
// Ф3: распознавание, ревью, раскладка.
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
AddHint(ctx context.Context, downloadID int64, text string) error
ListHints(ctx context.Context, downloadID int64) ([]string, error)
SetOverride(ctx context.Context, downloadID int64, field, value string) error
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (string, error)
GetCurrentRecognition(ctx context.Context, downloadID string) (*store.Recognition, error)
AddHint(ctx context.Context, downloadID string, text string) error
ListHints(ctx context.Context, downloadID string) ([]string, error)
SetOverride(ctx context.Context, downloadID string, field, value string) error
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
CreateFileLinks(ctx context.Context, links []store.FileLink) error
SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
LatestBatchID(ctx context.Context, downloadID string) (string, error)
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
// Кандидаты базы метаданных (ручной выбор в review).
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]store.MetadataCandidate, error)
GetCandidate(ctx context.Context, id string) (*store.MetadataCandidate, error)
SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error
}
// QBittorrent — нужная worker часть клиента qBittorrent.
@@ -111,7 +115,7 @@ const (
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
type Notifier interface {
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
Notify(ctx context.Context, downloadID string, event NotifyEvent)
}
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
@@ -146,6 +150,7 @@ type Live struct {
State string // сырое состояние qBittorrent
Seeding bool // торрент завершён и раздаётся
TotalSize int64 // полный размер раздачи, байт (доступен для любой раздачи в снимке)
Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д)
Seeds int // подключённые сиды
Peers int // подключённые личи
@@ -161,6 +166,7 @@ func liveFrom(t qbt.Torrent) Live {
ETA: t.Eta,
State: t.State,
Seeding: classify(t.State) == classReady,
TotalSize: t.TotalSize,
Ratio: t.Ratio,
Seeds: t.NumSeeds,
Peers: t.NumLeechs,
@@ -196,7 +202,7 @@ type Worker struct {
// время последнего пинга). Мерцающий stalled-торрент колеблется
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
failNotified map[int64]time.Time
failNotified map[string]time.Time
}
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
@@ -221,7 +227,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
log: log,
now: time.Now,
newID: defaultBatchID,
failNotified: map[int64]time.Time{},
failNotified: map[string]time.Time{},
live: map[string]Live{},
}
}
@@ -246,15 +252,16 @@ func (w *Worker) setLive(snap map[string]Live) {
w.liveMu.Unlock()
}
// defaultBatchID — уникальный идентификатор батча раскладки.
// defaultBatchID — идентификатор батча раскладки (ULID, единая точка
// генерации id — internal/ident; сортируем по времени, удобен в логах).
func defaultBatchID() string {
return fmt.Sprintf("b-%d", time.Now().UnixNano())
return ident.NewID()
}
// scoped кладёт в ctx scoped-логгер загрузки (capability + download_id
// [+ infohash]); стадии и внешние клиенты достают его из ctx и дописывают эти
// ключи на каждую запись сами — без ручного доклеивания download_id.
func (w *Worker) scoped(ctx context.Context, capability string, id int64, infohash string) context.Context {
func (w *Worker) scoped(ctx context.Context, capability string, id string, infohash string) context.Context {
log := w.log.With("capability", capability, "download_id", id)
if infohash != "" {
log = log.With("infohash", infohash)
@@ -325,15 +332,16 @@ func (w *Worker) Poll(ctx context.Context) error {
return fmt.Errorf("poll: list active: %w", err)
}
for _, d := range active {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
}
t, ok := byHash[strings.ToLower(d.Infohash.String)]
t, ok := torrentFor(d, byHash)
if !ok {
w.log.Warn("active download not found in qbittorrent",
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
"capability", capIngest, "download_id", d.ID, "infohash", d.PrimaryInfohash())
continue
}
w.captureInfohashes(ctx, d, t)
w.captureSourceAddedAt(ctx, d, t)
w.reconcile(ctx, d, t)
}
@@ -351,7 +359,7 @@ func (w *Worker) Poll(ctx context.Context) error {
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
// w.mu.
func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) {
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
switch classify(t.State) {
case classReady:
w.transition(ctx, d, store.StateCompleted, "", "")
@@ -397,6 +405,39 @@ func (w *Worker) captureSourceAddedAt(ctx context.Context, d store.Download, t q
}
}
// torrentFor ищет торрент загрузки в карте byHash по любому из её хешей.
func torrentFor(d store.Download, byHash map[string]qbt.Torrent) (qbt.Torrent, bool) {
for _, h := range d.HashList() {
if t, ok := byHash[h]; ok {
return t, true
}
}
return qbt.Torrent{}, false
}
// captureInfohashes дописывает загрузке хеши, которые qBittorrent знает, а мы
// ещё нет (гибридный торрент раскрывает v1+v2 после получения метаданных).
// Хеши собирает torrentHashes (усечённый t.Hash v2-only раздач отсеян).
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
// дописан не будет (ErrInfohashTaken). Учётная операция: сбой не двигает
// задачу, лишь логируем WARN. Под w.mu.
func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt.Torrent) {
known := d.HashList()
var missing []string
for _, h := range torrentHashes(t) {
if !slices.Contains(known, h) {
missing = append(missing, h)
}
}
if len(missing) == 0 {
return
}
if err := w.store.AddInfohashes(ctx, d.ID, missing); err != nil {
w.log.Warn("capture infohashes failed",
"capability", capIngest, "download_id", d.ID, "error", err)
}
}
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
// отдал added_on.
@@ -453,7 +494,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.Infohash.String)
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
}
}
@@ -462,7 +503,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
// флаппинг.
func (w *Worker) shouldNotifyFail(id int64) bool {
func (w *Worker) shouldNotifyFail(id string) bool {
now := w.now()
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
return false
@@ -479,7 +520,7 @@ func (w *Worker) shouldNotifyFail(id int64) bool {
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
// раздачу (источник неприкосновенен).
func (w *Worker) Cancel(ctx context.Context, id int64) error {
func (w *Worker) Cancel(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -488,18 +529,18 @@ func (w *Worker) Cancel(ctx context.Context, id int64) error {
return fmt.Errorf("cancel: %w", err)
}
if d.State.IsTerminal() {
return fmt.Errorf("cancel: download %d is already terminal (%s)", id, d.State)
return fmt.Errorf("cancel: download %s is already terminal (%s)", id, d.State)
}
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
return fmt.Errorf("cancel: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download cancelled", "from", d.State)
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download cancelled", "from", d.State)
return nil
}
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
// qBittorrent и возвращает в downloading.
func (w *Worker) Retry(ctx context.Context, id int64) error {
func (w *Worker) Retry(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -508,32 +549,44 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
return fmt.Errorf("retry: %w", err)
}
if d.State != store.StateFailed && d.State != store.StateStuck {
return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State)
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
}
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике.
alive := false
if d.Infohash.Valid {
_, alive, err = w.torrentByInfohash(ctx, d.Infohash.String)
if hashes := d.HashList(); len(hashes) > 0 {
_, alive, err = w.torrentByInfohash(ctx, hashes)
if err != nil {
return fmt.Errorf("retry: %w", err)
}
}
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
// в failed, тем же infohash могла завладеть другая активная задача — тогда
// отказываем, не добавив торрент повторно (см. design ulid-identity, D4).
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateDownloading, "", ""); err != nil {
if errors.Is(err, store.ErrInfohashTaken) {
return fmt.Errorf("retry: для этого торрента уже есть другая активная задача: %w", ErrConflict)
}
return fmt.Errorf("retry: %w", err)
}
if !alive && d.SourceType == store.SourceMagnet {
if err := w.qbt.Add(ctx, qbt.AddRequest{
URLs: []string{d.SourceRef},
Category: w.cfg.Category,
SavePath: w.cfg.SavePath,
}); err != nil {
// Активация уже прошла — откатываем задачу в прежнее состояние,
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
w.log.Error("retry rollback failed",
"capability", capReview, "download_id", id, "error", rbErr)
}
return fmt.Errorf("retry: add to qbittorrent: %w", err)
}
}
if err := w.store.SetDownloadState(ctx, id, store.StateDownloading, "", ""); err != nil {
return fmt.Errorf("retry: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download retried", "from", d.State)
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
return nil
}
+168 -50
View File
@@ -2,6 +2,7 @@ package worker
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
@@ -14,18 +15,18 @@ import (
// фиксированные метки времени для детерминированных таймаут-тестов.
const (
timeNow = "2026-06-14 10:00:00"
timeOld = "2026-06-14 08:00:00" // 2 часа назад
timeRecent = "2026-06-14 09:59:00" // 1 минута назад
timeNow = "2026-06-14T10:00:00Z"
timeOld = "2026-06-14T08:00:00Z" // 2 часа назад
timeRecent = "2026-06-14T09:59:00Z" // 1 минута назад
)
type fakeStore struct {
downloads map[int64]*store.Download
downloads map[string]*store.Download
transitions []transition
}
type transition struct {
id int64
id string
state store.State
}
@@ -58,27 +59,39 @@ func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store
return out, nil
}
func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
d, ok := f.downloads[id]
if !ok {
return nil, fmt.Errorf("download %d not found", id)
return nil, fmt.Errorf("download %s not found", id)
}
cp := *d
return &cp, nil
}
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
// hasAnyHash сообщает, владеет ли загрузка любым из hashes.
func hasAnyHash(d *store.Download, hashes []string) bool {
for _, own := range d.Infohashes {
for _, h := range hashes {
if own.Infohash == store.NormalizeHash(h) {
return true
}
}
}
return false
}
func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash {
if hasAnyHash(d, hashes) {
return true, nil
}
}
return false, nil
}
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
@@ -86,18 +99,62 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*s
return nil, nil
}
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(f.downloads) + 1)
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
return existing, nil
}
id := fmt.Sprintf("%d", len(f.downloads)+1)
cp := *d
cp.ID = id
for _, h := range hashes {
h = store.NormalizeHash(h)
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
f.downloads[id] = &cp
return id, nil
d.ID = id
d.Infohashes = cp.Infohashes
return nil, nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
for _, other := range f.downloads {
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken)
}
}
return f.SetDownloadState(ctx, id, st, code, msg)
}
func hashList(d *store.Download) []string {
out := make([]string, len(d.Infohashes))
for i, h := range d.Infohashes {
out[i] = h.Infohash
}
return out
}
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
for _, h := range hashes {
h = store.NormalizeHash(h)
if !hasAnyHash(d, []string{h}) {
d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
}
return nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
d.State = st
d.ErrorCode = store.NullString(code)
@@ -106,19 +163,19 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
return nil
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
d.SourceMissCount = n
return nil
}
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
d.SourceAddedAt = store.NullString(store.FormatTime(t))
@@ -128,23 +185,23 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) e
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
return 0, nil
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
return "", nil
}
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ int64) (*store.Recognition, error) {
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) {
return nil, nil
}
func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil }
func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil }
func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil }
func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) {
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil }
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil }
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
return nil, nil
}
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ int64, _ []string) error {
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
return nil
}
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (string, error) { return "", nil }
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
return nil, nil
}
@@ -152,17 +209,18 @@ func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error {
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
return nil
}
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ string) ([]store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
type fakeQbt struct {
torrents []qbt.Torrent
added []qbt.AddRequest
addErr error
files []qbt.File
}
@@ -184,6 +242,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
}
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
if f.addErr != nil {
return f.addErr
}
f.added = append(f.added, ar)
return nil
}
@@ -204,18 +265,28 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
}
func oneDownloading(infohash, createdAt string) *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{
1: {
ID: 1,
return &fakeStore{downloads: map[string]*store.Download{
"1": {
ID: "1",
State: store.StateDownloading,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
Infohashes: hashesOf("1", infohash),
CreatedAt: createdAt,
},
}}
}
// hashesOf — срез хешей загрузки для литералов фикстур.
func hashesOf(id string, hashes ...string) []store.Infohash {
out := make([]store.Infohash, 0, len(hashes))
for _, h := range hashes {
h = store.NormalizeHash(h)
out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
return out
}
func TestPollTransitions(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
tests := []struct {
@@ -242,7 +313,7 @@ func TestPollTransitions(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := st.downloads[1].State; got != tc.want {
if got := st.downloads["1"].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
@@ -257,8 +328,8 @@ func TestPollMatchesByInfohashV2(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateCompleted {
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateCompleted {
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads["1"].State)
}
}
@@ -269,46 +340,93 @@ func TestPollIgnoresMissingTorrent(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
}
}
func TestCancel(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
w := newTestWorker(st, &fakeQbt{})
if err := w.Cancel(context.Background(), 1); err != nil {
if err := w.Cancel(context.Background(), "1"); err != nil {
t.Fatalf("Cancel: %v", err)
}
if st.downloads[1].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled", st.downloads[1].State)
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
// Повторная отмена терминальной задачи — ошибка.
if err := w.Cancel(context.Background(), 1); err == nil {
if err := w.Cancel(context.Background(), "1"); err == nil {
t.Error("ожидалась ошибка при отмене терминальной задачи")
}
}
func TestRetry(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads[1].State = store.StateStuck
st.downloads["1"].State = store.StateStuck
qb := &fakeQbt{}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), 1); err != nil {
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
}
if len(qb.added) != 1 {
t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
}
}
// Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не
// добавляется в qBittorrent повторно.
func TestRetryConflictNoAdd(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
// Хешем владеет другая активная задача.
st.downloads["2"] = &store.Download{
ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet,
Infohashes: hashesOf("2", ih), CreatedAt: timeRecent,
}
qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
t.Fatalf("ожидался ErrConflict, получили %v", err)
}
if len(qb.added) != 0 {
t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added))
}
if st.downloads["1"].State != store.StateFailed {
t.Errorf("state = %s, want failed", st.downloads["1"].State)
}
}
// Если после активации повторный Add в qBittorrent упал — задача
// откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи.
func TestRetryRollsBackOnAddFailure(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
qb := &fakeQbt{addErr: fmt.Errorf("connection refused")}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); err == nil {
t.Fatal("ожидалась ошибка Add")
}
if st.downloads["1"].State != store.StateFailed {
t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State)
}
if st.downloads["1"].ErrorCode.String != "magnet_timeout" {
t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String)
}
}
func TestRetryRejectsActive(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
w := newTestWorker(st, &fakeQbt{})
if err := w.Retry(context.Background(), 1); err == nil {
if err := w.Retry(context.Background(), "1"); err == nil {
t.Error("retry активной (downloading) задачи должен отклоняться")
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-02
@@ -0,0 +1,240 @@
## Context
Идентичность сегодня: `download.id` — INTEGER AUTOINCREMENT (как и у всех
шести таблиц), дедуп — `idempotency_key UNIQUE` (= infohash у активных
задач). Ключ снимается при терминализации (`SetDownloadState`:
`idempotency_key = CASE WHEN terminal THEN NULL ELSE infohash END`) и
восстанавливается при возврате из терминала — так обеспечивается инвариант
«одна активная задача на infohash» при легальном повторном приёме того же
торрента после завершения. Поллинг сопоставляет раздачу по `hash`/
`infohash_v1`/`infohash_v2` с одним хранимым `download.infohash`.
Мотивация и разбор — в proposal и черновике
[logical-title-model §5.1](../../../docs/drafts/logical-title-model.md).
## Goals / Non-Goals
**Goals:**
- ULID как публичный стабильный ключ всех сущностей домена; единый формат id
в БД, URL и логах; grep по голому id находит всё.
- Множество инфохэшей загрузки (`download_infohash`) вместо одного столбца;
дедуп и сопоставление в поллинге — по любому из хешей.
- Упрощение механики активности: убрать снимаемый/восстанавливаемый
`idempotency_key`, активность выводится только из `state`.
- Конвенция «без числовых PK» для будущих таблиц.
**Non-Goals:**
- Сходимость папки, merge-раскладка, `state_transition`, сущность title —
отдельные change'и (этапность черновика).
- Оптимизация производительности БД (домашний масштаб).
- Совместимость со старыми числовыми id после миграции (старые URL в
истории Telegram/закладках, старые inline-кнопки бота) — не поддерживаем.
## Decisions
### D1. ULID, канонически lowercase
ULID (`github.com/oklog/ulid/v2`, чистый Go): 128 бит, сортируем по времени
(48 бит timestamp), 26 символов Crockford base32 — компактнее UUID, без
дефисов (grep/двойной клик в логах), глобально уникален across таблиц.
Альтернативы: UUIDv4 — не сортируется; UUIDv7 — эквивалент со статусом RFC,
выбрали бы при интеграции с внешней системой, ждущей UUID (таких нет);
xid/KSUID — менее распространены без выгоды.
Канонический вид — **lowercase** (читаемость; спека ULID case-insensitive
при декодировании). Единственная точка генерации — хелпер `internal/ident`:
`NewID()` (monotonic entropy, `strings.ToLower`), `Parse()` (нормализация
регистра + валидация). Id сущностей генерируют Create-методы `store`
(сейчас они живут на `LastInsertId` — все переводятся на `ident.NewID()`);
`apply_batch_id` генерирует воркер тем же хелпером. Все входные границы
(URL, формы — включая `candidate_id` в ревью) прогоняют id через `Parse`
до запроса к БД — сравнение в SQLite побайтовое.
### D2. Хранение: TEXT PK, обычные rowid-таблицы
TEXT(26), не BLOB(16) — читаемость в `sqlite3` CLI и логах дороже 10 байт.
`WITHOUT ROWID` не используем — выгода на нашем масштабе нулевая, а готчи
есть. `AUTOINCREMENT` исчезает из схемы полностью.
### D3. `download_infohash`: составной ключ, а НЕ `UNIQUE(infohash)`
```sql
CREATE TABLE download_infohash (
download_id TEXT NOT NULL REFERENCES download (id) ON DELETE CASCADE,
infohash TEXT NOT NULL, -- lowercase hex
kind TEXT NOT NULL, -- v1 | v2
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (infohash, download_id)
);
```
Черновик предлагал `UNIQUE(infohash)` — это **неверно**: спека
state-reconciliation гарантирует «повторный приём того же infohash после
терминала возможен», то есть один хеш легитимно принадлежит нескольким
загрузкам во времени. Глобальная уникальность действует только среди
**активных** загрузок, а это условие на `download.state` — в индекс SQLite
не выразить. PK `(infohash, download_id)` даёт и дедуп строк, и индекс для
поиска по хешу.
### D4. Инвариант «одна активная загрузка на infohash» — двумя атомарными операциями store
`idempotency_key` и его CASE-восстановление удаляются; активность — чисто
функция `state` (terminalStates).
**Критично:** сегодня финальный backstop инварианта — partial unique index
по `idempotency_key`, и на него опираются **пять** путей записи (комментарии
в коде прямо ссылаются на индекс): приём (`ingest`), adopt чужого торрента
(`worker/discover.go`), ручной `Retry` (`worker.go`), воскрешение сверкой
(`reconcile.go:reconcileOneRecovery`), `Relink` (`review.go`). Ни один из
них сейчас не делает check+write в одной транзакции — гонку закрывал индекс.
С удалением индекса **все пять** обязаны пройти через атомарные операции.
Вводим два guarded-метода `store`, каждый — одна write-транзакция
(`BEGIN IMMEDIATE`; SQLite сериализует писателей, поэтому check-then-write
внутри одной write-tx гонок не имеет):
- `CreateDownloadIfNoActive(d, hashes)` — проверка «нет активной загрузки с
любым из хешей» (join `download_infohash` × `state`) → вставка `download`
+ хешей; иначе возвращает существующую активную (семантика дедупа приёма),
дописав ей недостающие хеши из вызова (второй хеш гибрида не теряется).
Используют ingest и discover-adopt.
- `ActivateIfNoOtherActive(id, toState, …)` — проверка «никакая ДРУГАЯ
активная загрузка не владеет любым из хешей этой» (сама задача исключена
из выборки — stuck-задача при retry активна и не должна маскировать
чужого владельца) → переход состояния; иначе отказ. Используют Retry,
recovery-воскрешение, Relink; отказ — ДО побочных эффектов (Retry
активирует до повторного qbt.Add, при сбое Add откатывает состояние).
- `AddInfohashes(id, hashes)` — дозапись хешей (раскрытие гибрида) под тем
же гардом: хеш чужой активной задачи не дописывается (ErrInfohashTaken).
Механический бэкстоп вместо удалённого unique-индекса: `SetDownloadState`
отклоняет переход терминал→активное (предикат в UPDATE) — оживление идёт
только через `ActivateIfNoOtherActive`.
`FindActiveByInfohash`/`ExistsByInfohash` переезжают на join по
`download_infohash` и остаются для чтения (не как гард).
### D5. Накопление хешей из поллинга
Magnet-парсер извлекает btih (v1) **и** btmh (v2) гибридной ссылки
(`Info.Infohashes`, v1 первым) — приём записывает ВСЕ известные хеши,
`kind` — по длине hex: 40 = v1, 64 = v2 (не хардкодить v1). Когда
qBittorrent отдаёт торрент с заполненными `infohash_v1`/`infohash_v2`,
поллинг дописывает недостающие строки через guarded `AddInfohashes`.
Сборщик хешей торрента один — `torrentHashes`: поле `hash` qBittorrent
берётся только при пустых v1/v2 (старый API), потому что у v2-only раздач
это УСЕЧЁННЫЙ v2 (40 hex, по длине неотличим от v1) — его не храним, а
SourceRef усыновления строится из полноразмерного хеша (btih/btmh).
Сопоставление раздачи — по любому из хешей загрузки; live-карта воркера
уже ключуется всеми формами хеша торрента, модель с ней совместима.
### D6. Миграция: одна Go-миграция goose
Первая Go-миграция в проекте (до сих пор — только SQL-файлы из embed).
Механизм: goose поддерживает смешение — classic API (`goose.SetBaseFS` +
`goose.Up`) подхватывает и зарегистрированные Go-миграции. Регистрация —
`goose.AddMigrationContext` в `init()` пакета `store/migrations`, файл
`0006_*.go`; пакет становится Go-пакетом и должен быть импортирован из
`store.go` (иначе `init()` не выполнится). SQL не может генерить ULID —
поэтому Go.
Вся миграция — в одной транзакции goose. Важно: `PRAGMA foreign_keys=OFF`
внутри транзакции — тихий no-op в SQLite, а DSN включает FK на каждом
соединении, поэтому **работаем с включёнными FK** и соблюдаем порядок:
1. Прочитать строки старых таблиц **в порядке старого `id`** (хронология).
2. Сгенерить маппинг `old int id → ULID` для каждой таблицы:
**timestamp-часть — из `created_at` строки** (UTC в БД), entropy — через
`ulid.Monotonic`-reader. `created_at` имеет секундное разрешение и
дубли — норма (батч `file_link`): monotonic-инкремент entropy при равном
timestamp сохраняет относительный порядок старых id. Непарсибельный
`created_at` → время миграции.
3. Создать новые таблицы (`*_new`) **родители первыми**, дочерние — с
`REFERENCES` на `*_new`-родителей; заливать данные тоже родители-первыми
(FK включены — порядок обязателен). `download.infohash` разносится в
`download_infohash_new` (lowercase, `kind` по длине hex);
`idempotency_key` и `download.infohash` опускаются.
4. `DROP` старых таблиц **дети первыми**, затем `ALTER TABLE … RENAME`
(`*_new` → канонические имена; SQLite ≥ 3.25 переписывает `REFERENCES`
в ссылающихся таблицах при переименовании), пересоздать индексы.
5. Финальный `PRAGMA foreign_key_check` как самопроверка.
### D7. Границы: URL, ссылки, логи
- `httpapi`: парсинг `{id}` централизован в `pathID` — там `ident.Parse`
вместо `strconv.ParseInt`; невалидный id → 404 без похода в БД. Вне
`{id}`-роутов: `candidate_id` из формы ревью, сентинелы `downloadID > 0`
в `errBody`/`userErr` (со string — `!= ""`). **BREAKING для REST JSON**:
поле `id` в DTO меняет тип `number → string`.
- Telegram: ссылки бота ведут на `/review/{id}` (не только `/download/`),
callback-data содержит id (`parseCallback` через `strconv.ParseInt`,
сентинел `id == 0`, `pending map[int64]int64`) — всё переводится на
string/ULID. Старые сообщения: числовые URL отдадут 404, нажатие старой
inline-кнопки должно получать понятный ответ «кнопка устарела», а не
панику/тишину.
- Логи: поле `<entity>_id` у каждой сущности (`download_id`,
`recognition_id`, `batch_id`); scoped-логгер уже есть; grep по голому ULID
— штатный способ корреляции наравне с jq.
- Сортировки: списки сегодня сортируются `ORDER BY id DESC`
`COALESCE(source_added_at, created_at), id`) — с ULID это остаётся
корректным благодаря сортируемости и хронологическому бэкфиллу; менять
запросы не требуется.
### D8. Экспозиция множества хешей наружу
`download.infohash` читают не только дедуп и поллинг: карточка загрузки и
кнопка копирования (требование web-ui), REST DTO, live-лукап
(`Live(d.Infohash)`), scoped-логгеры воркера, поиск в списке
(`listWhere … infohash LIKE`). Решения:
- Модель `Download` дополняется срезом хешей, подгружаемым вместе с записью
(или методом `store`); карточка и REST показывают **все** хеши загрузки
(v1 и v2, каждый с копированием); REST-поле `infohash` заменяется на
`infohashes` (список).
- Live-лукап — по любому из хешей (live-карта воркера уже ключуется всеми
формами хеша торрента).
- Scoped-логгер кладёт в поле `infohash` первый известный хеш (для
корреляции этого достаточно — id теперь главный ключ поиска по логам).
- Поиск в списке — `EXISTS`-подзапрос по `download_infohash` вместо
`LIKE` по удаляемому столбцу.
### D9. Конвенция
Новый `docs/conventions/database.md`: PK — TEXT ULID, генерится приложением
через `internal/ident`; числовой AUTOINCREMENT не используем; у
деталей/связей допустим естественный/составной ключ; канонический вид id —
lowercase, нормализация на входных границах. Ссылки — из README конвенций и
CLAUDE.md. ER-схема `docs/specs/database.md` обновляется в том же change.
## Risks / Trade-offs
- [Гонка дедупа без UNIQUE-гарда] → check-then-insert строго в одной
write-транзакции (`BEGIN IMMEDIATE`), SQLite сериализует писателей.
- [Ошибка миграции портит данные] → миграция в транзакции goose
(SQLite умеет транзакционный DDL); перед деплоем — копия файла БД
(штатный бэкап на umbar пока не автоматизирован — сделать руками).
- [Старые URL в истории Telegram/закладках и старые inline-кнопки ломаются]
→ принято: домашний сервис, история коротка; редиректов со старых числовых
id не делаем; на устаревшую callback-data бот отвечает понятной ошибкой.
- [`created_at` непарсибелен] → fallback на время миграции, порядок ULID
внутри таблицы всё равно монотонен (entropy-инкремент).
- [Коллизия ULID] → 80 бит энтропии на миллисекунду, единственный генератор
в одном процессе — пренебрежимо.
- [Первая Go-миграция усложняет store/migrations] → цена принята: паттерн
понадобится и дальше (backfill-миграции), закладываем аккуратно.
## Migration Plan
1. Код + миграция в одном бинаре; goose прогоняет миграцию на старте, как
обычно.
2. Перед деплоем на umbar — ручная копия SQLite-файла (data-том).
3. Откат = восстановить копию файла + прежний бинарь (совместимость схем
вниз не поддерживаем).
## Open Questions
- Нет блокирующих. Мелочь на реализацию: `hint`/`override`/
`metadata_candidate` нигде не светятся наружу — их ULID нужны только для
единообразия и логов, отдельных требований не несут.
@@ -0,0 +1,69 @@
## Why
Идентичность в домене сегодня держится на двух хрупких вещах: загрузка
фактически идентифицируется инфохэшем (`idempotency_key`), хотя у одной
логической загрузки хешей несколько (v1/v2/гибрид, перезалив — другой хеш),
а первичные ключи всех таблиц — числовые автоинкременты, не уникальные между
таблицами и неудобные для корреляции в логах. Это фундамент (шаг 1 черновика
[logical-title-model](../../../docs/drafts/logical-title-model.md)) для
«второго сезона», «докачивания» и истории переходов; менять PK дешевле
сейчас, пока БД маленькая и ссылок на идентификатор немного.
## What Changes
- **ULID (канонически lowercase) как TEXT PK всех сущностей**: `download`,
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
Генерация — в приложении (`oklog/ulid`, monotonic entropy). **BREAKING**:
формат id меняется в URL (`/download/{id}`, `/review/{id}`), ссылках и
callback-data Telegram-бота, логах; в REST JSON поле `id` меняет тип
`number → string`, поле `infohash` заменяется списком `infohashes`.
- **Новая таблица `download_infohash`** (`download_id`, `infohash`,
`kind` v1|v2, составной PK `(infohash, download_id)` — один хеш легитимно
принадлежит нескольким загрузкам во времени) — множество хешей одной
загрузки; дедуп переезжает на проверку активности по этой таблице,
столбцы `download.idempotency_key` и `download.infohash` удаляются.
- **Поиск по любому из хешей** — при приёме (дедуп) и в поллинге qBittorrent.
- `apply_batch_id` генерируется как ULID (столбец уже TEXT).
- **Go-миграция goose**: backfill ULID существующим строкам с timestamp-частью
из `created_at` (сортировка id сохраняет хронологию), переписывание FK,
разнос текущего `infohash` в `download_infohash`.
- **Конвенция `docs/conventions/database.md`**: PK — TEXT ULID, генерится
приложением; числовой AUTOINCREMENT не используем; у деталей/связей
допустим естественный ключ.
- **Логи**: у каждой сущности поле `<entity>_id`; глобальная уникальность
ULID делает grep по голому id штатным способом корреляции; обновить
примеры в `docs/conventions/logging.md`.
- ER-схема `docs/specs/database.md` обновляется в этом же change.
## Capabilities
### New Capabilities
- `identity`: как система идентифицирует сущности домена — ULID-ключи и их
канонический вид (нормализация на входных границах), множество инфохэшей
загрузки, инвариант «одна активная загрузка на infohash» (дедуп при приёме),
сопоставление раздачи в поллинге по любому из хешей.
### Modified Capabilities
- `state-reconciliation`: требование «терминализация восстанавливает
`idempotency_key`» меняется — инвариант «одна активная задача на infohash»
обеспечивается проверкой активности по `download_infohash`, отдельный
снимаемый/восстанавливаемый ключ исчезает.
## Impact
- **Код**: `store` (типы id `int64 → string`, все запросы, миграция),
`ingest` (дедуп через `download_infohash`), `worker` (сопоставление в
поллинге по множеству хешей), `httpapi`/веб-UI (парсинг и валидация ULID в
`/download/{id}`, ссылки), Telegram-уведомления (ссылки на загрузку).
- **Зависимости**: + `github.com/oklog/ulid/v2` (чистый Go, CGO не нужен).
- **БД**: пересоздание всех шести таблиц (SQLite меняет PK только через
rebuild) одной миграцией; первая Go-миграция в проекте — goose до сих пор
использовался только с SQL-файлами, нужна регистрация Go-миграций.
- **Документация**: новая `docs/conventions/database.md`, правки
`docs/conventions/logging.md`, ER-схема `docs/specs/database.md`,
ссылка на новую конвенцию из `CLAUDE.md`/README конвенций.
- **Не меняется**: семантика дедупа (нашли активную загрузку по любому хешу →
та же загрузка), явные `ORDER BY created_at` в списках, инварианты
безопасности данных.
@@ -0,0 +1,156 @@
# identity — идентичность сущностей домена
Как система идентифицирует сущности: ULID-ключи и их канонический вид,
множество инфохэшей загрузки, дедупликация приёма, корреляция в логах.
## ADDED Requirements
### Requirement: ULID как первичный ключ сущностей
Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
Crockford base32, генерируемый приложением в момент создания записи через
единственную точку генерации (`internal/ident`). Сущности: `download`,
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
Канонический вид SHALL быть lowercase. Числовые AUTOINCREMENT-ключи в новых таблицах
использоваться SHALL NOT. Идентификатор партии раскладки (`apply_batch_id`)
SHALL генерироваться тем же способом.
#### Scenario: Создание загрузки
- **WHEN** принимается новая загрузка
- **THEN** её `id` — валидный ULID в lowercase
- **AND** `id` уникален глобально (не совпадает с id других сущностей)
#### Scenario: Хронологическая сортировка
- **GIVEN** две загрузки, созданные последовательно
- **WHEN** записи сортируются по `id` лексикографически
- **THEN** порядок совпадает с порядком создания
### Requirement: Нормализация и валидация id на входных границах
Внешние идентификаторы SHALL валидироваться как ULID и нормализоваться к
lowercase до обращения к хранилищу — это касается всех входных границ:
URL `/download/{id}`, параметры форм и команд. Синтаксически невалидный id SHALL обрабатываться как
несуществующая сущность (404 для страниц), без обращения к БД.
#### Scenario: Uppercase-вариант id в URL
- **GIVEN** существующая загрузка с id `01jz…` (lowercase)
- **WHEN** клиент открывает `/download/01JZ…` (uppercase)
- **THEN** открывается страница той же загрузки
#### Scenario: Мусор вместо id
- **WHEN** клиент открывает `/download/abc!!!`
- **THEN** ответ — 404, запрос к БД не выполняется
### Requirement: Множество инфохэшей загрузки
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
`infohash` lowercase hex, `kind``v1`|`v2`). При приёме magnet-ссылки
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
приём после терминального состояния), но активной из них MUST быть не более
одной.
#### Scenario: Гибридный торрент раскрывает оба хеша
- **GIVEN** загрузка принята по magnet с v1-хешем
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
`infohash_v2`
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
#### Scenario: Сопоставление по v2-хешу
- **GIVEN** загрузка с записями v1- и v2-хешей
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
- **THEN** раздача сопоставляется с этой загрузкой
### Requirement: Дедупликация приёма по любому из хешей
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
более одной активной загрузки на infohash». Отдельного снимаемого/
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
выводится только из `state`.
#### Scenario: Повторный приём при активной загрузке
- **GIVEN** активная загрузка с infohash `h`
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
#### Scenario: Повторный приём после завершения
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
### Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
возвращающем загрузку из терминального состояния в активное (ручной retry,
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
сохраняя инвариант «не более одной активной загрузки на infohash».
Отказ SHALL происходить до побочных эффектов во внешних системах
(повторного добавления торрента в qBittorrent).
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
гибридного торрента): хеш, которым владеет другая активная загрузка,
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
бэкстоп вместо удалённого unique-индекса).
#### Scenario: Retry при занятом хеше
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
#2 с тем же `h`
- **WHEN** пользователь вызывает retry для #1
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
- **AND** активной по `h` остаётся #2
### Requirement: Корреляция сущностей в логах
Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
`<entity>_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в
контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
все записи журнала, относящиеся к сущности, независимо от имени поля.
#### Scenario: Путь загрузки по логам
- **GIVEN** загрузка прошла приём, распознавание и раскладку
- **WHEN** журнал фильтруется по значению её `id`
- **THEN** находятся записи всех этапов (ingest, recognition, file-layout)
### Requirement: Миграция существующих записей
Существующие записи SHALL получить ULID-идентификаторы одной миграцией с
сохранением всех связей (FK) и хронологии: timestamp-часть ULID SHALL
браться из `created_at` записи, чтобы лексикографический порядок новых id
соответствовал историческому порядку создания. Существующий
`download.infohash` SHALL быть перенесён в `download_infohash`
(нормализация к lowercase, `kind` по длине hex: 40 — `v1`, 64 — `v2`);
столбцы `download.infohash` и `download.idempotency_key` SHALL быть удалены.
#### Scenario: Связи и порядок после миграции
- **GIVEN** БД с загрузками, распознаваниями и файловыми ссылками на
числовых id
- **WHEN** миграция выполнена
- **THEN** все FK-связи сохранены (распознавания/ссылки указывают на те же
загрузки)
- **AND** порядок загрузок по `id` совпадает с порядком по `created_at`
- **AND** каждый прежний `infohash` представлен записью в
`download_infohash`
@@ -0,0 +1,113 @@
# state-reconciliation — дельта для ulid-identity
Механика идемпотентности меняется: снимаемый/восстанавливаемый
`idempotency_key` исчезает, инвариант «не более одной активной задачи на
infohash» обеспечивается проверкой активности по `download_infohash`
(см. capability `identity`).
## MODIFIED Requirements
### Requirement: Периодическая сверка состояния с реальностью
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача, совпавшая с **любым из известных хешей** загрузки в
`download_infohash`, в выдаче qBittorrent) и присутствия **цели** (см.
требование о владении целевым путём: существуют все ссылки последнего батча
со статусом раскладки, всё ещё принадлежащие этой загрузке).
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
сверка по матрице трогать SHALL NOT.
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
требование о восстановлении зависшей загрузки), не по матрице «источник ×
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
#### Scenario: Источник и цель на месте — состояние не меняется
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
разложенные хардлинки существуют
- **THEN** задача остаётся в `done`
- **AND** запись состояния и лог перехода не выполняются
#### Scenario: Частичная пропажа цели считается отсутствием
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
#### Scenario: Задача в deleted сверкой не переоценивается
- **WHEN** задача находится в `deleted`
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
- **WHEN** задача в `failed` с `error_code` `qbit_error`
- **THEN** сверка её не рассматривает и состояние не меняет
### Requirement: Восстановление зависшей загрузки при оживлении источника
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
источник в qBittorrent жив и продвинулся: переход выводится из текущего
состояния торрента так же, как при штатной сверке загрузки
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
`downloading`). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи.
После возврата в любое нетерминальное состояние (`downloading` или
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
на эту задачу: активность задачи выводится только из её `state`, отдельный
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
другая активная задача (новый приём, пока эта лежала упавшей), система
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
сохраняя инвариант «не более одной активной задачи на infohash».
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
метаданным в `docs/specs/workflow.md`).
#### Scenario: Метаданные пришли после magnet_timeout
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже получил метаданные и качается (`downloading`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача возвращается в `downloading`
- **AND** повторный приём того же infohash снова дедуплицируется на неё
#### Scenario: Торрент уже завершился, пока задача была в failed
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача переходит в `completed` и продолжает обычный поток
(распознавание/раскладка)
#### Scenario: Источник так и не ожил — состояние не меняется
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача остаётся в `failed`
#### Scenario: infohash уже занят другой активной задачей
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
пытается воскресить #1
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
- **AND** активной по этому infohash остаётся #2
@@ -0,0 +1,81 @@
## 1. Фундамент: пакет ident
- [x] 1.1 Добавить зависимость `github.com/oklog/ulid/v2`; пакет
`internal/ident`: `NewID()` (lowercase, monotonic entropy,
потокобезопасно), `NewIDAt(t time.Time)` (для миграции/бэкфилла),
`Parse(s)` (нормализация регистра + валидация); тесты
(lowercase, сортируемость, отказ на мусоре)
## 2. Схема и миграция
- [x] 2.1 Механизм Go-миграций goose в `internal/store/migrations`
(регистрация через `goose.AddMigrationContext`, совместный прогон с
embed SQL-миграциями)
- [x] 2.2 Миграция 0006: новые таблицы с TEXT ULID PK (все шесть), перенос
данных с маппингом `int → ULID` (timestamp из `created_at`, fallback —
время миграции), перенос `download.infohash``download_infohash`
(lowercase, `kind` по длине hex), удаление `download.infohash` и
`download.idempotency_key`, пересоздание индексов
- [x] 2.3 Тест миграции на фикстурной БД: FK-связи сохранены, порядок по
`id` = порядок по `created_at`, хеши разнесены, `idempotency_key`
отсутствует
## 3. Store
- [x] 3.1 Типы id `int64 → string` во всех структурах и методах `store`
(download, recognition, hint, override, metadata_candidate,
file_link, list); генерация ULID через `ident.NewID()` во ВСЕХ
Create-методах (вместо `LastInsertId`)
- [x] 3.2 Guarded-методы инварианта (design D4):
`CreateDownloadIfNoActive` и `ActivateIfNoOtherActive`, каждый — одна
write-транзакция; `FindActiveByInfohash`/`ExistsByInfohash` join'ом
по любому хешу (для чтения); убрать CASE-восстановление
`idempotency_key` из `SetDownloadState`
- [x] 3.3 Методы хешей: добавить недостающие хеши загрузке
(INSERT OR IGNORE), получать хеши вместе с Download (срез в модели)
- [x] 3.4 Поиск в списке (`listWhere`): `EXISTS`-подзапрос по
`download_infohash` вместо `LIKE` по удаляемому `download.infohash`
## 4. Ядро и воркер
- [x] 4.1 Приём (`ingest`) и discover-adopt — через
`CreateDownloadIfNoActive`; хеш из magnet (btih ИЛИ btmh, `kind` по
длине hex) пишется в той же транзакции; сигнатуры
`Result.DownloadID`, `notifyFailed`, `Notifier.Notify`,
`failNotified` — на string
- [x] 4.2 Поллинг/сверка: сопоставление раздачи по любому из хешей загрузки
(Poll, desync, recovery, preflight); дописывание недостающих v1/v2,
когда qBittorrent отдаёт оба; scoped-логгеры — первый известный хеш
- [x] 4.3 Retry, recovery-воскрешение и Relink — через
`ActivateIfNoOtherActive` (сейчас гонку закрывал unique-индекс —
см. design D4); понятная ошибка при занятом хеше
- [x] 4.4 `apply_batch_id` генерировать через `ident.NewID()`
## 5. Внешние границы
- [x] 5.1 `httpapi`: `ident.Parse` в `pathID` (невалидный → 404 без похода
в БД) и для `candidate_id` из формы ревью; сентинелы
`downloadID > 0 → != ""`; REST DTO: `id` string, `infohash`
список `infohashes`; карточка показывает все хеши с копированием;
live-лукап по любому хешу; проверить шаблоны и ссылки
- [x] 5.2 Telegram (`tgbot`): `parseCallback` и callback-data на string-id,
`pending map[int64]int64 → map[int64]string`, сентинел `id == 0 →
== ""`, ссылки `/review/{id}`; понятный ответ на устаревшую
callback-data со старым числовым id
## 6. Логи и документация
- [x] 6.1 Атрибуты `<entity>_id` в логах: `recognition_id` у попыток
распознавания, `batch_id` у раскладки; сверить с scoped-логгером
- [x] 6.2 `docs/conventions/logging.md`: примеры id в формате ULID, grep по
голому id как штатная корреляция
- [x] 6.3 Новая `docs/conventions/database.md` (TEXT ULID PK, без
AUTOINCREMENT, естественные ключи у деталей, lowercase + нормализация);
ссылки из `docs/conventions/README.md` и `CLAUDE.md`
- [x] 6.4 ER-схема `docs/specs/database.md`: ULID PK, `download_infohash`,
удалённые столбцы
## 7. Проверка
- [x] 7.1 `task test` и `task lint` зелёные; ручной прогон: приём magnet →
дедуп повторного приёма → страница `/download/{id}` с ULID в URL
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-03
@@ -0,0 +1,202 @@
## Context
Набор capabilities в `openspec/specs/` сложился по ходу пилотной миграции и не
отражает цепочку обработки загрузки. Три проблемы:
1. `recognition` смешивает разбор LLM и работу с метабазами.
2. Поведение ревью размазано: часть — в `web-ui`, основная часть — в не
перенесённом `docs/specs/review-ux.md`.
3. Звенья цепочки `file-layout`, `download-tracking`, `notifications` в OpenSpec
отсутствуют — источник истины по ним в `docs/specs/` (`jellyfin-layout.md`,
`workflow.md`, `architecture.md`).
Разбор цепочки «загрузка → распознавание → матч → ревью → раскладка» (действие →
артефакт в БД) дал естественный набор доменов; приводим capabilities к нему.
Ограничение: **рефакторинг только спек, код не трогаем.** Формулировки переносим
эквивалентными — ни одно нормативное требование не должно измениться по смыслу.
## Goals / Non-Goals
**Goals:**
- Одно поведение — один capability; имя читается без знания кода.
- Полная цепочка представлена в `openspec/specs/` (перенос из `docs/specs/`).
- Источник истины по мигрируемым темам переезжает в OpenSpec; в `docs/specs/`
остаётся пометка о переезде (как пилот `ingest`).
**Non-Goals:**
- Никаких изменений поведения, схемы БД, кода, конфигурации.
- Не вводим новые сущности домена (напр. «тайтл» — отдельная задача беклога).
- Не дробим метабазу на search/match (решено: одна `metadata-match`).
- Мульти-бот маршрутизация уведомлений — отдельная задача, здесь только фиксируем
текущее поведение `notifier`.
## Decisions
### D1. Механика переноса требований между capabilities
OpenSpec 1.4.x поддерживает `RENAMED` только внутри одной спеки. Межспековый
перенос оформляем парой: **`REMOVED Requirement`** в исходной спеке (с
`**Reason**`/`**Migration**`, указывающими целевую capability) + **`ADDED
Requirement`** в целевой. Текст требования в ADDED — эквивалент исходного
(перенос, не переписывание). Альтернатива (просто RENAMED) не подходит:
capability меняется, а не имя внутри одной спеки.
### D2. `recognition``recognition` + `metadata-match`
Разные действия: «разобрать сигналы моделью» и «найти/подтвердить запись в базе».
`Контракт LLM на оригинальное и локализованное названия` **остаётся в
recognition** (это требование к выходу модели), хотя используют эти поля обе:
recognition — чтобы модель их заполнила, metadata-match — как ключи поиска.
| Текущее требование (recognition) | Назначение |
|---|---|
| Сверка с базой по нескольким названиям | → `metadata-match` |
| Контракт LLM на оригинальное и локализованное названия | остаётся `recognition` |
| Локаль запроса к TMDB | → `metadata-match` |
| Нормализация названий при сравнении | → `metadata-match` |
| Кандидат несёт URL для внешней проверки | → `metadata-match` |
Backfill `recognition` из `docs/specs/recognition.md` (ADDED — сейчас в OpenSpec
этих требований нет):
- Пред-парс имени релиза (`go-ptn`).
- Разбор сигналов LLM в структурированный план (схема: `type`, `title`,
`original_title`, `year`, `provider_hint`, `files[]` с per-file `season`/
`episode`, `confidence`).
- Провайдер LLM за абстракцией (`openai-compat`, JSON-mode, ретраи с передачей
ошибки/схемы; неразобранный после ретраев → review, не failed).
- Модель уверенности и решение auto/review (авто только при подтверждённом матче
в базе + структурная валидация + согласованность с пред-парсом).
- Роли файлов на краях (sample/extra/ignore, привязка внешних субтитров).
ADDED в `metadata-match` (сам поиск/подтверждение, backfill из `recognition.md`
§3, помимо 4 перенесённых):
- Поиск записи в TMDB/TVDB/TVMaze, сбор кандидатов с дедупом `provider:id`.
- Подтверждение единичного сильного матча → `provider`/`provider_id`/канон. имя/год.
- Опциональность баз: нет баз или нет матча → авто-раскладки нет (граница с
recognition-решением; здесь — что матч не подтверждён).
### D3. `review` как отдельный capability
Переносим из `web-ui`:
| Текущее требование (web-ui) | Назначение |
|---|---|
| Единый список источников совпадения на ревью | → `review` |
| Ручное добавление источника по id или URL | → `review` |
| Предпросмотр полей источника до фиксации выбора | → `review` |
`Матч с записью метабазы ссылкой` **остаётся в web-ui** — это отображение на
странице загрузки, а не действие ревью.
ADDED в `review` (миграция `docs/specs/review-ux.md`):
- Триггеры входа в review с явной причиной.
- Команды: Применить, Уточнить (подсказка+перераспознавание), Распознать заново,
Тип, Игнор файла, Позже, Отклонить, Undo, Привязать заново.
- Подсказка (мягкая, интерпретирует LLM) vs override (жёсткий пин;
перераспознавание не затирает).
- Единый список источников (нейронка наравне с кандидатами) + ручное добавление
+ предпросмотр «превью = применение» (перенесены из web-ui, п. выше).
- Разделение труда транспортов (веб — точные правки, Telegram — быстрые действия/
эскалация в веб); одно состояние ревью, команды сериализует worker.
### D4. Миграция `file-layout`, `download-tracking`, `notifications` из docs/specs
**`file-layout`** ← `docs/specs/jellyfin-layout.md`:
- Целевые имена фильмов (`Название (Год) [providerid-…]`).
- Целевые имена сериалов (папка с provider-id, `Season xx`, `SxxEyy`).
- Сопоставление источник→цель хардлинками (`save_path`+относит. имя; mkdir 0755).
- Санитизация целевого имени и запрет выхода за библиотеку.
- Never-overwrite (тот же inode → готово; другой файл → коллизия → review).
- Copy-fallback при невозможности хардлинка.
**Владение целевым путём** (`superseded`) и **безопасный undo** (`nlink<=1`
`ErrLastCopy`) в `file-layout` НЕ дублируем: их доминирующая забота — сверка с
реальностью (присутствие цели определяется по владению; отказ Undo при пропавшем
источнике), и они уже живут более полными версиями в `state-reconciliation`.
`file-layout` описывает акт прямой раскладки; на владение/undo он опирается,
оставляя их дом в `state-reconciliation` (см. ревью дизайна: устранение дубля).
**`download-tracking`** ← `docs/specs/workflow.md` (прямой путь; сверка
разложенного остаётся в `state-reconciliation`):
- Поллинг qBittorrent и сопоставление его состояний с нашими.
- Готовность только когда файлы на месте (не `moving`/`checking*`).
- Таймауты-предохранители: `metaDL` > `magnet_timeout` → failed; `stalledDL` >
`stuck_after` → stuck; базис возраста — `added_on` (переживает retry/усыновление).
- Ошибка qBit (`error`/`missingFiles`) → failed (`qbit_error`).
- Усыновление раздач по категории **или** тегу, которых нет в БД → `downloading`.
- Владелец переходов — worker под per-download блокировкой (FSM).
**`notifications`** ← `workflow.md` + `state-reconciliation`:
- Уведомление автора о падении (`failed`/`stuck`), включая приёмный `qbit_add`
мимо поллинга.
- Дебаунс повторных падений одной задачи (мерцающий stalled не спамит).
- Пинг о входе в review и о готовности.
- Уведомление о рассинхроне (перенос из `state-reconciliation`).
| Текущее требование (state-reconciliation) | Назначение |
|---|---|
| Уведомление о рассинхроне | → `notifications` |
| (остальные 11) | остаются `state-reconciliation` |
### D5. `identity` → инфраструктура id; приём → `ingest`
| Текущее требование (identity) | Назначение |
|---|---|
| ULID как первичный ключ сущностей | остаётся `identity` |
| Нормализация и валидация id на входных границах | остаётся `identity` |
| Множество инфохэшей загрузки | → `ingest` |
| Дедупликация приёма по любому из хешей | → `ingest` |
| Атомарность возврата загрузки в активное состояние | → `ingest` |
| Корреляция сущностей в логах | остаётся `identity` |
| Миграция существующих записей | остаётся `identity` |
Примечание: `Атомарность возврата в активное состояние` используется и «Привязать
заново» (review) — но это инвариант приёма/активации загрузки, поэтому его дом —
`ingest`; review на него ссылается.
### D6. Формулировки — эквивалентный перенос
При ADDED в целевой capability текст берём из исходного требования (или из
`docs/specs/` при backfill), сохраняя нормативную силу (SHALL/MUST) и сценарии.
Правки — только связочные (ссылки на соседние capability), не смысловые. Это
делает `openspec archive` безопасным: живые спеки после влития эквивалентны сумме
прежних.
## Risks / Trade-offs
- **Большой diff, риск потерять требование при переносе** → Перенос по таблицам
D2–D5 (чек-лист: каждое исходное требование учтено — либо STAY, либо REMOVED+
ADDED). `openspec validate --strict` + сверка счётчика требований до/после.
- **Расхождение docs/specs ↔ openspec после переезда**В `docs/specs/`
мигрированных файлов ставим шапку «источник истины переехал в
`openspec/specs/<cap>`»; содержимое не дублируем.
- **Граница recognition ↔ metadata-match может «поплыть» на будущих задачах**
(сила совпадения кандидата — идея беклога) → Сейчас фиксируем по действию;
дальнейшее уточнение — отдельным change.
- **Пробел: у `ingest` нет формального требования на сам приём** (парс magnet →
завести download → отдать в qBittorrent) — сейчас поведение не выражено
требованием ни в одной спеке → см. Open Questions.
## Migration Plan
1. Дельты change: ADDED в новых/целевых спеках, REMOVED в исходных (D1).
2. `openspec validate --strict refactor-capability-boundaries`.
3. Ревью дизайна (этот документ) — **чекпоинт до написания всех дельт**.
4. После апрува — генерация дельта-спек, повторная валидация.
5. Пометки о переезде в `docs/specs/` мигрированных файлов; снять пункт беклога.
6. `openspec archive` — влить дельты в `openspec/specs/`.
Откат: change не тронул код; отмена = удалить директорию change (спеки не влиты до
archive).
## Resolved Questions
1. **Ядро приёма в `ingest` — ДА.** Добавляем ADDED «Приём источника и заведение
загрузки» (backfill из `architecture.md` → «Транспорты»): парс magnet → дедуп →
завести `download`+`download_infohash` → отдать в qBittorrent; ошибка добавления
`failed` (`qbit_add`). Иначе `ingest` остаётся про имя+инфохэши без ядра.
2. **«Секция раздачи на странице загрузки» — остаётся в `live-status`** (меняется
вместе с телеметрией).
3. **Один change** (решение автора). При необходимости порядок дельт разложим на
этапе apply.
@@ -0,0 +1,80 @@
## Why
Деление capabilities в OpenSpec сложилось стихийно по ходу миграции и смешивает
разные заботы в одной спеке: `recognition` держит и разбор через LLM, и работу с
внешними базами; поведение ревью размазано между `web-ui` и не перенесённым
`docs/specs/review-ux.md`; ключевые звенья цепочки (раскладка, отслеживание
загрузки, уведомления) в OpenSpec отсутствуют вовсе. На каждой задаче приходится
гадать, какой capability трогать. Приводим набор capabilities к цепочке
«загрузка → распознавание → матч → ревью → раскладка», где имя capability
отвечает **одному** поведению.
Это чисто спецификационный рефакторинг: **поведение системы не меняется**, код не
трогаем. Меняются только границы и расположение требований.
## What Changes
- **Разделяем `recognition`**: оставляем в нём только разбор сигналов LLM (план,
тип, название, файлы→серии, модель уверенности, решение auto/review); всю работу
с метабазами выносим в новую `metadata-match`.
- **Вводим `review`** как отдельный capability: переносим поведение ревью из
`web-ui` (единый список источников, ручное добавление источника, предпросмотр
полей) и мигрируем `docs/specs/review-ux.md`.
- **Подрезаем `web-ui`** до чистого оформления: статика, шрифты, дизайн-система,
скелет страниц, бейджи, клиентские взаимодействия, превью через единую логику
именования.
- **Мигрируем в OpenSpec** три звена цепочки, живущие пока в `docs/specs`:
`file-layout` (из `jellyfin-layout.md`), `download-tracking` (FSM/поллинг из
`workflow.md`), `notifications` (из `workflow.md` + `architecture.md`).
- **Чистим `identity`** до инфраструктуры id: переносим приёмные требования
(инфохэши, дедуп, атомарный возврат в активное) в `ingest`.
- **Собираем уведомления** в `notifications`: переносим «Уведомление о
рассинхроне» из `state-reconciliation`.
- Не-BREAKING: перенос требований (REMOVED в исходной спеке + ADDED в целевой),
формулировки сохраняем эквивалентными.
## Capabilities
### New Capabilities
- `metadata-match`: поиск записи во внешних базах (TMDB/TVDB/TVMaze) по названиям,
сбор кандидатов, подтверждение единичного сильного матча → `provider`/
`provider_id`/каноническое имя/год, URL кандидата.
- `review`: процесс ревью после распознавания и матча — петля «догадка → подсказка
→ перераспознавание», команды (Применить/Уточнить/Распознать заново/Тип/Игнор/
Позже/Отклонить/Undo/Привязать заново), подсказка vs override, единый список
источников совпадения, ручное добавление источника, предпросмотр (превью=
применение), разделение труда транспортов.
- `file-layout`: целевые имена фильмов/сериалов, сопоставление источник→цель,
хардлинки, санитизация пути и запрет выхода за библиотеку, never-overwrite,
copy-fallback. Владение путём (`superseded`) и безопасный undo (`nlink<=1`)
остаются в `state-reconciliation` (не дублируем — см. design.md).
- `download-tracking`: поллинг qBittorrent и машина состояний загрузки —
`downloading`/`completed`/`stuck`/`failed`, завершение (moving/checking),
таймауты (`magnet_timeout`/`stuck_after`), усыновление по категории/тегу.
- `notifications`: пинги автору загрузки — падения (`failed`/`stuck`, включая
приёмный `qbit_add` мимо поллинга) с дебаунсом, вход в review и готовность,
рассинхрон.
### Modified Capabilities
- `recognition`: убираем требования по метабазам (уезжают в `metadata-match`);
добавляем перенесённый из `docs/specs/recognition.md` разбор сигналов LLM
(пред-парс, схема плана, провайдер LLM, модель уверенности).
- `ingest`: принимает перенесённые из `identity` приёмные требования (множество
инфохэшей, дедуп по любому хешу, атомарный возврат в активное состояние).
- `identity`: убираем приёмные требования — остаётся инфраструктура id (ULID,
нормализация/валидация, корреляция в логах, миграция).
- `web-ui`: убираем ревью-специфику (уезжает в `review`) — остаётся оформление.
- `state-reconciliation`: убираем «Уведомление о рассинхроне» (уезжает в
`notifications`).
## Impact
- Затрагивает только `openspec/specs/**` и `openspec/changes/**`**кода нет**.
- Источник истины по мигрируемым темам переезжает из `docs/specs/`
(`recognition.md`, `review-ux.md`, `jellyfin-layout.md`, `workflow.md`) в
`openspec/specs/` (как пилот `ingest`); в исходных файлах ставим пометку о
переезде, содержимое не дублируем.
- CLAUDE.md и `docs/backlog.md`: снять пункт из беклога, при необходимости
поправить перечисление capabilities.
- Валидация: `openspec validate --strict` для change перед коммитом; после архива
дельты вливаются в `openspec/specs/`.
@@ -0,0 +1,84 @@
## ADDED Requirements
### Requirement: Поллинг qBittorrent и сопоставление состояний
Worker SHALL периодически (`worker.poll_interval`, дефолт 5 с) опрашивать
qBittorrent и сопоставлять его состояния раздачи с состоянием загрузки в БД.
Готовые к раскладке состояния (`uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
`queuedUP`/`forcedUP`, с учётом различий имён между qBit v4 и v5) SHALL переводить
загрузку в `completed`. Ещё качающиеся состояния (`downloading`/`stalledDL`/
`metaDL`/…) SHALL оставлять её в `downloading`.
#### Scenario: Раздача завершилась
- **GIVEN** загрузка в `downloading`
- **WHEN** qBittorrent сообщает состояние `stalledUP` и файлы на месте
- **THEN** загрузка переходит в `completed`
### Requirement: Готовность только когда файлы на месте
Переходные состояния qBittorrent система SHALL трактовать как «ждём»
(`moving`/`checkingUP`/`checkingResumeData`/`allocating`): оставаться в
`downloading` и НЕ объявлять готовность, даже если выставлены флаги `UP`, пока
qBit не завершит перенос/проверку. Финальные пути файлов система SHALL брать из
API после завершения переноса.
#### Scenario: Ждём завершения переноса
- **GIVEN** загрузка, у которой qBittorrent в состоянии `moving`
- **WHEN** идёт тик поллинга
- **THEN** загрузка остаётся в `downloading`, готовность не объявляется
### Requirement: Таймауты-предохранители downloading
Система SHALL переводить `metaDL`/`forcedMetaDL` дольше `magnet_timeout` (дефолт
`24h`, редкий предохранитель) в `failed` (`error_code` `magnet_timeout`), а
`stalledDL` дольше `stuck_after` — в `stuck` (`error_code`
`stalled`). Возраст система SHALL считать от времени добавления в qBittorrent
(`added_on`), а не от создания задачи, чтобы базис переживал retry и усыновление.
Долгий `metaDL` система НЕ SHALL убивать агрессивно (медленные трекеры — норма).
#### Scenario: Завис на метаданных дольше таймаута
- **GIVEN** раздача в `metaDL` дольше `magnet_timeout` от `added_on`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `magnet_timeout`
### Requirement: Ошибка qBittorrent переводит в failed
Состояния `error`/`missingFiles` система SHALL трактовать как настоящий провал и
переводить загрузку в `failed` (`error_code` `qbit_error`) — в отличие от
таймаутов-предохранителей, такой провал сверкой не воскрешается.
#### Scenario: qBit сообщает об ошибке
- **GIVEN** раздача в состоянии `missingFiles`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_error`
### Requirement: Усыновление раздач по категории или тегу
Worker SHALL периодически сверять раздачи qBittorrent с БД и **усыновлять** те, у
которых наша категория (`qbittorrent.category`) ИЛИ тег (`qbittorrent.tag`), а
записи в БД ещё нет, заводя для них загрузку в состоянии `downloading`. Категория
ставится на добавляемые нами раздачи (push); тег позволяет подхватить уже
существующую раздачу (pull), не трогая её категорию и файлы.
#### Scenario: Подхват существующей раздачи по тегу
- **GIVEN** в qBittorrent есть раздача с тегом `qbittorrent.tag`, которой нет в БД
- **WHEN** worker сверяет qBittorrent с БД
- **THEN** для раздачи заводится загрузка в состоянии `downloading`
### Requirement: Переходы состояний под per-download блокировкой
Все переходы состояний загрузки SHALL проходить через worker под per-download
блокировкой, чтобы два транспорта не гонялись за одно состояние. Состояние SHALL
быть персистентным в SQLite; активность загрузки SHALL выводиться только из
`state`, без отдельного флага.
#### Scenario: Команды сериализуются
- **GIVEN** две одновременные команды к одной загрузке из разных транспортов
- **WHEN** они обрабатываются
- **THEN** переходы применяются последовательно под блокировкой, без гонки
@@ -0,0 +1,83 @@
## ADDED Requirements
### Requirement: Целевые имена фильмов
Фильм система SHALL раскладывать в папку и файл вида `Название (Год)`, помещённые
под `paths.movies`. При подтверждённом матче в базе имя папки SHALL нести
provider-id (`[tmdbid-…]`/`[tvdbid-…]`) — он снимает неоднозначность русских
названий для Jellyfin. Внешние субтитры SHALL именоваться `Имя.<lang>[.flag].srt`
(флаги `forced`/`sdh`/`default`/`hi`), с базой имени, совпадающей с именем
видеофайла; пары VobSub — `.idx` + `.sub`.
#### Scenario: Фильм с provider-id
- **GIVEN** распознанный фильм «Дюна Часть вторая» (2024) с матчем TMDB `693134`
- **WHEN** строится целевой путь
- **THEN** папка = `movies/Дюна Часть вторая (2024) [tmdbid-693134]/`
- **AND** видеофайл = `Дюна Часть вторая (2024).mkv`
### Requirement: Целевые имена сериалов
Сериал система SHALL раскладывать под `paths.series` в папку `Название (Год)` с
provider-id на папке сериала, сезонными подпапками `Season xx` и файлами вида
`Название (Год) SxxEyy`.
#### Scenario: Серия сезона
- **GIVEN** распознанный сериал «Фарго» (2024) с матчем TVDB `123456`, серия S01E02
- **WHEN** строится целевой путь
- **THEN** путь = `series/Фарго (2024) [tvdbid-123456]/Season 01/Фарго (2024) S01E02.mkv`
### Requirement: Сопоставление источник → цель хардлинками
Для каждого распознанного **файла** (не каталога) система SHALL создавать
**хардлинк** в `paths.movies`/`paths.series`; исходный путь берётся из
qBittorrent (`save_path` + относительное имя файла из `/torrents/files`, уже
включающее корневую папку многофайловой раздачи). Целевые каталоги SHALL
создаваться `mkdir` (0755, `1000:1000`). Исходный файл система НЕ SHALL трогать —
раздача продолжается, inode общий, диск не дублируется.
#### Scenario: Хардлинк не дублирует данные
- **GIVEN** видеофайл раздачи под `paths.downloads`
- **WHEN** файл раскладывается
- **THEN** в библиотеке создаётся хардлинк на тот же inode
- **AND** исходный файл остаётся на месте
### Requirement: Санитизация целевого пути и запрет выхода за библиотеку
Целевое имя система SHALL санитизировать (без разделителей пути, `..`,
управляющих символов), а финальный путь SHALL проверять на строгое нахождение под
`paths.movies`/`paths.series`. Путь, выходящий за пределы библиотеки, система НЕ
SHALL создавать. Безопасность SHALL держаться на валидации пути, а не на доверии к
выходу LLM.
#### Scenario: Traversal отклоняется
- **GIVEN** распознанное имя, содержащее `../`
- **WHEN** строится и проверяется целевой путь
- **THEN** путь отклоняется как выходящий за пределы библиотеки, хардлинк не создаётся
### Requirement: Существующую цель не перезаписываем
Существующий целевой файл система НЕ SHALL перезаписывать. Если по целевому пути
уже лежит тот же inode — операция идемпотентна (готово); если другой файл —
это коллизия, и задача SHALL уходить в `review`.
#### Scenario: Коллизия уходит в review
- **GIVEN** по целевому пути уже лежит другой файл
- **WHEN** выполняется раскладка
- **THEN** файл не перезаписывается, задача переходит в `review` с причиной коллизии
### Requirement: Copy-fallback при невозможности хардлинка
Система SHALL при невозможности хардлинка (разные ФС или ФС без поддержки жёстких
ссылок) НЕ падать, а копировать файл с предупреждением в лог, помечая ссылку
статусом `copied`.
#### Scenario: Разные ФС — копирование
- **GIVEN** целевой и исходный каталоги на разных ФС
- **WHEN** выполняется раскладка файла
- **THEN** файл копируется, ссылка получает статус `copied`, в лог пишется предупреждение
@@ -0,0 +1,19 @@
## REMOVED Requirements
### Requirement: Множество инфохэшей загрузки
**Reason**: Инфохэши — часть приёма загрузки, а не инфраструктуры id; поведение
относится к capability `ingest`.
**Migration**: Требование перенесено без изменений в `ingest` (см. `specs/ingest`).
### Requirement: Дедупликация приёма по любому из хешей
**Reason**: Дедуп приёма — поведение приёма загрузки, а не инфраструктуры id.
**Migration**: Требование перенесено без изменений в `ingest`.
### Requirement: Атомарность возврата загрузки в активное состояние
**Reason**: Инвариант «не более одной активной загрузки на infohash» — забота
приёма/активации загрузки; логичнее держать рядом с приёмом.
**Migration**: Требование перенесено без изменений в `ingest`; review и retry на
него ссылаются.
@@ -0,0 +1,102 @@
## ADDED Requirements
### Requirement: Приём источника и заведение загрузки
Приём SHALL быть единым use-case, общим для всех транспортов (HTTP, Telegram,
CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL извлечь
инфохэши, дедуплицировать по активной задаче, при отсутствии дубля завести
загрузку (`download` в состоянии `downloading` + записи `download_infohash`) и
отдать источник в qBittorrent (категория `qbittorrent.category`, savepath). Если
добавление в qBittorrent не удалось, система SHALL перевести уже заведённую
загрузку в `failed` (`error_code` `qbit_add`) и уведомить автора. Заведение
загрузки и запись её хешей SHALL выполняться атомарно (см. «Атомарность возврата
загрузки в активное состояние»).
#### Scenario: Успешный приём magnet
- **GIVEN** валидная magnet-ссылка и контекст
- **WHEN** вызывается приём
- **THEN** создаётся `download` в `downloading` с записями `download_infohash`
- **AND** источник отдан в qBittorrent с нашей категорией
#### Scenario: Падение добавления в qBittorrent
- **GIVEN** заведённую загрузку не удалось добавить в qBittorrent
- **WHEN** обрабатывается ошибка добавления
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
- **AND** автор загрузки уведомляется
### Requirement: Множество инфохэшей загрузки
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
`infohash` lowercase hex, `kind``v1`|`v2`). При приёме magnet-ссылки
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
приём после терминального состояния), но активной из них MUST быть не более
одной.
#### Scenario: Гибридный торрент раскрывает оба хеша
- **GIVEN** загрузка принята по magnet с v1-хешем
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
`infohash_v2`
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
#### Scenario: Сопоставление по v2-хешу
- **GIVEN** загрузка с записями v1- и v2-хешей
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
- **THEN** раздача сопоставляется с этой загрузкой
### Requirement: Дедупликация приёма по любому из хешей
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
более одной активной загрузки на infohash». Отдельного снимаемого/
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
выводится только из `state`.
#### Scenario: Повторный приём при активной загрузке
- **GIVEN** активная загрузка с infohash `h`
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
#### Scenario: Повторный приём после завершения
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
### Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
возвращающем загрузку из терминального состояния в активное (ручной retry,
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
сохраняя инвариант «не более одной активной загрузки на infohash».
Отказ SHALL происходить до побочных эффектов во внешних системах
(повторного добавления торрента в qBittorrent).
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
гибридного торрента): хеш, которым владеет другая активная загрузка,
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
бэкстоп вместо удалённого unique-индекса).
#### Scenario: Retry при занятом хеше
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
#2 с тем же `h`
- **WHEN** пользователь вызывает retry для #1
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
- **AND** активной по `h` остаётся #2
@@ -0,0 +1,122 @@
## ADDED Requirements
### Requirement: Сверка с базой по нескольким названиям
При сверке плана с включёнными базами метаданных система SHALL искать по
нескольким названиям в порядке убывания силы ключа: сначала по
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
Поиск SHALL останавливаться, как только очередной запрос дал единичный
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
названием, нормализованно совпадающим с уже выполненным, система SHALL
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
Кандидаты для ручного выбора в review система SHALL собирать из всех
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
#### Scenario: Иностранный фильм находится по оригинальному названию
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
- **WHEN** выполняется сверка с базой
- **THEN** первый запрос идёт по «The Dark Knight»
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
#### Scenario: Фолбэк на локализованное название
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
- **WHEN** продолжается сверка
- **THEN** выполняется запрос по локализованному `title`
- **AND** при отсутствии матча и там — запрос по `provider_hint`
#### Scenario: Дублирующий запрос пропускается
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
- **WHEN** выполняется сверка
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
### Requirement: Подтверждение матча и каноническое имя
При единичном сильном матче система SHALL брать из записи базы официальный
`provider` (`tmdb`|`tvdb`|`tvmaze`) и `provider_id`, а также каноническое название
и год, и подменять ими соответствующие поля плана (для сериала — с учётом внешнего
тега TVDB/IMDb из `externals`, идущего в имя папки). Матч SHALL считаться
подтверждённым только при ровно одном сильном кандидате; при нуле или нескольких
кандидатах подтверждённого матча быть SHALL NOT (авто-раскладка не разрешается,
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
сверка не выполняется и подтверждённого матча нет.
#### Scenario: Единичный матч даёт id и каноническое имя
- **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма
- **WHEN** матч подтверждается
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
#### Scenario: Несколько кандидатов — матч не подтверждён
- **GIVEN** поиск вернул более одного подходящего кандидата
- **WHEN** оценивается матч
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
### Requirement: Локаль запроса к TMDB
Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию
`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`.
Это влияет только на локализованное поле `Title`/`Name`; поле
`original_title`/`original_name` остаётся на языке оригинала, поэтому
оригинальная сторона сравнения не затрагивается.
#### Scenario: Локализованный заголовок приходит по-русски
- **GIVEN** TMDB включён, `language` не задан в конфиге
- **WHEN** выполняется поиск фильма с русской локализацией
- **THEN** запрос содержит `language=ru-RU`
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
### Requirement: Нормализация названий при сравнении
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
#### Scenario: «Тёмный» и «Темный» совпадают
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
- **WHEN** сравниваются нормализованные названия
- **THEN** они считаются совпадающими
### Requirement: Кандидат несёт URL для внешней проверки
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
провайдера. URL SHALL формироваться клиентом провайдера при поиске
(`Search`) и сохраняться в таблице `metadata_candidate`. Отображение этой
ссылки на экране ревью — забота `review`/`web-ui`, не данного требования.
Формат URL для каждого провайдера:
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
запроса `Query.Type`
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
TVMaze-страницу
#### Scenario: Кандидат TMDB с корректной ссылкой
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
- **WHEN** клиент TMDB формирует Candidate
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
#### Scenario: Кандидат TVMaze с нативной ссылкой
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
- **WHEN** клиент TVMaze формирует Candidate
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
#### Scenario: URL сохраняется в БД
- **GIVEN** результат поиска с кандидатами
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
- **THEN** значение `url` SHALL быть записано в колонку `url`
- **AND** при последующей загрузке данных ревью url доступен без повторной
генерации
@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: Уведомление о падении загрузки
Любой переход загрузки в `failed`/`stuck` система SHALL сопровождать уведомлением
автора загрузки через настроенный механизм (`notifier`), чтобы падение не
оставалось незамеченным. Это SHALL включать приёмное падение `qbit_add` (не
удалось добавить раздачу в qBittorrent), которое идёт мимо поллинг-цикла worker.
#### Scenario: Уведомление при падении приёма
- **GIVEN** приём загрузки, где добавление в qBittorrent не удалось
- **WHEN** загрузка помечается `failed` с `error_code` `qbit_add`
- **THEN** автор загрузки получает уведомление о падении
### Requirement: Дебаунс повторных падений
Повторные падения одной задачи в пределах окна дебаунса система SHALL уведомлять
лишь один раз, чтобы мерцающий stalled-торрент (`stuck``downloading`) не спамил
автора.
#### Scenario: Мерцающий stalled не спамит
- **GIVEN** задача, многократно переходящая `stuck``downloading` в пределах окна дебаунса
- **WHEN** происходят повторные падения
- **THEN** уведомление отправляется один раз за окно
### Requirement: Пинг о входе в review и готовности
При переходе загрузки в `review` система SHALL пинговать автора (сообщение в
Telegram / бейдж в вебе) — пользователя зовут, а не он опрашивает. После
успешного применения (готовность) система SHALL показывать, что создано.
#### Scenario: Пинг при входе в review
- **GIVEN** загрузка переходит в `review`
- **WHEN** происходит переход
- **THEN** автор получает пинг с приглашением подтвердить раскладку
### Requirement: Уведомление о рассинхроне
При переходе задачи в `orphaned` или `target_missing` система SHALL
уведомлять автора загрузки через настроенный механизм уведомлений
(`notifier`), чтобы рассинхрон не оставался незамеченным.
#### Scenario: Уведомление при потере источника
- **WHEN** задача переходит в `orphaned`
- **THEN** автор загрузки получает уведомление о рассинхроне
@@ -0,0 +1,118 @@
## ADDED Requirements
### Requirement: Пред-парс имени релиза
Перед вызовом LLM система SHALL выполнять дешёвый пред-парс имени торрента
(`go-ptn`): извлекать черновые название, год, сезон, серию и качество. Результат
пред-парса SHALL использоваться как вспомогательный сигнал в промпте и как
сторона проверки согласованности при решении auto/review, но НЕ SHALL считаться
итоговым распознаванием.
#### Scenario: Пред-парс даёт черновые поля
- **WHEN** на вход распознавания поступает имя релиза `Fargo.S02.2015.WEB-DL.1080p`
- **THEN** пред-парс возвращает черновые `title`, `year`, `season`, `quality`
- **AND** эти значения передаются в промпт LLM как подсказка
### Requirement: Разбор сигналов LLM в структурированный план
Система SHALL передавать LLM недоверенные сигналы (имя торрента, дерево файлов с
размерами, текстовый контекст и накопленные подсказки, пред-парс) и получать
структурированный план в схеме: `type` (`movie`|`series`), `title`,
`original_title`, `year`, `provider_hint`, `files[]` и `confidence`. Каждый
элемент `files[]` SHALL нести `src`, `role`
(`main`|`episode`|`subtitle`|`extra`|`sample`|`ignore`) и, для сериала,
per-file `season`/`episode` (отдельного скалярного `season` быть SHALL NOT — так
выражаются мультисезонные паки и спецвыпуски). План SHALL приниматься только
если каждый `files[].src` совпадает с реальным файлом торрента.
#### Scenario: План сериала с per-file нумерацией
- **GIVEN** сезон-пак из 10 видеофайлов
- **WHEN** LLM возвращает план
- **THEN** `type` = `series`, а каждый видеофайл несёт свои `season`/`episode`
#### Scenario: Несуществующий src отклоняется
- **GIVEN** ответ LLM, где `files[].src` не совпадает ни с одним файлом торрента
- **WHEN** план разбирается
- **THEN** такой план не принимается как валидный
### Requirement: Провайдер LLM за абстракцией со структурированным выводом
Доступ к LLM SHALL быть за интерфейсом с выбором реализации по полю `[llm].type`
(первый тип — `openai-compat`). Система SHALL запрашивать JSON-режим
(`response_format: {"type":"json_object"}`), срезать ```-ограждения и
валидировать ответ в Go против схемы плана. При ошибке разбора система SHALL
ретраить до `[llm].max_retries`, передавая модели саму ошибку и схему. Если после
ретраев ответ не разобран, задача SHALL уходить в `review` (НЕ в `failed`) с
причиной «ответ LLM не разобран».
#### Scenario: Неразобранный ответ уходит в review
- **GIVEN** LLM, чей ответ не проходит валидацию схемы после всех ретраев
- **WHEN** завершается распознавание
- **THEN** задача переходит в `review` с причиной «ответ LLM не разобран»
- **AND** задача НЕ переходит в `failed`
### Requirement: Модель уверенности и решение auto/review
Система SHALL раскладывать автоматически (без review) только при выполнении
ВСЕГО: (1) подтверждённый единичный сильный матч в базе (`metadata-match`) с
`provider_id`; (2) структурная валидация без предупреждений (фильм — ровно один
основной видеофайл; сериал — число серий бьётся с базой, нумерация S·E
консистентна); (3) согласованность пред-парса и LLM по типу/названию/году. Иначе
задача SHALL уходить в `review` с явной причиной. Самооценку LLM (`confidence`)
система SHALL учитывать лишь как вспомогательный сигнал, НЕ как единственный гейт.
#### Scenario: Нет матча в базе — всегда review
- **GIVEN** план без подтверждённого матча в базе (база выключена или матча нет)
- **WHEN** принимается решение auto/review
- **THEN** задача уходит в `review`, авто-раскладка не делается
#### Scenario: Матч и чистая валидация — авто
- **GIVEN** подтверждённый единичный матч, чистая структурная валидация и
согласованность сигналов
- **WHEN** принимается решение
- **THEN** допускается авто-раскладка (при отсутствии `force_review`)
### Requirement: Роли файлов на краях раздачи
Система SHALL относить семплы, «экстра» и мусор к роли `ignore` (эвристики размер/
имя + LLM), а внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) —
привязывать к соответствующему видео. Любую неоднозначность нумерации (дыры,
дубли, спорные спецвыпуски) система SHALL эскалировать в `review`, а не разрешать
молча.
#### Scenario: Семпл помечается ignore
- **GIVEN** раздача с файлом `sample.mkv` малого размера
- **WHEN** строится план
- **THEN** этот файл получает роль `ignore` и в раскладку не попадает
## REMOVED Requirements
### Requirement: Сверка с базой по нескольким названиям
**Reason**: Работа с внешними базами метаданных — отдельное поведение; выделена в
capability `metadata-match`.
**Migration**: Требование перенесено без изменений в `metadata-match` (см.
`specs/metadata-match`).
### Requirement: Локаль запроса к TMDB
**Reason**: Относится к работе с метабазой (TMDB), выделенной в `metadata-match`.
**Migration**: Требование перенесено без изменений в `metadata-match`.
### Requirement: Нормализация названий при сравнении
**Reason**: Нормализация — часть сверки с метабазой, выделенной в `metadata-match`.
**Migration**: Требование перенесено без изменений в `metadata-match`.
### Requirement: Кандидат несёт URL для внешней проверки
**Reason**: Кандидат — сущность метабазы; контракт кандидата относится к
`metadata-match`.
**Migration**: Требование перенесено без изменений в `metadata-match`.
@@ -0,0 +1,164 @@
## ADDED Requirements
### Requirement: Вход в review с явной причиной
Когда модель уверенности не разрешает авто-раскладку, система SHALL переводить
загрузку в `review` и SHALL показывать **конкретную причину** (низкая самооценка
LLM; нет матча в базе или несколько кандидатов; предупреждение структурной
валидации; неразобранный ответ LLM), а не обобщённое «не уверен». Поверхность
решения SHALL быть единой для всех транспортов и содержать источник (имя, контекст,
дерево файлов), догадку системы (тип, название, год, матч) и превью целевой
раскладки.
#### Scenario: Причина видна в интерфейсе
- **GIVEN** загрузка ушла в `review` из-за отсутствия матча в базе
- **WHEN** пользователь открывает ревью
- **THEN** показана конкретная причина (напр. «нет в TMDB · уверенность 0.46»)
### Requirement: Команды ревью и их эффекты
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
**Распознать заново** (повторный прогон без новой подсказки), **Тип** (переключить
movie↔series), **Игнор файла**, **Позже** (`deferred`), **Отклонить**
(`cancelled`), **Undo** (снять созданные ссылки → `reverted`) и **Привязать
заново** (из `reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
подтверждением). Команды из любого транспорта SHALL сериализоваться worker'ом под
per-download блокировкой; применяется последняя валидная команда. Команды,
которым нужен источник, SHALL проверять его наличие синхронно перед действием.
#### Scenario: Применение создаёт раскладку
- **GIVEN** загрузка в `review` с эффективным планом
- **WHEN** пользователь выбирает «Применить»
- **THEN** создаются хардлинки по плану, задача переходит к раскладке
#### Scenario: Отклонить и привязать заново
- **GIVEN** загрузка в `review`
- **WHEN** пользователь «Отклонить», затем «Привязать заново»
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
подтверждением (авто-раскладка не делается)
### Requirement: Подсказка мягкая, override жёсткий
Подсказка (`hint`) SHALL быть мягким сигналом — её интерпретирует LLM при
перераспознавании. Ручная правка поля SHALL быть жёстким **override**: система
берёт значение как есть и «пиннит» его; перераспознавание НЕ SHALL затирать уже
поправленное поле. Накопленные подсказки и правки SHALL переживать
перераспознавание и накладываться на новый план.
#### Scenario: Override переживает перераспознавание
- **GIVEN** пользователь зафиксировал тип `series` как override
- **WHEN** запускается перераспознавание по новой подсказке
- **THEN** в новом эффективном плане тип остаётся `series`
### Requirement: Единый список источников совпадения на ревью
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
Ровно один источник в списке SHALL быть отмечен активным (эффективный
матч). Экран SHALL позволять как операции над этим списком: выбрать
кандидата базы, переключиться на другого кандидата и снять матч с базой
обратно на нейронку («без базы»). Смена активного источника SHALL
выполняться через раундтрип на сервер (форма/htmx), без клиентского
пересчёта доменного состояния. Список источников SHALL показываться только
при наличии плана распознавания.
#### Scenario: Нейронка — строка в общем списке
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
несколькими кандидатами метабаз
- **WHEN** пользователь открывает `GET /review/{id}`
- **THEN** источники показаны единым списком, где строка «распознано
нейронкой» стоит наравне с кандидатами баз
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
#### Scenario: Переключение между кандидатами
- **GIVEN** на экране ревью выбран один кандидат метабазы
- **WHEN** пользователь выбирает другого кандидата из списка
- **THEN** активным становится выбранный кандидат, прочие — неактивны
#### Scenario: Снятие матча в пользу нейронки
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
- **WHEN** пользователь выбирает строку «распознано нейронкой»
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
папки провайдера не проставляется
- **AND** поля источника — из распознавания нейронкой, без унаследованных
от прежнего кандидата название/год
### Requirement: Ручное добавление источника по id или URL
Когда автопоиск по базам промахнулся, экран ревью SHALL позволять добавить
источник вручную — по идентификатору записи метабазы или, где применимо, по
её URL. Ввод SHALL разбираться и валидироваться в пару
`(provider, provider_id)` на входной границе (`internal/httpapi`); допустимые
провайдеры — `tmdb`, `tvdb`, `imdb`. Добавленный источник SHALL появляться в
списке как выбираемая строка; при совпадении `provider:id` с уже присутствующим
источником новая строка NOT создаётся, а выбирается существующая.
Некорректный ввод SHALL отклоняться с сообщением, не меняя текущий активный
источник.
#### Scenario: Добавление кандидата по URL TMDB
- **GIVEN** загрузка в `review`, где нужной записи нет среди автокандидатов
- **WHEN** пользователь вводит URL записи TMDB и подтверждает добавление
- **THEN** из URL извлекаются провайдер и id, источник добавляется в список
выбираемой строкой
#### Scenario: Дубль id выбирает существующую строку
- **GIVEN** в списке уже есть кандидат с данным `provider:id`
- **WHEN** пользователь добавляет вручную тот же `provider:id`
- **THEN** новая строка не создаётся, активным становится существующий
кандидат
#### Scenario: Некорректный ввод отклонён
- **WHEN** пользователь вводит нераспознаваемый id/URL
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
источник
### Requirement: Предпросмотр полей источника до фиксации выбора
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
кандидат базы или добавленный вручную) **поля** результата — тип, название,
год, с зарезервированным местом под режиссёра. Показ полей источника
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
сохранённый матч меняется только явным выбором источника, а раскладка —
только действием «Применить». Совпадение целевых путей предпросмотра с
результатом применения регулируется требованием «Превью раскладки через
единую логику именования» (`web-ui`).
#### Scenario: Предпросмотр полей без фиксации выбора
- **GIVEN** список источников на экране ревью
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
- **THEN** показаны поля результата (тип, название, год) для этого источника
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
#### Scenario: Зарезервированное место под режиссёра
- **GIVEN** режиссёр из метабазы пока не загружается
- **WHEN** отображается предпросмотр полей источника
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
пустым (или прочерком), не ломая вёрстку
### Requirement: Разделение труда транспортов в ревью
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
быть поверхностью точных правок (маппинг файлов, выбор/ввод источника,
предпросмотр). Telegram SHALL давать быстрые действия (одобрить, подсказать,
переключить тип, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
страницу; точечные правки, не помещающиеся в чат, SHALL делаться в вебе.
#### Scenario: Эскалация из Telegram в веб
- **GIVEN** загрузка в `review`, требующая точечного маппинга файлов
- **WHEN** пользователь в Telegram выбирает «В вебе»
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
@@ -0,0 +1,9 @@
## REMOVED Requirements
### Requirement: Уведомление о рассинхроне
**Reason**: Уведомления автора — единое поведение, собранное в capability
`notifications`; здесь оно дублировало эту заботу.
**Migration**: Требование перенесено без изменений в `notifications` (см.
`specs/notifications`). Сама сверка и переходы `orphaned`/`target_missing`
остаются в `state-reconciliation`.
@@ -0,0 +1,18 @@
## REMOVED Requirements
### Requirement: Единый список источников совпадения на ревью
**Reason**: Поведение ревью, а не оформление; выделено в capability `review`.
**Migration**: Требование перенесено без изменений в `review` (см. `specs/review`).
### Requirement: Ручное добавление источника по id или URL
**Reason**: Действие ревью (ручной выбор источника), а не оформление UI.
**Migration**: Требование перенесено без изменений в `review`.
### Requirement: Предпросмотр полей источника до фиксации выбора
**Reason**: Поведение ревью (предпросмотр источника до выбора), а не оформление.
**Migration**: Требование перенесено без изменений в `review`; совпадение
целевых путей превью по-прежнему регулируется требованием `web-ui` «Превью
раскладки через единую логику именования».
@@ -0,0 +1,56 @@
## 1. Дельта-спеки change (готово при propose)
- [x] 1.1 `recognition` — REMOVED 4 требования метабазы + ADDED разбор LLM
- [x] 1.2 `metadata-match` (new) — поиск/подтверждение матча + перенесённые требования
- [x] 1.3 `review` (new) — процесс ревью + перенос 3 требований из web-ui
- [x] 1.4 `file-layout` (new) — миграция jellyfin-layout.md
- [x] 1.5 `download-tracking` (new) — миграция прямого пути FSM из workflow.md
- [x] 1.6 `notifications` (new) — падения/дебаунс/пинги + перенос из state-reconciliation
- [x] 1.7 `ingest` — ADDED ядро приёма + перенос 3 требований из identity
- [x] 1.8 `identity`/`web-ui`/`state-reconciliation` — REMOVED-дельты переносов
- [x] 1.9 `openspec validate --strict` — проходит
## 2. Ревью дизайна (чекпоинт до влития)
- [ ] 2.1 Проверить полноту переносов по таблицам design.md D2–D5: каждое
исходное требование учтено (STAY либо REMOVED+ADDED), ни одно не потеряно
- [ ] 2.2 Сверить счётчик требований до/после (сумма по капабилити не изменилась,
кроме намеренно добавленного «Приём источника и заведение загрузки»)
- [ ] 2.3 Подтвердить, что формулировки перенесены эквивалентно (нормативная сила
SHALL/MUST и сценарии сохранены), поведение системы не меняется
## 3. Purpose живых спек (при/после archive)
- [ ] 3.1 Дописать `## Purpose` новым capability (`metadata-match`, `review`,
`file-layout`, `download-tracking`, `notifications`) — иначе archive
проставит «TBD», как у `live-status`
- [ ] 3.2 Подчистить стухший `## Purpose` у доноров: `identity` (убрать
инфохэши/дедуп/атомарный возврат), `recognition` (убрать сверку/локаль
TMDB/сбор кандидатов — оставить разбор LLM), `state-reconciliation` (убрать
«уведомления о рассинхроне»), `web-ui` (убрать ревью-специфику)
## 5. Синхронизация docs/specs (источник истины переезжает в OpenSpec)
- [ ] 5.1 `docs/specs/recognition.md` — шапка «источник истины: openspec/specs/
recognition + metadata-match»; не дублировать содержимое
- [ ] 5.2 `docs/specs/review-ux.md` — шапка «источник истины: openspec/specs/review»
- [ ] 5.3 `docs/specs/jellyfin-layout.md` — шапка «источник истины: openspec/specs/
file-layout»
- [ ] 5.4 `docs/specs/workflow.md` — шапка «прямой путь FSM: openspec/specs/
download-tracking; сверка: state-reconciliation; уведомления: notifications»
- [ ] 5.5 `docs/specs/architecture.md` — сверить перечень capabilities и ссылки
## 6. Обновление беклога и памятки
- [ ] 6.1 `docs/backlog.md` — снять пункт «Пересмотр набора capabilities и
рефакторинг спек»
- [ ] 6.2 `CLAUDE.md` — при необходимости обновить перечисление capabilities
(ingest, recognition, metadata-match, review, file-layout, download-tracking,
notifications, state-reconciliation, live-status, web-ui, identity)
## 7. Влитие и архив
- [ ] 7.1 Ревью change до архива (процесс CLAUDE.md)
- [ ] 7.2 `openspec archive refactor-capability-boundaries` — влить дельты в
`openspec/specs/`
- [ ] 7.3 Повторный `openspec validate --strict` по влитым спекам
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-03
@@ -0,0 +1,211 @@
## Context
Экран ревью уже умеет много: выбор кандидата (`ChooseCandidate`), ручной
ввод id (`SetProviderID`), «без базы» (`ClearProvider`), превью текущего
плана (`ReviewData.Preview` через `layout.BuildLinks`). Механика выбора
источника — это **overrides**: выбор кандидата пиннит `provider`,
`provider_id` и (если есть) `title`/`year`, после чего эффективный план
пересобирается `applyOverrides` и печатается превью путей.
Проблемы текущего экрана — не в отсутствии операций, а в подаче:
- нейронка и кандидаты баз показаны как разные сущности (план сверху,
кандидаты снизу; «без базы» — особое состояние);
- **превью есть только для уже выбранного** источника: чтобы увидеть пути
для другого кандидата, его надо сначала выбрать (запись пиннится), т.е.
«примерить вслепую».
Ограничение capability `web-ui`: клиент не пересчитывает доменное
состояние — все доменные величины (пути, поля) приходят с сервера
(требование «Клиентские взаимодействия без сборки»).
## Goals / Non-Goals
**Goals:**
- Единый список источников: нейронка + кандидаты баз + добавленные вручную,
один активный.
- Предпросмотр полей (тип/название/год) и целевых путей **для любого
источника до его выбора**, посчитанный на сервере.
- Ручное добавление источника по id/URL как строки списка.
- Переиспользовать существующую логику (`applyOverrides` +
`layout.BuildLinks`), не дублируя правила именования.
**Non-Goals:**
- Fetch деталей записи из метабазы (режиссёр и пр.) — только резервируем
место в UI.
- **Сила совпадения кандидата** (per-candidate score) и сортировка/подсветка
списка по ней — сегодня у кандидата такого поля нет; список берём в
порядке сбора. Отдельная идея беклога (там же — пересмотр процесса
распознавания/матчинга).
- Изменение решения «авто vs review» и модели уверенности — не трогаем: авто
по-прежнему только при единственном сильном матче + валидации, иначе
review; фича работает **внутри** уже наступившего review.
- Отдельный capability `review` и перекройка домена метабаз — отдельная
задача беклога.
- Редактор маппинга «файл → серия» и правки в Telegram.
## Decisions
### 0. Двухуровневая модель ревью сохраняется, нового подтверждения нет
В ревью два независимых шага, оба остаются как есть:
1. **Выбор источника** — кнопки «выбрать» / «Задать id» / «Без базы» (POST →
`ChooseCandidate`/`SetProviderID`/`ClearProvider`) **фиксируют** матч,
записывая overrides. Файлы не раскладываются, лишь пересобирается план.
2. **«Применить»** (POST → `Apply`) — единственный шаг, создающий хардлинки.
Фича добавляет **только предпросмотр до шага 1**: увидеть поля и целевые
пути каждого источника, **не нажимая «выбрать»** (не трогая БД). Семантику
«выбрать» и «Применить» не меняем, новых кнопок-подтверждений не вводим.
### 1. Предпросмотр считается на сервере эфемерно, без записи overrides
Добавляем в `worker` чистый расчёт: по текущему плану и **гипотетическому**
источнику `(provider, provider_id, опц. title/year)` собрать эффективные
поля и `[]layout.Link`, **не записывая** overrides в БД. Технически — тот
же `applyOverrides` + `layouter.BuildLinks(toLayoutPlan(...))`, что и в
`ReviewData`, но поверх копии плана с временными пинами источника; в БД
ничего не пишем.
`ReviewData` расширяется срезом «источник → (поля, превью путей, активен
ли)» для нейронки и каждого кандидата. Транспорт рендерит их строками
списка.
- **Почему так, а не «выбрать → посмотреть → отменить»:** выбор сейчас
пиннит запись (пишет overrides) и меняет сохранённый матч; «примерка»
не должна трогать состояние (инвариант «предпросмотр ничего не
раскладывает и не фиксирует»).
- **Альтернатива — отдельный эндпоинт `GET /review/{id}/preview?source=…`,
отдающий htmx-фрагмент:** отвергнута для известных источников — превью
считается быстро и без сети (`BuildLinks` — чистая работа с путями), их
дешевле посчитать сразу и вложить в страницу; лишний раундтрип на каждое
наведение не нужен.
### 1a. Единая деривация «источник → набор overrides» (гарантия preview == apply)
**Проблема, найденная на ревью дизайна.** Пины `ovrTitle`/`ovrYear` пишутся
`SetOverride` и не удаляются (в store нет `DeleteOverride`). `ChooseCandidate`
пиннит title/year только при непустых полях кандидата (review.go:573-579), а
`ClearProvider` их вообще не трогает (review.go:624-640). Значит после выбора
кандидата «Fargo/2014» и последующего переключения на нейронку или на ручной
кандидат без title запиненный `ovrTitle=Fargo` **остаётся**. Тогда `applyOverrides`
(review.go:742) при `Apply` возьмёт `Fargo`, а эфемерное превью источника без
собственного title покажет title из плана → **пути превью ≠ пути применения**.
Это и латентный баг текущего кода (переключение кандидатов тянет чужой title).
**Решение.** Ввести одну чистую функцию «источник → полный самосогласованный
набор overrides» и использовать её **и в превью, и в коммите**:
- источник-кандидат с title/year → пиннит их; **без** title/year → пишет
**пустую строку** в `ovrTitle`/`ovrYear``applyOverrides` пустая строка
трактуется как «нет override», review.go:742,745 — `DeleteOverride` не нужен,
берётся значение плана);
- источник-нейронка (`ClearProvider`) → `provider=none` и пустые
`ovrTitle`/`ovrYear` (поля из плана распознавания).
Так каждый источник даёт детерминированный эффективный план; превью считается
тем же набором overrides, что запишет выбор → **preview == apply по построению**.
- **Затрагивает поведение `worker`** (`ChooseCandidate`/`ClearProvider` теперь
очищают title/year), а не только web-ui. Это осознанно: спека остаётся в
`web-ui` (наблюдаемое — «превью == применение» и «переключение не тянет чужие
поля»), а правка команд ревью — реализация. Заодно чиним латентный баг.
- **Альтернатива — превью «симулирует» унаследованные пины** (показывать чужой
title у нейронки): отвергнута — противоречит принципу «нейронка = поля
распознавания» и путает пользователя.
### 2. Клиент только показывает предпосчитанное, не считает пути
Превью всех источников предпосчитываем на сервере и вкладываем в страницу
(данных немного — кандидатов мало, порога/ленивой загрузки не вводим).
Клиент лишь раскрывает/скрывает предпосчитанный блок строки по клику
(нативный `<details>` или минимальный vanilla-JS) — никакого доменного
пересчёта на клиенте, соблюдаем требование `web-ui`.
UI-модель: секция **«Раскладка» остаётся отдельной** и показывает пути
**активного** источника (как сейчас); раскрываемое инлайн-превью в строке —
для сравнения **неактивных** источников до выбора. Фактическая смена
активного источника (пиннинг) — по явному действию формой (раундтрип), как
сейчас.
### 3. Нейронка — синтетическая строка, а не особый режим
Строку «распознано нейронкой» синтезируем из сырого плана распознавания
(`provider = none`): её поля — то, что дал LLM без базы, её превью —
раскладка без тега провайдера. Выбор этой строки = существующий
`ClearProvider`. Так «без базы» перестаёт быть отдельным состоянием UI и
становится обычной строкой списка.
### 4. Ручной источник — кандидат в том же списке
Ручной ввод (id или URL) на входной границе `httpapi` парсим в
`(provider, provider_id)`. Допустимые провайдеры — `tmdb`, `tvdb`, `imdb`
(набор согласован с `providerTag`/`providerURL`; `tvmaze` — только источник
автопоиска, вручную не вводится). Добавляем как **строку списка**: сохраняем
`metadata_candidate` с этим `provider`/`provider_id` и (если дан) `url`;
`title`/`year` — пустые (деталей не тянем). Дедуп по `provider:id`: если такой
источник уже в списке — не плодим строку, а выбираем существующую. Такой
кандидат участвует в выборе и предпросмотре наравне с автонайденными; выбор —
тот же `ChooseCandidate` (с очисткой title/year по решению 1a).
**Парсинг URL реалистичен не для всех баз.** `providerURL` строит TVDB как
`thetvdb.com/dereferrer/series/{numeric_id}`, но с сайта пользователь копирует
`thetvdb.com/series/{slug}` — без числового id. Поэтому обещаем: **URL — для
TMDB/IMDb**, для **TVDB — ручной ввод числового id** (slug из URL не
распознаём). Список принимаемых паттернов фиксируем в реализации как обратный
к `providerURL`.
- Превью ручного источника корректно и без title/year из базы: имя папки
берётся из title **плана**, а от источника меняется лишь тег провайдера
в пути. Значит «предпросмотр путей» для ручного кандидата полноценен.
- **Альтернатива — просто пиннить `SetProviderID` без строки в списке:**
отвергнута — тогда ручной источник не «переключаемый» наравне с
остальными, что противоречит принципу единого списка.
### 5. Режиссёр — зарезервированное место, источник позже
В предпросмотре полей выводим строку «Режиссёр» пустой (прочерк). Fetch
деталей из метабазы — отдельная задача; при появлении источника (детали по
id или парсинг из контекста загрузки) заполняем это же место.
## Risks / Trade-offs
- **[Рассинхрон превью и применения]** Превью и реальная раскладка должны
идти одной логикой. → Оба используют `layout.BuildLinks`/`naming` и **одну
деривацию «источник → overrides»** (решение 1a); в тестах проверяем равенство
путей превью и применения (требование `web-ui` «Превью совпадает с реальной
раскладкой», сценарий «Переключение источника не тянет чужие поля»).
- **[Залипший override title/year]** Пины title/year не удаляются и могут
утечь между источниками (латентный баг). → Решение 1a: выбор источника пишет
полный самосогласованный набор (пустая строка = сброс к плану).
- **[Раздувание страницы]** Предпосчёт превью для всех кандидатов кладёт N
наборов путей в HTML. → Кандидатов немного (потолок сбора уже есть в
`recognition`); `BuildLinks` без сети. Приемлемо; если станет тяжело —
ленивый htmx-фрагмент (решение 1, альтернатива) как эволюция.
- **[Ручной кандидат с пустыми title/year]** Строки списка и матч-ссылка
должны переживать пустые поля. → `matchURL` уже строит URL из
provider/id; заголовок берём из плана. Проверить рендер строки без
title/year.
- **[Дубль ручного и автокандидата]** Пользователь может ввести id, уже
присутствующий в списке. → Дедуп по `provider:id` при добавлении (как в
сборе кандидатов): не плодим строку, просто выбираем существующую.
## Migration Plan
- Данные: миграций схемы не требуется — ручной кандидат ложится в
существующую `metadata_candidate` (title/year nullable уже так). Новый
источник строки — пользовательское действие, обратная совместимость
полная.
- Откат: изменения ограничены страницей ревью и добавочным методом
предпросмотра в `worker`; откат — возврат прежнего шаблона/обработчика,
данные не затрагиваются.
## Open Questions
Блокирующих открытых вопросов нет. Оставшийся детерминированный на
реализацию пункт — точный список принимаемых URL-паттернов ручного ввода
(обратный к `providerURL`: `themoviedb.org/{movie,tv}/{id}`,
`imdb.com/title/{id}`; TVDB — числовой id, не slug), см. решение 4.
@@ -0,0 +1,87 @@
## Why
Экран ревью сегодня трактует распознавание нейронкой и совпадения из
метабаз как **разные режимы**: догадка LLM показана сверху как «текущий
план», а кандидаты TMDB/TVDB — отдельным списком ниже; «без базы» —
особое состояние. Из-за этого выбор источника непрозрачен: чтобы понять,
куда лягут файлы при другом кандидате, приходится сначала его выбрать
(запись пиннится в overrides) и только потом увидеть результат. Отменить и
попробовать другой — снова вслепую.
Принцип должен быть иным: **совпадение есть всегда — мы лишь выбираем
источник**. Матч нейронки — такая же строка списка, как кандидаты баз.
Выбор любого источника должен показывать, что получится (поля и целевые
пути), **до применения**.
## What Changes
- **Единый список источников совпадения** на странице ревью: строка
«распознано нейронкой» (без базы) наравне с кандидатами метабаз
(TMDB/TVDB/TVMaze) — один список с выбором одного активного источника,
а не «план сверху + кандидаты снизу».
- **Выбор / переключение / отмена в пользу нейронки** как операции над
этим списком: выбрать кандидата базы, переключиться на другого, снять
матч с базой обратно на нейронку — единообразно.
- **Ручное добавление кандидата** по id или URL записи базы, когда
автопоиск промахнулся: добавляется в тот же список как выбираемая строка.
- **Предпросмотр до применения**: при наведении/выборе источника показываем
**поля** (тип, название, год) и **предпросмотр целевых путей раскладки**,
которые получатся при этом источнике, **не пиннит** выбор до явного
подтверждения. Место под «режиссёр» в предпросмотре резервируем (источник
подключим позже — часто есть в контексте загрузки).
- Существующие действия ревью (Применить/Отклонить/Позже/Уточнить/тип/
игнор/Undo) сохраняются; переработка касается только блока выбора
источника и предпросмотра.
Вне объёма (осознанно, чтобы не раздувать change):
- **Детали записи из метабазы** (режиссёр и пр.) через новый вызов
«детали по id» — только резервируем место в UI, сам fetch не делаем.
- **Полноценный редактор маппинга «файл → серия»** — остаётся Ф5.
- Изменения в Telegram — не трогаем (веб = точные правки).
## Capabilities
### New Capabilities
Новых capability не вводим. Отдельный capability `review` (весь процесс
ревью) и перекройка домена метабаз — предмет отдельной задачи беклога
«Пересмотр набора capabilities и рефакторинг спек»; здесь ограничиваемся
существующими capability.
### Modified Capabilities
- `web-ui`: рендеринг страницы ревью получает **единый список выбора
источника** (нейронка + кандидаты баз + ручной ввод) и **предпросмотр
полей и целевых путей выбранного источника до применения**. Предпросмотр
берётся из единой логики `internal/layout`, как и текущее превью
раскладки.
Сбор кандидатов при сверке с базами (`recognition`) переиспользуется как
есть — его поведение не меняется.
Часть логики этого change доменная (ручное добавление источника,
самосогласованный набор overrides при выборе — см. design.md 1a), а не чисто
презентационная. До выделения отдельного capability `review` (отложено в
беклог) она осознанно живёт под `web-ui`; наблюдаемое поведение выражено
требованиями `web-ui`, а `docs/specs/review-ux.md` остаётся источником истины
по review-домену и обновляется в этом change.
## Impact
- **Код:** `internal/httpapi` (обработчик и шаблон страницы ревью,
парсер ручного ввода id/URL, клиентский JS переключения предпросмотра без
пиннинга), `internal/worker` (метод расчёта эфемерного плана + предпросмотра
путей для источника без записи overrides — переиспользует `applyOverrides` +
`layout.BuildLinks`; единая деривация «источник → overrides» с очисткой
title/year в `ChooseCandidate`/`ClearProvider` — заодно чинит залипший
override). Возможна небольшая правка `internal/store` при персистентности
вручную добавленного кандидата.
- **Данные:** новый пользовательский путь добавления кандидата вручную
(provider+id, опц. url); поля title/year у него могут быть пустыми
(деталей из базы пока не тянем).
- **Инварианты:** предпросмотр только строит пути через `layout` (та же
санитизация и проверка границ библиотеки); выбор источника ничего не
раскладывает — хардлинки по-прежнему только по «Применить».
- **Совместимость:** ломающих изменений API/схемы не предполагается;
существующие действия ревью и их семантика сохраняются.
@@ -0,0 +1,144 @@
## ADDED Requirements
### Requirement: Единый список источников совпадения на ревью
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
Ровно один источник в списке SHALL быть отмечен активным (эффективный
матч). Экран SHALL позволять как операции над этим списком: выбрать
кандидата базы, переключиться на другого кандидата и снять матч с базой
обратно на нейронку («без базы»). Смена активного источника SHALL
выполняться через раундтрип на сервер (форма/htmx), без клиентского
пересчёта доменного состояния. Список источников SHALL показываться только
при наличии плана распознавания.
#### Scenario: Нейронка — строка в общем списке
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
несколькими кандидатами метабаз
- **WHEN** пользователь открывает `GET /review/{id}`
- **THEN** источники показаны единым списком, где строка «распознано
нейронкой» стоит наравне с кандидатами баз
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
#### Scenario: Переключение между кандидатами
- **GIVEN** на экране ревью выбран один кандидат метабазы
- **WHEN** пользователь выбирает другого кандидата из списка
- **THEN** активным становится выбранный кандидат, прочие — неактивны
#### Scenario: Снятие матча в пользу нейронки
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
- **WHEN** пользователь выбирает строку «распознано нейронкой»
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
папки провайдера не проставляется
- **AND** поля источника — из распознавания нейронкой, без унаследованных
от прежнего кандидата название/год
### Requirement: Ручное добавление источника по id или URL
Когда автопоиск по базам промахнулся, экран ревью SHALL позволять добавить
источник вручную — по идентификатору записи метабазы или, где применимо, по
её URL. Ввод SHALL разбираться и валидироваться в пару
`(provider, provider_id)` на входной границе (`internal/httpapi`); допустимые
провайдеры — `tmdb`, `tvdb`, `imdb`. Добавленный источник SHALL появляться в
списке как выбираемая строка; при совпадении `provider:id` с уже присутствующим
источником новая строка NOT создаётся, а выбирается существующая.
Некорректный ввод SHALL отклоняться с сообщением, не меняя текущий активный
источник.
#### Scenario: Добавление кандидата по URL TMDB
- **GIVEN** загрузка в `review`, где нужной записи нет среди автокандидатов
- **WHEN** пользователь вводит URL записи TMDB и подтверждает добавление
- **THEN** из URL извлекаются провайдер и id, источник добавляется в список
выбираемой строкой
#### Scenario: Дубль id выбирает существующую строку
- **GIVEN** в списке уже есть кандидат с данным `provider:id`
- **WHEN** пользователь добавляет вручную тот же `provider:id`
- **THEN** новая строка не создаётся, активным становится существующий
кандидат
#### Scenario: Некорректный ввод отклонён
- **WHEN** пользователь вводит нераспознаваемый id/URL
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
источник
### Requirement: Предпросмотр полей источника до фиксации выбора
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
кандидат базы или добавленный вручную) **поля** результата — тип, название,
год, с зарезервированным местом под режиссёра. Показ полей источника
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
сохранённый матч меняется только явным выбором источника, а раскладка —
только действием «Применить». Совпадение целевых путей предпросмотра с
результатом применения регулируется требованием «Превью раскладки через
единую логику именования».
#### Scenario: Предпросмотр полей без фиксации выбора
- **GIVEN** список источников на экране ревью
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
- **THEN** показаны поля результата (тип, название, год) для этого источника
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
#### Scenario: Зарезервированное место под режиссёра
- **GIVEN** режиссёр из метабазы пока не загружается
- **WHEN** отображается предпросмотр полей источника
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
пустым (или прочерком), не ломая вёрстку
## MODIFIED Requirements
### Requirement: Превью раскладки через единую логику именования
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
каждого источника в списке** (нейронка, кандидат базы, добавленный вручную) —
эфемерно на сервере, без записи сохранённого матча. Показанные для источника
пути MUST совпадать с теми, что создались бы при выборе этого источника и
применении.
#### Scenario: Превью совпадает с реальной раскладкой
- **WHEN** на экране ревью отображается превью целевых путей для источника
- **THEN** эти пути идентичны тем, что создаст применение при выборе этого
источника (те же правила имён, спецвыпусков, мультифайла, запрещённых
символов, тега провайдера и коллизий)
#### Scenario: Переключение источника не тянет чужие поля
- **GIVEN** активен кандидат с запиненными название/год, затем выбран
источник без собственных названия/года (нейронка или ручной кандидат)
- **WHEN** строится превью и затем выполняется применение выбранного источника
- **THEN** и превью, и применение используют название/год этого источника
(из плана распознавания), без унаследованных от прежнего кандидата
### Requirement: Матч с записью метабазы ссылкой
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
распознавания) и в едином списке источников совпадения экрана ревью (у
активного источника-кандидата). Ссылка SHALL открываться в новой вкладке с
`rel="noopener"`. Рядом со ссылкой SHALL быть видны провайдер, идентификатор
записи и (при наличии) год.
#### Scenario: Матч виден ссылкой на странице просмотра
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
запись с провайдером и id
#### Scenario: URL записи неизвестен
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
записи построить нельзя
- **THEN** матч показывается текстом (провайдер и id) без ссылки, строка списка
не ломается
@@ -0,0 +1,68 @@
## 1. Ядро: эфемерный предпросмотр источника (worker)
- [x] 1.1 Ввести чистую деривацию «источник → полный самосогласованный набор
overrides» (кандидат с title/year → пиннит; без — пустая строка в
title/year; нейронка → provider=none + пустые title/year) и
переиспользовать её в превью и в коммите — гарантия preview == apply
(решение 1a design.md)
- [x] 1.2 Привести команды выбора к этой деривации: `ChooseCandidate` у
безтайтлового кандидата и `ClearProvider` очищают `ovrTitle`/`ovrYear`
(пустой строкой) — чинит латентный залипший override
- [x] 1.3 Выделить чистый расчёт «план + набор overrides источника → поля +
`[]layout.Link`» из логики `ReviewData` (переиспользуя `applyOverrides` +
`toLayoutPlan` + `layouter.BuildLinks`), без записи overrides в БД
- [x] 1.4 Расширить `ReviewData` срезом источников: нейронка (синтетическая
строка `provider=none`) + каждый кандидат; для каждого — эффективные
поля (тип/название/год), превью путей и признак «активен»; дедуп
источников по `provider:id` (нейронка — отдельная строка)
- [x] 1.5 Тесты worker: превью для неактивного источника не пишет overrides
и не создаёт ссылок; пути превью совпадают с результатом применения
того же источника; **переключение с титульного кандидата на нейронку/
безтайтловый источник не тянет чужие название/год** (preview и apply);
нейронка-строка = раскладка без тега провайдера
## 2. Ручное добавление источника (worker + store + httpapi)
- [x] 2.1 Парсер ручного ввода в `httpapi`: id или URL записи метабазы →
`(provider, provider_id)` (обратный к `providerURL`: themoviedb.org
movie/tv, thetvdb, imdb); валидация на входной границе
- [x] 2.2 Метод worker «добавить источник вручную»: сохранить
`metadata_candidate` (provider/id, опц. url; title/year пустые) с
дедупом по `provider:id`, затем выбрать его (как `ChooseCandidate`)
- [x] 2.3 Обработчик POST добавления ручного источника + маршрут; отклонять
некорректный ввод сообщением, не меняя активный источник
- [x] 2.4 Тесты: URL → provider+id; невалидный ввод отклонён; дубль id не
плодит строку, а выбирает существующую
## 3. Страница ревью: единый список и предпросмотр (httpapi + шаблон)
- [x] 3.1 Переработать блок «Источник совпадения» в единый список строк из
`[]SourceOption`: нейронка (первой) + кандидаты баз + ручной ввод;
ровно один активный, у активного — матч-ссылка (как сейчас)
- [x] 3.2 Строка источника: шапка (провайдер/название/год + маркер активного
+ кнопка «выбрать») и **раскрываемый по клику** блок предпросмотра —
поля (тип/название/год + пустое место под режиссёра) и целевые пути;
данные всех источников предпосчитаны сервером и вложены в страницу
- [x] 3.3 Секцию «Раскладка» оставить отдельной — показывает пути **активного**
источника (как сейчас); инлайн-превью в строках — только для сравнения
неактивных до выбора
- [x] 3.4 Клиентский vanilla-JS: раскрытие/скрытие предпосчитанного превью
строки по клику (нативный `<details>` или минимальный JS); без
доменного пересчёта на клиенте
- [x] 3.5 Действия строк формами-раундтрипами: выбрать кандидата
(`ChooseCandidate`), снять в пользу нейронки (`ClearProvider`),
добавить вручную; сохранить прежние действия ревью
- [x] 3.6 Убедиться, что матч-ссылка и заголовок строки корректны для ручного
кандидата с пустыми title/year (деградация до «провайдер + id»)
## 4. Проверка и приёмка
- [x] 4.1 `task test` и `task lint` зелёные
- [x] 4.2 Ручная проверка сценариев спеки: нейронка-строка, переключение
кандидатов, снятие в пользу нейронки, ручной ввод по URL, предпросмотр
без фиксации
- [x] 4.3 `openspec validate review-source-selection --strict`
- [x] 4.4 Обновить `docs/specs/review-ux.md` (источник истины по review-домену
до миграции): единый список источников, ручное добавление, предпросмотр,
объём Ф3/Ф5 — обязательно (поведение экрана меняется); убрать пункт
беклога после archive
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-04
@@ -0,0 +1,105 @@
## Context
Карточка списка на главной (`web/templates/index.html`, view-model `downloadView`
в `internal/httpapi`) сейчас показывает заголовок, `download.id`, бейдж
состояния, спойлер контекста и — для качающихся — прогресс. Живая телеметрия
раздачи живёт в in-memory снимке воркера (`worker.Live`, capability
`live-status`) и в БД не персиститься; `handleIndex` читает снимок только для
карточек в состоянии `downloading`. Размер разложенных файлов нигде не хранится:
`file_link` содержит пути и статус, без размера.
Задача — дать в карточке обзор для решения «пора удалять»: возраст (дата
добавления) и рейтинг отдачи, плюс размер. Возраст и рейтинг — ровно те два
критерия, по которым пользователь удаляет раздачи.
## Goals / Non-Goals
**Goals:**
- Дата добавления в карточке всегда (абсолют + относительная давность).
- Рейтинг отдачи и размер раздачи в карточке из живого снимка.
- Размер работает и после исчезновения торрента из qBittorrent (случай
`orphaned`) — через сохранённый размер разложенных файлов.
- Убрать спойлер контекста из карточки (контекст уже есть на `/download/{id}`).
**Non-Goals:**
- Живое (поллинг) обновление рейтинга/размера в списке — значения статичны на
момент рендера (рейтинг меняется медленно; поллинг сейчас только у прогресс-бара).
- Сиды/пиры, время сидирования, объём отданного в карточке — вне объёма.
- Персист рейтинга в БД — он нужен, пока торрент в qBittorrent, тогда и доступен.
- Бэкофилл размера для уже разложенных до миграции загрузок (см. Risks).
## Decisions
### Размер разложенных файлов — колонка `file_link.size`
Фолбэк размера (когда торрента нет в снимке) берём из БД: новая колонка
`file_link.size INTEGER`, которую layouter заполняет при линковке (размер уже
известен из `os.Stat`/`qbt.File.Size` в момент создания ссылки). Ридер отдаёт
`SUM(size)` по разложенным файлам загрузки одним запросом вместе с листингом.
Почему так, а не иначе:
- *Stat на лету при рендере* — N системных вызовов на карточку под пагинацией;
I/O в горячем пути рендера, зависимость показа от доступности ФС. Отклонено.
- *Не показывать фолбэк*у `orphaned` (файлы библиотеки = последняя копия
данных) размер как раз важен для решения об удалении. Отклонено.
- Колонка в БД: ноль I/O при рендере, размер зафиксирован в момент раскладки
(когда файл точно на месте). Цена — миграция goose + правка layouter.
### Размер при наличии торрента — `total_size` из снимка
В `qbt.Torrent` добавляем `TotalSize int64 \`json:"total_size"\`` (qBittorrent
отдаёт его в том же `/torrents/info`, без отдельного вызова), пробрасываем в
`worker.Live.TotalSize`. При рендере: снимок есть → `TotalSize`; снимка нет →
`SUM(file_link.size)`; нет ни того ни другого → «—».
### `handleIndex` читает снимок для всех карточек
Сейчас `liveFor(d)` вызывается только для `downloading`. Расширяем на все
карточки страницы — это map-lookup по волатильному снимку (`worker`), без сети и
без БД, стоимость незначительна при `pageSize` карточек. Рейтинг/размер попадают
в `downloadView` на рендере.
### Дата и относительная давность
Формат «`2006-01-02 · N дней назад`». Абсолютную часть форматируем в TZ сервера
(`Europe/Moscow`), относительную считаем от `now` в том же TZ. Базис — как в
сортировке списка: `source_added_at` → фолбэк `created_at` (оба хранятся в UTC,
парсятся `store.ParseTime`). Относительные подписи — вспомогательный форматтер в
`internal/httpapi` рядом с существующими (`fmtBytes`, `fmtRatio`, `fmtETA`).
### Форматтеры и вёрстка
Переиспользуем `fmtBytes` (размер) и `fmtRatio` (рейтинг) из `internal/httpapi/live.go`.
Мета-строку карточки выносим отдельным партиалом либо инлайним в `index.html`
решается при реализации; спойлер контекста удаляем из `index.html`.
## Risks / Trade-offs
- **Уже разложенные до миграции загрузки не имеют `file_link.size`** (колонка
`DEFAULT 0`/NULL) → их фолбэк-размер = 0/«—», пока торрент отсутствует в
снимке. Для `done`/сидирующих торрент обычно ещё в qBittorrent, поэтому размер
берётся из `total_size` и проблема почти не проявляется. → Митигация: бэкофилл
не делаем (сложность ради редкого края); при желании — отдельная разовая
задача. Показываем «—» честно, а не 0.
- **Рейтинг/размер статичны на рендере** (без поллинга) → в списке значение
может слегка отставать от реального. → Приемлемо: рейтинг меняется медленно,
точные живые цифры есть на `/download/{id}`; полная перезагрузка списка
освежает.
- **Чтение снимка для всех карточек** чуть увеличивает работу `handleIndex`. →
Митигация: это lookup в готовой in-memory карте под `pageSize` элементов;
сетевых/БД-обращений не добавляется.
- **`SUM(file_link.size)` — доп. агрегат к листингу**. → Один запрос батчем по
id страницы (как `attachInfohashes`), не N+1.
## Migration Plan
1. Goose-миграция: `ALTER TABLE file_link ADD COLUMN size INTEGER NOT NULL DEFAULT 0`.
2. Обновить ER-схему `docs/specs/database.md` (колонка `file_link.size`).
3. Down-миграция дропает колонку (пересоздание таблицы — как в существующих
миграциях проекта, SQLite без `DROP COLUMN` до нужной версии — при
необходимости).
4. Откат безопасен: новые поля в карточке деградируют до «—», старый бинарь
игнорирует колонку.
@@ -0,0 +1,61 @@
## Why
Карточка загрузки в списке на главной сейчас показывает состояние, заголовок,
идентификатор и (для качающихся) прогресс. Для решения «пора ли удалять
раздачу» этого мало: пользователь удаляет по **возрасту** и по **рейтингу
отдачи**, а этих полей в обзоре нет. Одновременно карточка тратит место на
спойлер контекста, который логичнее смотреть на странице загрузки. Цель —
сделать карточку кратким обзором жизненного цикла: от загрузки до момента, когда
раздачу пора убирать.
## What Changes
- Убрать из карточки списка спойлер «контекст»; контекст остаётся доступен на
странице загрузки `/download/{id}`.
- Дописать метку `ID:` перед идентификатором загрузки — чтобы было понятно, что
за значение копируется.
- Показывать в карточке **всегда** дату добавления раздачи в формате
«`2026-06-30 · 5 дней назад`» (абсолютная + относительная). Источник — время
добавления в источник (`source_added_at`, qBittorrent `added_on`) с фолбэком
на время создания загрузки (`created_at`).
- Показывать в карточке **рейтинг отдачи** (ratio) из живого снимка воркера;
если торрента нет в qBittorrent — прочерк «—».
- Показывать в карточке **размер раздачи**: из живого снимка (общий размер
торрента), а когда торрента нет в qBittorrent — из суммарного размера
разложенных файлов.
- Расширить состав живой телеметрии общим размером раздачи (доступен для любой
раздачи в снимке, не только сидирующей).
- Раскладка при линковке SHALL сохранять размер каждого созданного файла — чтобы
фолбэк размера работал и после исчезновения торрента (`orphaned`).
Явно вне объёма: сиды/пиры, время сидирования и объём отданного в карточку не
добавляем — пользователю для решения об удалении достаточно возраста и рейтинга.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `web-ui`: карточка списка получает обзорную мета-строку (ID-метка, дата
добавления всегда, размер, рейтинг); спойлер контекста уходит из карточки на
страницу загрузки.
- `live-status`: состав телеметрии дополняется общим размером раздачи, доступным
для любой раздачи в снимке (не только сидирующей).
- `file-layout`: раскладка сохраняет размер каждого разложенного файла (основа
фолбэка размера раздачи, когда торрента нет в qBittorrent).
## Impact
- `internal/qbt` — поле `TotalSize` (`json:"total_size"`) в `Torrent`.
- `internal/worker``Live.TotalSize`, проброс из `qbt.Torrent`.
- `internal/store` — колонка `file_link.size` (миграция goose), запись размера
при линковке, чтение суммарного размера разложенных файлов для карточки.
- `internal/httpapi` — новые поля `downloadView`, форматтеры даты/относительного
времени/размера/рейтинга; `handleIndex` читает живой снимок для всех карточек
списка, а не только качающихся.
- `web/templates/index.html` — мета-строка карточки (+ возможный партиал), удаление
спойлера контекста.
- `docs/specs/database.md` — обновить ER-схему (новая колонка `file_link.size`).
@@ -0,0 +1,24 @@
# file-layout Specification (delta)
## ADDED Requirements
### Requirement: Запись размера разложенного файла
При линковке файла раскладка SHALL сохранять его размер в байтах вместе с
записью о созданной ссылке (`file_link`). Сохранённый размер SHALL позволять
вычислить суммарный размер разложенных файлов загрузки — он служит фолбэком
размера раздачи, когда торрента нет в qBittorrent (например, состояние
`orphaned`, где файлы библиотеки — последняя копия данных). Запись размера MUST
NOT влиять на инвариант неприкосновенности источника (по-прежнему только
`mkdir`/`link`/копия в цель).
#### Scenario: Размер сохраняется при линковке
- **WHEN** раскладка создаёт хардлинк (или копию при copy-fallback) файла
- **THEN** размер этого файла в байтах сохраняется в записи ссылки `file_link`
#### Scenario: Суммарный размер доступен без торрента
- **WHEN** у загрузки есть разложенные файлы, а её торрента нет в qBittorrent
- **THEN** суммарный размер разложенных файлов доступен как размер раздачи для
показа в карточке
@@ -0,0 +1,28 @@
# live-status Specification (delta)
## MODIFIED Requirements
### Requirement: Состав телеметрии
Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость загрузки,
ETA и общий размер раздачи (total size); а для сидирующих раздач дополнительно —
рейтинг, число сидов и пиров, объём отданного и скорость отдачи. Общий размер
SHALL быть доступен для любой раздачи, присутствующей в снимке (не только
сидирующей). Значения SHALL извлекаться из ответа qBittorrent `/torrents/info`
без дополнительного сетевого вызова.
#### Scenario: Телеметрия качающейся задачи
- **WHEN** торрент задачи находится в состоянии загрузки
- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA
#### Scenario: Общий размер доступен для любой раздачи в снимке
- **WHEN** торрент задачи присутствует в снимке в любом состоянии
- **THEN** в телеметрии для неё доступен общий размер раздачи
#### Scenario: Телеметрия сидирующей задачи
- **WHEN** торрент задачи завершён и раздаётся
- **THEN** в снимке для неё доступны рейтинг, число сидов/пиров, объём
отданного и скорость отдачи
@@ -0,0 +1,79 @@
# web-ui Specification (delta)
## ADDED Requirements
### Requirement: Обзор жизненного цикла в карточке списка
Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о
судьбе раздачи: метку `ID:` перед копируемым идентификатором загрузки, дату
добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки
MUST NOT показываться в карточке списка — он доступен на странице `/download/{id}`.
Дата добавления SHALL показываться всегда как абсолютная дата и относительная
давность (например «`2026-06-30 · 5 дней назад`»); источником SHALL быть время
добавления раздачи в источник (`source_added_at`, qBittorrent `added_on`) с
фолбэком на время создания загрузки (`created_at`), согласованным с порядком
списка.
Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в
снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».
Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
если неизвестно ни то, ни другое — прочерк «—».
#### Scenario: Метка идентификатора
- **WHEN** рендерится карточка загрузки в списке
- **THEN** перед значением `download.id` показана метка «ID:», а кнопка
копирования копирует именно `download.id`
#### Scenario: Дата добавления показана всегда
- **WHEN** рендерится любая карточка списка
- **THEN** в ней показана дата добавления раздачи абсолютной датой и
относительной давностью
- **AND** если `source_added_at` неизвестно, используется `created_at`
#### Scenario: Рейтинг из живого снимка
- **WHEN** торрент загрузки присутствует в живом снимке
- **THEN** в карточке показан его рейтинг отдачи
- **AND** если торрента в снимке нет, рейтинг показан прочерком «—»
#### Scenario: Размер с фолбэком на разложенные файлы
- **WHEN** торрент загрузки присутствует в живом снимке
- **THEN** размер раздачи в карточке берётся из общего размера торрента
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
размер берётся из суммарного размера этих файлов
#### Scenario: Контекст не в карточке
- **WHEN** у загрузки есть переданный контекст
- **THEN** он не показывается в карточке списка, но доступен на странице
`/download/{id}`
## MODIFIED Requirements
### Requirement: Клиентские взаимодействия без сборки
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
фреймворков: копирование идентификатора загрузки (vanilla JS). Основной
копируемый идентификатор в карточке списка и в шапке страницы просмотра SHALL
быть `download.id` (ULID) — тот же ключ, что пишется в логи (`download_id`). Все
действия над загрузкой SHALL выполняться через формы/htmx (раундтрип на сервер),
без клиентского пересчёта доменного состояния.
#### Scenario: Копирование идентификатора загрузки
- **WHEN** пользователь нажимает кнопку копирования рядом с идентификатором
загрузки (`download.id`) в карточке списка или шапке страницы просмотра
- **THEN** значение `download.id` копируется в буфер обмена без перезагрузки
страницы
#### Scenario: Действия только через раундтрип
- **WHEN** пользователь выполняет действие над загрузкой
- **THEN** оно исполняется формой/htmx-запросом на сервер, без клиентского
пересчёта доменного состояния
@@ -0,0 +1,37 @@
## 1. Живой размер раздачи (снимок)
- [x] 1.1 `internal/qbt`: добавить поле `TotalSize int64 \`json:"total_size"\`` в `Torrent`
- [x] 1.2 `internal/worker`: добавить `TotalSize int64` в `Live`, пробросить в `liveFrom`
- [x] 1.3 `internal/worker/live_test.go`: покрыть проброс `TotalSize` в снимок
## 2. Размер разложенных файлов (БД)
- [x] 2.1 Goose-миграция `internal/store/migrations`: `ALTER TABLE file_link ADD COLUMN size INTEGER NOT NULL DEFAULT 0` (+ down)
- [x] 2.2 `internal/store`: писать `size` при вставке `file_link` (layouter передаёт размер файла, известный на момент линковки)
- [x] 2.3 `internal/store`: метод чтения суммарного размера разложенных файлов по id загрузок (батч по странице, как `attachInfohashes`)
- [x] 2.4 `internal/layout` (или вызывающий раскладку): передать размер файла (`os.Stat`/`qbt.File.Size`) в запись `file_link`
- [x] 2.5 Тест: запись `file_link.size` при линковке и copy-fallback; `SUM(size)` по загрузке
- [x] 2.6 Обновить ER-схему `docs/specs/database.md` (колонка `file_link.size`)
## 3. View-model и форматтеры (httpapi)
- [x] 3.1 `downloadView`: добавить поля `Added`, `AddedAgo`, `Size`, `Ratio` (строки для шаблона); убрать зависимость карточки от `Context`
- [x] 3.2 Форматтер даты добавления: абсолют `2006-01-02` в TZ `Europe/Moscow`, базис `source_added_at` → фолбэк `created_at`
- [x] 3.3 Форматтер относительной давности («N дней/часов назад») от `now` в TZ сервера
- [x] 3.4 `toView`/сборка карточки: размер (снимок `TotalSize` → фолбэк `SUM(file_link.size)` → «—»), рейтинг (`fmtRatio` из снимка → «—»), переиспользовать `fmtBytes`
- [x] 3.5 `handleIndex`: читать `liveFor(d)` для всех карточек страницы, а не только `downloading`; прокинуть суммарные размеры файлов в сборку карточек
- [x] 3.6 Тесты httpapi: дата (с фолбэком на `created_at`), размер (снимок и фолбэк по файлам, и «—»), рейтинг («—» без снимка)
## 4. Шаблон карточки
- [x] 4.1 `web/templates/index.html`: удалить спойлер контекста из карточки
- [x] 4.2 `web/templates/index.html`: метка `ID:` перед идентификатором (кнопка копирования копирует `download.id`)
- [x] 4.3 `web/templates/index.html`: мета-строка карточки (дата · размер · рейтинг), при необходимости вынести в партиал
- [x] 4.4 Проверить деградацию: значения «—» когда данных нет, вёрстка не ломается
## 5. Проверка
- [x] 5.1 `task lint` и `task test` зелёные
- [x] 5.2 Ручная проверка карточек в разных состояниях (downloading / done+сидирование / orphaned) — дата, размер, рейтинг отображаются корректно
- [x] 5.3 `openspec validate download-card-lifecycle-overview --strict`
- [x] 5.4 Ревью кода перед archive (второй чекпоинт)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-04
@@ -0,0 +1,177 @@
## Context
Экран ревью (`web/templates/review.html` + `internal/httpapi/review.go`) сейчас
состоит из трёх секций:
- **Догадка** — тип (переключатель movie↔series, POST `/type`), название, год;
- **Источник совпадения** — единый список вариантов (`.Sources`); у каждого
не-активного варианта — нативный `<details>` с предпросмотром раскладки, и
отдельная кнопка «выбрать» (POST `/candidate` или `/nobase`); ниже — форма
ручного добавления (POST `/source`);
- **Раскладка** — та же таблица предпросмотра, но для активного источника.
Все действия — обычные `<form method=post>` c PRG-редиректом (`reviewAction`
`redirectReview`, `303`). htmx подключён (`review.html:8`), но на странице ревью
не используется; на других страницах он уже применяется для фрагментов
(`hx-get .../progress`, `every 3s`).
Домен уже отдаёт всё нужное: `worker.ReviewData` строит `Sources []SourceOption`
(нейронка + кандидаты), у каждого — эффективные поля и эфемерный предпросмотр;
верхнеуровневые поля `reviewView` (`Title/Year/Files/...`) уже соответствуют
**активному** источнику. Инвариант «превью == применённое» обеспечивается тем,
что выбор источника пишет те же пины, что показаны в превью.
## Goals / Non-Goals
**Goals:**
- Слить три секции в один блок: список вариантов (радио) → инфо о выбранном →
предпросмотр раскладки выбранного.
- Выбор варианта — одним кликом/тапом по строке; инфо и предпросмотр
обновляются немедленно, без полной перезагрузки (htmx частичный своп блока).
- Тип показывать read-only; убрать переключатель типа с веб-экрана.
- Сохранить: ручное добавление источника, инвариант «превью == применённое»,
тонкость транспорта (доменная логика в `worker` не трогается).
**Non-Goals:**
- Менять доменный слой `internal/worker/review.go` (выбор источника, построение
плана/превью остаются как есть).
- Менять команду `SetType` в домене и её доступность в Telegram (убираем только
веб-контрол).
- Клиентский рефреймворк/сборка. Оптимизация «не считать превью для не-активных
источников» — отдельная будущая задача, не входит сюда.
## Decisions
### Решение 1: htmx частичный своп единого блока (не полная перезагрузка)
Выделяем единый блок в партиал `web/templates/partials/review_source_block.html`
с корневым контейнером `id="source-block"`. Партиал рендерится из того же
`reviewView`: список радио из `.Sources`, инфо — из верхнеуровневых полей
активного источника (`.Title/.OriginalTitle/.Year/.IsSeries/.SeasonSummary`),
предпросмотр — из `.Files`.
Радиокнопка варианта несёт htmx-атрибуты: `hx-trigger="change"`,
`hx-target="#source-block"`, `hx-swap="outerHTML"` и `hx-post` на эндпоинт
выбора. Клик по строке (label оборачивает кликабельную зону строки) переключает
радио → `change` → POST → сервер сохраняет выбор и возвращает **свежий партиал
блока** → htmx подменяет блок. Инфо и предпросмотр в новом партиале уже
относятся к новому активному источнику.
Все радио вариантов имеют **общий `name`** для взаимной эксклюзивности;
кандидатские несут `value`=`candidate_id` и постят на `/candidate`, нейронка —
пустое `value` и постит на `/nobase` (тот `candidate_id` игнорирует).
**Внешняя ссылка «запись ↗»** у кандидата (открывается в новой вкладке) НЕ
должна попадать в кликабельную зону label — иначе клик по ссылке заодно
переключит источник. Выносим ссылку из `<label>` (или гасим всплытие клика),
чтобы «перейти к записи» и «выбрать источник» не конфликтовали.
**Почему так, а не клиентское переключение:** сохранение выбора — доменная
операция (пишет override/пины), поэтому нужен раундтрип; после него активный
источник и его превью пересчитываются на сервере единой логикой (инвариант
«превью == применённое» держится сам собой). Чистый клиентский свитч потребовал
бы дублировать превью-логику и рассинхронизировался бы с применением.
**Альтернатива (отклонено):** заранее рендерить инфо+превью всех источников и
показывать активный через CSS/JS без запроса. Отклонено: выбор не сохранялся бы,
«Применить» не знал бы что применять, и вернулась бы рассинхронизация
превью/применения.
### Решение 2: эндпоинты выбора становятся htmx-aware, без новых роутов
Переиспользуем существующие POST-эндпоинты `/candidate`, `/nobase`, `/source`.
Радио кандидата постит на `/candidate` (поле `candidate_id` = value радио), радио
нейронки — на `/nobase`, форма ручного добавления — на `/source` (тоже
`hx-post`, target = `#source-block`).
Хендлеры (`handleChooseCandidate`, `handleNoBase`, `handleAddSource`) после
успешной доменной операции определяют htmx-запрос по заголовку `HX-Request` и:
- при htmx — перечитывают `ReviewData`, рендерят **партиал блока** (`200`);
- без htmx (фолбек) — как сейчас, PRG-редирект на `/review/{id}`.
Это **новый паттерн** для проекта: существующие живые партиалы (`progress`,
`seeding`) работают через отдельные GET-роуты `/fragments/...` с htmx-поллингом,
а не через ветвление одного POST-эндпоинта по `HX-Request` — так что чтение
`r.Header.Get("HX-Request")` вводится здесь впервые. Сам механизм рендера одного
партиала уже есть: `server.render(w, "<name>", data)` вызывает
`ExecuteTemplate` по имени define (как `render(w, "progress", …)`), никаких
правок в `render` не нужно.
Ошибку (напр. невалидный ручной ввод) на htmx-пути рендерим тем же партиалом с
баннером ошибки **внутри блока** и **без смены активного источника**
(перечитанный `ReviewData` отражает прежний матч). Чтобы не задваивать баннер с
уже существующим верхним `?err=` (его показывают PRG-редиректы других действий —
apply/defer/cancel), ошибку блока держим в **отдельном поле** view (напр.
`BlockError`), которое рендерит только партиал; верхний `.Error` остаётся для
полностраничного `?err=`. Общий помощник — по образцу `reviewAction`, но с
ветвлением htmx/redirect (напр. `reviewBlockAction`).
**Почему не новый единый роут `/select`:** минимизируем изменения и
переиспользуем валидацию и доменные вызовы; семантика «кандидат» vs «без базы»
уже разведена по эндпоинтам.
### Решение 3: тип — read-only, веб-контрол `/type` убираем
Из шаблона убираем форму переключения типа; тип показываем текстом
(`фильм`/`сериал`) в инфо-части. Роут `/type` и `handleSetType` в `httpapi`
удаляем (веб — единственный их потребитель; Telegram вызывает `worker.SetType`
напрямую, доменный метод остаётся). Перед удалением — убедиться grep'ом, что на
`/type`/`handleSetType` в `httpapi` больше никто не ссылается.
Корректировать тип пользователь по-прежнему может через «Уточнить» (мягкая
подсказка «это сериал»), что согласовано в модифицированном требовании «Команды
ревью и их эффекты».
### Решение 4: инфо-часть — состав полей
Инфо-часть выбранного источника: тип (read-only), название, ориг. название, год,
для сериала — **сводка сезонов**, плюс зарезервированное место под режиссёра
(пустой прочерк).
Сезон в плане задан **на каждом файле** (`recognize.PlanFile.Season *int`), а не
на плане целиком — одна раздача может быть многосезонным паком. Поэтому в
инфо-части показываем компактную сводку по различным сезонам эпизодных файлов
эффективного плана (`rd.Plan`):
- один сезон → «Сезон 2»;
- несколько подряд → «Сезоны 1–3» (диапазон), с разрывами → список «Сезоны 1,
3, 4»;
- только спецвыпуски (`Season == nil`/0) → «Спецвыпуски».
Сводку собираем в транспорте из `rd.Plan.Files` по файлам с ролью `episode`
(игнор-файлы `applyOverrides` уже пометил ролью `ignore` — они выпадают из
фильтра). `PlanFile.Season``*int`, где и `nil`, и `*0` трактуются как
спецвыпуск. Добавляем в `reviewView` строковое поле `SeasonSummary string`
(пусто для фильма). Номер сезона построчно и так виден в предпросмотре раскладки
(`.../Season 02/...`); сводка — это верхнеуровневая подпись-страховка «что за
сезоны в раздаче», обычный случай — один сезон.
## Risks / Trade-offs
- **[Выбор одним кликом требует htmx/JS — нет чистого no-JS фолбека выбора]** →
htmx всегда загружен, инструмент однопользовательский домашний; прочие
действия (Применить/Уточнить/Позже/Отклонить) остаются обычными формами и
работают без JS; хендлеры сохраняют redirect-фолбек, так что без htmx выбор
деградирует до перезагрузки, а не ломается (radio без submit-кнопки, впрочем,
без JS не отправится — это осознанный компромисс UI-мелочи).
- **[Случайный клик меняет сохранённый матч]** → эффект не разрушительный
(хардлинки только по «Применить»), возврат — один клик по другому варианту.
- **[Панель действий вне свопаемого блока может рассинхрониться]** (`Применить`
зависит от `HasLinks`) → на практике план/превью есть всегда, когда есть
активный источник, поэтому набор действий при переключении источников не
меняется; если понадобится — обновляем панель через `hx-swap-oob` из того же
партиала.
- **[Мобильный тап]** → строка-вариант должна иметь крупную кликабельную зону
(label оборачивает всю строку), проверить на узком экране.
## Migration Plan
Чистая замена рендера страницы ревью; данные/БД не затрагиваются, миграций нет.
Откат — возврат шаблона и хендлеров. Деплой — обычная пересборка бинаря.
## Open Questions
- Нет — открытые вопросы закрыты (сезон показываем сводкой, см. Решение 4).

Some files were not shown because too many files have changed in this diff Show More